SHA256
Обновить pairing устройств и доработать ESP32 UI
This commit is contained in:
+785
-15
@@ -7,6 +7,7 @@
|
||||
#include <lvgl.h>
|
||||
#include <Arduino_GFX_Library.h>
|
||||
#include <TouchDrvCSTXXX.hpp>
|
||||
#include <mbedtls/gcm.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#include <mbedtls/base64.h>
|
||||
#include <Ed25519.h>
|
||||
@@ -93,6 +94,8 @@ enum Screen {
|
||||
SCREEN_HOME,
|
||||
SCREEN_WALLET_QR,
|
||||
SCREEN_SETTINGS_MENU,
|
||||
SCREEN_PAIRING_REQUESTS,
|
||||
SCREEN_PAIRING_REQUEST_DETAIL,
|
||||
SCREEN_WIFI,
|
||||
SCREEN_SERVER,
|
||||
SCREEN_ACCOUNT,
|
||||
@@ -121,6 +124,7 @@ enum ActionId {
|
||||
ACTION_NONE,
|
||||
ACTION_OPEN_SETTINGS,
|
||||
ACTION_OPEN_WALLET_QR,
|
||||
ACTION_OPEN_PAIRING_REQUESTS,
|
||||
ACTION_OPEN_WIFI,
|
||||
ACTION_OPEN_SERVER,
|
||||
ACTION_OPEN_ACCOUNT,
|
||||
@@ -145,6 +149,9 @@ enum ActionId {
|
||||
ACTION_BACK_SECRET_MENU,
|
||||
ACTION_BACK_ACCOUNT,
|
||||
ACTION_REFRESH_BALANCE,
|
||||
ACTION_PAIRING_REFRESH,
|
||||
ACTION_PAIRING_APPROVE,
|
||||
ACTION_PAIRING_REJECT,
|
||||
ACTION_REGISTER_ACCOUNT,
|
||||
ACTION_REGISTER_ACCOUNT_EXECUTE,
|
||||
ACTION_HOMESERVER_PDA_ACTION,
|
||||
@@ -226,8 +233,14 @@ struct SimpleWebSocketClient {
|
||||
uint16_t port = 0;
|
||||
};
|
||||
|
||||
static const char *kMenuItems[] = {"1. Wi-Fi", "2. Server", "3. Account"};
|
||||
static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]);
|
||||
struct PairingRequestUiItem {
|
||||
String pairingId;
|
||||
String requesterSessionKey;
|
||||
int requesterSessionType = 0;
|
||||
String requesterClientPlatform;
|
||||
String shortCode;
|
||||
uint64_t expiresAtMs = 0;
|
||||
};
|
||||
|
||||
static lv_disp_draw_buf_t gDrawBuf;
|
||||
static lv_color_t *gBuf1 = nullptr;
|
||||
@@ -328,6 +341,10 @@ static unsigned long gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
|
||||
static uint32_t gWsRequestCounter = 1;
|
||||
static int64_t gShineServerTimeOffsetMs = 0;
|
||||
static SimpleWebSocketClient gShineWs;
|
||||
static std::vector<PairingRequestUiItem> gPairingRequests;
|
||||
static int gSelectedPairingRequestIndex = -1;
|
||||
static String gPairingStatusMessage = "Refresh requests";
|
||||
static bool gPairingBusy = false;
|
||||
|
||||
struct DerivedKeyInfo {
|
||||
String title;
|
||||
@@ -409,7 +426,25 @@ static String bytesToBase64String(const uint8_t *data, size_t len);
|
||||
static String jsonEscape(const String &value);
|
||||
static bool jsonStringField(const String &json, const String &field, String &valueOut);
|
||||
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
|
||||
static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut);
|
||||
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
|
||||
static String formatPairingShortCode(const String &value);
|
||||
static bool pairingMenuVisible();
|
||||
static int settingsMenuCount();
|
||||
static String settingsMenuLabel(int itemIndex);
|
||||
static ActionId settingsMenuAction(int itemIndex);
|
||||
static String pairingSessionKindLabel(int sessionType);
|
||||
static String pairingSessionNameLabel(const PairingRequestUiItem &item);
|
||||
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut);
|
||||
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut);
|
||||
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut);
|
||||
static bool refreshPairingRequests(String &errorOut);
|
||||
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out);
|
||||
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
|
||||
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
|
||||
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut);
|
||||
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut);
|
||||
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut);
|
||||
static String shineWsUrl();
|
||||
static String shineHomeLine();
|
||||
static String balanceHomeLine();
|
||||
@@ -855,6 +890,195 @@ static bool jsonBoolField(const String &json, const String &field, bool &valueOu
|
||||
return false;
|
||||
}
|
||||
|
||||
static String formatPairingShortCode(const String &value) {
|
||||
String digits;
|
||||
for (int i = 0; i < (int)value.length(); ++i) {
|
||||
char ch = value.charAt(i);
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
digits += ch;
|
||||
}
|
||||
}
|
||||
while (digits.length() < 10) {
|
||||
digits = String("0") + digits;
|
||||
}
|
||||
if (digits.length() > 10) {
|
||||
digits = digits.substring(0, 10);
|
||||
}
|
||||
String out;
|
||||
for (int i = 0; i < 10; i += 2) {
|
||||
if (!out.isEmpty()) out += " ";
|
||||
out += digits.substring(i, i + 2);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool pairingMenuVisible() {
|
||||
return gShineAuthenticated;
|
||||
}
|
||||
|
||||
static int settingsMenuCount() {
|
||||
return pairingMenuVisible() ? 4 : 3;
|
||||
}
|
||||
|
||||
static String settingsMenuLabel(int itemIndex) {
|
||||
if (pairingMenuVisible()) {
|
||||
switch (itemIndex) {
|
||||
case 0: return "1. Device requests";
|
||||
case 1: return "2. Wi-Fi";
|
||||
case 2: return "3. Server";
|
||||
case 3: return "4. Account";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
switch (itemIndex) {
|
||||
case 0: return "1. Wi-Fi";
|
||||
case 1: return "2. Server";
|
||||
case 2: return "3. Account";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
static ActionId settingsMenuAction(int itemIndex) {
|
||||
if (pairingMenuVisible()) {
|
||||
switch (itemIndex) {
|
||||
case 0: return ACTION_OPEN_PAIRING_REQUESTS;
|
||||
case 1: return ACTION_OPEN_WIFI;
|
||||
case 2: return ACTION_OPEN_SERVER;
|
||||
case 3: return ACTION_OPEN_ACCOUNT;
|
||||
default: return ACTION_NONE;
|
||||
}
|
||||
}
|
||||
switch (itemIndex) {
|
||||
case 0: return ACTION_OPEN_WIFI;
|
||||
case 1: return ACTION_OPEN_SERVER;
|
||||
case 2: return ACTION_OPEN_ACCOUNT;
|
||||
default: return ACTION_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
static String pairingSessionKindLabel(int sessionType) {
|
||||
return sessionType == 50 ? "Wallet session" : "Client session";
|
||||
}
|
||||
|
||||
static String pairingSessionNameLabel(const PairingRequestUiItem &item) {
|
||||
String value = item.requesterClientPlatform;
|
||||
value.trim();
|
||||
return value.isEmpty() ? "Unknown client" : value;
|
||||
}
|
||||
|
||||
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut) {
|
||||
String needle = "\"" + field + "\"";
|
||||
int keyPos = json.indexOf(needle);
|
||||
if (keyPos < 0) return false;
|
||||
int colon = json.indexOf(':', keyPos + needle.length());
|
||||
if (colon < 0) return false;
|
||||
int start = json.indexOf('[', colon + 1);
|
||||
if (start < 0) return false;
|
||||
bool inString = false;
|
||||
bool escape = false;
|
||||
int depth = 0;
|
||||
for (int i = start; i < (int)json.length(); ++i) {
|
||||
char ch = json.charAt(i);
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch == '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch == '[') depth++;
|
||||
if (ch == ']') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
startOut = start;
|
||||
endOut = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut) {
|
||||
int start = json.indexOf('{', startIndex);
|
||||
if (start < 0) return false;
|
||||
bool inString = false;
|
||||
bool escape = false;
|
||||
int depth = 0;
|
||||
for (int i = start; i < (int)json.length(); ++i) {
|
||||
char ch = json.charAt(i);
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch == '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch == '{') depth++;
|
||||
if (ch == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
endOut = i;
|
||||
objectOut = json.substring(start, i + 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut) {
|
||||
itemsOut.clear();
|
||||
errorOut = "";
|
||||
uint64_t status = 0;
|
||||
if (!jsonInt64Field(json, "status", status) || status != 200) {
|
||||
errorOut = "ListTrustedDeviceLoginRequests rejected";
|
||||
return false;
|
||||
}
|
||||
int arrayStart = 0;
|
||||
int arrayEnd = 0;
|
||||
if (!findJsonArrayBounds(json, "requests", arrayStart, arrayEnd)) {
|
||||
errorOut = "requests array missing";
|
||||
return false;
|
||||
}
|
||||
int cursor = arrayStart + 1;
|
||||
while (cursor < arrayEnd) {
|
||||
String objectJson;
|
||||
int objectEnd = 0;
|
||||
if (!extractJsonObjectAt(json, cursor, objectEnd, objectJson) || objectEnd > arrayEnd) {
|
||||
break;
|
||||
}
|
||||
PairingRequestUiItem item;
|
||||
uint64_t sessionType = 0;
|
||||
uint64_t expiresAtMs = 0;
|
||||
jsonStringField(objectJson, "pairingId", item.pairingId);
|
||||
jsonStringField(objectJson, "requesterSessionKey", item.requesterSessionKey);
|
||||
jsonStringField(objectJson, "requesterClientPlatform", item.requesterClientPlatform);
|
||||
jsonStringField(objectJson, "shortCode", item.shortCode);
|
||||
jsonInt64Field(objectJson, "requesterSessionType", sessionType);
|
||||
jsonInt64Field(objectJson, "expiresAtMs", expiresAtMs);
|
||||
item.requesterSessionType = (int)sessionType;
|
||||
item.expiresAtMs = expiresAtMs;
|
||||
if (!item.pairingId.isEmpty()) {
|
||||
itemsOut.push_back(item);
|
||||
}
|
||||
cursor = objectEnd + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) {
|
||||
hostOut = "";
|
||||
pathOut = "/";
|
||||
@@ -3177,6 +3401,289 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool refreshPairingRequests(String &errorOut) {
|
||||
errorOut = "";
|
||||
String authError;
|
||||
if (!ensureShineSessionAuthenticated(authError)) {
|
||||
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
|
||||
return false;
|
||||
}
|
||||
String response;
|
||||
if (!shineWsRequest(gShineWs, "ListTrustedDeviceLoginRequests", "{}", response, SHINE_RPC_TIMEOUT_MS)) {
|
||||
errorOut = "ListTrustedDeviceLoginRequests failed";
|
||||
return false;
|
||||
}
|
||||
std::vector<PairingRequestUiItem> items;
|
||||
if (!parsePairingRequestsResponse(response, items, errorOut)) {
|
||||
return false;
|
||||
}
|
||||
gPairingRequests = items;
|
||||
if (gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
|
||||
gSelectedPairingRequestIndex = -1;
|
||||
}
|
||||
gPairingStatusMessage = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out) {
|
||||
static const uint8_t kEd25519Pkcs8Prefix[] = {
|
||||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
|
||||
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20
|
||||
};
|
||||
std::vector<uint8_t> raw;
|
||||
raw.reserve(sizeof(kEd25519Pkcs8Prefix) + 32);
|
||||
raw.insert(raw.end(), kEd25519Pkcs8Prefix, kEd25519Pkcs8Prefix + sizeof(kEd25519Pkcs8Prefix));
|
||||
raw.insert(raw.end(), seed32, seed32 + 32);
|
||||
pkcs8B64Out = bytesToBase64String(raw.data(), raw.size());
|
||||
return !pkcs8B64Out.isEmpty();
|
||||
}
|
||||
|
||||
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
|
||||
payloadJsonOut = "";
|
||||
errorOut = "";
|
||||
uint8_t deviceSeed[32] = {};
|
||||
uint8_t devicePub[32] = {};
|
||||
uint8_t deviceSec[64] = {};
|
||||
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
|
||||
errorOut = "Failed to derive device key";
|
||||
return false;
|
||||
}
|
||||
String devicePkcs8;
|
||||
if (!buildPkcs8FromSeed32(deviceSeed, devicePkcs8)) {
|
||||
errorOut = "Failed to encode device key";
|
||||
return false;
|
||||
}
|
||||
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-pairing-transfer\",\"login\":\"") + jsonEscape(gLoginValue)
|
||||
+ "\",\"mode\":\"device-only\",\"keys\":{\"deviceKey\":\"" + jsonEscape(devicePkcs8)
|
||||
+ "\",\"blockchainKey\":\"\",\"rootKey\":\"\"},\"payloadType\":1,\"createdAtMs\":"
|
||||
+ String((unsigned long long)shineNowMs()) + "}";
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
|
||||
payloadJsonOut = "";
|
||||
errorOut = "";
|
||||
uint8_t deviceSeed[32] = {};
|
||||
uint8_t devicePub[32] = {};
|
||||
uint8_t deviceSec[64] = {};
|
||||
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
|
||||
errorOut = "Failed to derive device key";
|
||||
return false;
|
||||
}
|
||||
|
||||
String wsUrl = shineWsUrl();
|
||||
if (wsUrl.isEmpty()) {
|
||||
errorOut = "Shine server is not configured";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t storageRaw[32];
|
||||
for (size_t i = 0; i < sizeof(storageRaw); ++i) storageRaw[i] = (uint8_t)esp_random();
|
||||
String storagePwd = bytesToBase64String(storageRaw, sizeof(storageRaw));
|
||||
|
||||
SimpleWebSocketClient tempWs;
|
||||
String wsError;
|
||||
if (!ensureWebSocketConnected(tempWs, wsUrl, wsError)) {
|
||||
errorOut = wsError.isEmpty() ? "Delegated session WS connect failed" : wsError;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
do {
|
||||
String authResp;
|
||||
if (!shineWsRequest(tempWs, "AuthChallenge",
|
||||
String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}",
|
||||
authResp, SHINE_RPC_TIMEOUT_MS)) {
|
||||
errorOut = "Delegated AuthChallenge failed";
|
||||
break;
|
||||
}
|
||||
uint64_t statusCode = 0;
|
||||
String authNonce;
|
||||
if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) {
|
||||
errorOut = "Delegated AuthChallenge rejected";
|
||||
break;
|
||||
}
|
||||
|
||||
uint64_t timeMs = shineNowMs();
|
||||
String preimage = String("AUTH_CREATE_SESSION:") + gLoginValue + ":" + item.requesterSessionKey + ":" + storagePwd + ":" + String((unsigned long long)timeMs) + ":" + authNonce;
|
||||
uint8_t signature[64] = {};
|
||||
crypto_sign_ed25519_detached(signature, nullptr,
|
||||
reinterpret_cast<const unsigned char *>(preimage.c_str()),
|
||||
preimage.length(), deviceSec);
|
||||
String clientPlatform = item.requesterClientPlatform;
|
||||
clientPlatform.trim();
|
||||
if (clientPlatform.isEmpty()) clientPlatform = "Wallet session";
|
||||
String createReq = String("{\"login\":\"") + jsonEscape(gLoginValue)
|
||||
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
|
||||
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
|
||||
+ "\",\"timeMs\":" + String((unsigned long long)timeMs)
|
||||
+ ",\"authNonce\":\"" + jsonEscape(authNonce)
|
||||
+ "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32))
|
||||
+ "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
|
||||
+ "\",\"sessionType\":50"
|
||||
+ ",\"clientPlatform\":\"" + jsonEscape(clientPlatform)
|
||||
+ "\",\"clientInfo\":\"Wallet session approved via ESP32 pairing\"}";
|
||||
String createResp;
|
||||
if (!shineWsRequest(tempWs, "CreateAuthSession", createReq, createResp, SHINE_RPC_TIMEOUT_MS)) {
|
||||
errorOut = "Delegated CreateAuthSession failed";
|
||||
break;
|
||||
}
|
||||
String sessionId;
|
||||
if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", sessionId)) {
|
||||
errorOut = "Delegated CreateAuthSession rejected";
|
||||
break;
|
||||
}
|
||||
|
||||
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-session-attach\",\"login\":\"") + jsonEscape(gLoginValue)
|
||||
+ "\",\"session\":{\"sessionId\":\"" + jsonEscape(sessionId)
|
||||
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
|
||||
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
|
||||
+ "\",\"sessionType\":50,\"clientPlatform\":\"" + jsonEscape(clientPlatform)
|
||||
+ "\"},\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
|
||||
ok = true;
|
||||
} while (false);
|
||||
|
||||
closeWebSocket(tempWs);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut) {
|
||||
encryptedPayloadOut = "";
|
||||
errorOut = "";
|
||||
if (!requesterSessionKey.startsWith("ed25519/")) {
|
||||
errorOut = "Unsupported requesterSessionKey";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> requesterEdPub;
|
||||
if (!base64DecodeStd(requesterSessionKey.substring(8), requesterEdPub) || requesterEdPub.size() != 32) {
|
||||
errorOut = "Bad requester session public key";
|
||||
return false;
|
||||
}
|
||||
uint8_t requesterCurvePub[32] = {};
|
||||
if (crypto_sign_ed25519_pk_to_curve25519(requesterCurvePub, requesterEdPub.data()) != 0) {
|
||||
errorOut = "Failed to convert requester public key";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t ephPub[32] = {};
|
||||
uint8_t ephSec[32] = {};
|
||||
if (crypto_box_keypair(ephPub, ephSec) != 0) {
|
||||
errorOut = "Failed to create ephemeral key";
|
||||
return false;
|
||||
}
|
||||
uint8_t sharedSecret[32] = {};
|
||||
if (crypto_scalarmult(sharedSecret, ephSec, requesterCurvePub) != 0) {
|
||||
errorOut = "Failed to derive shared secret";
|
||||
return false;
|
||||
}
|
||||
uint8_t aesKey[32] = {};
|
||||
mbedtls_sha256(sharedSecret, sizeof(sharedSecret), aesKey, 0);
|
||||
|
||||
uint8_t iv[12] = {};
|
||||
for (size_t i = 0; i < sizeof(iv); ++i) iv[i] = (uint8_t)esp_random();
|
||||
|
||||
std::vector<uint8_t> plain(payloadJson.length());
|
||||
for (int i = 0; i < (int)payloadJson.length(); ++i) plain[i] = (uint8_t)payloadJson.charAt(i);
|
||||
std::vector<uint8_t> cipher(plain.size());
|
||||
uint8_t tag[16] = {};
|
||||
|
||||
mbedtls_gcm_context gcm;
|
||||
mbedtls_gcm_init(&gcm);
|
||||
bool ok = false;
|
||||
if (mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, 256) == 0
|
||||
&& mbedtls_gcm_crypt_and_tag(&gcm,
|
||||
MBEDTLS_GCM_ENCRYPT,
|
||||
plain.size(),
|
||||
iv, sizeof(iv),
|
||||
nullptr, 0,
|
||||
plain.data(),
|
||||
cipher.data(),
|
||||
sizeof(tag), tag) == 0) {
|
||||
std::vector<uint8_t> cipherWithTag = cipher;
|
||||
cipherWithTag.insert(cipherWithTag.end(), tag, tag + sizeof(tag));
|
||||
String envelopeJson = String("{\"v\":1,\"alg\":\"x25519-aes256-gcm\",\"ephPubB64\":\"")
|
||||
+ jsonEscape(bytesToBase64String(ephPub, sizeof(ephPub)))
|
||||
+ "\",\"ivB64\":\"" + jsonEscape(bytesToBase64String(iv, sizeof(iv)))
|
||||
+ "\",\"cipherB64\":\"" + jsonEscape(bytesToBase64String(cipherWithTag.data(), cipherWithTag.size()))
|
||||
+ "\",\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
|
||||
String payloadB64 = bytesToBase64String(reinterpret_cast<const uint8_t *>(envelopeJson.c_str()), envelopeJson.length());
|
||||
payloadB64.replace("+", "-");
|
||||
payloadB64.replace("/", "_");
|
||||
while (payloadB64.endsWith("=")) payloadB64.remove(payloadB64.length() - 1);
|
||||
encryptedPayloadOut = String("shine-esp-pairing-v1:") + payloadB64;
|
||||
ok = true;
|
||||
} else {
|
||||
errorOut = "AES-GCM encryption failed";
|
||||
}
|
||||
mbedtls_gcm_free(&gcm);
|
||||
sodium_memzero(sharedSecret, sizeof(sharedSecret));
|
||||
sodium_memzero(aesKey, sizeof(aesKey));
|
||||
sodium_memzero(ephSec, sizeof(ephSec));
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut) {
|
||||
errorOut = "";
|
||||
String payloadJson;
|
||||
if (item.requesterSessionType == 50) {
|
||||
if (!createDelegatedWalletSessionPayload(item, payloadJson, errorOut)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!buildPairingSecretsPayload(item, payloadJson, errorOut)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String encryptedPayload;
|
||||
if (!encryptPairingPayloadForRequester(item.requesterSessionKey, payloadJson, encryptedPayload, errorOut)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String authError;
|
||||
if (!ensureShineSessionAuthenticated(authError)) {
|
||||
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
|
||||
return false;
|
||||
}
|
||||
|
||||
String approveReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
|
||||
+ "\",\"encryptedPayload\":\"" + jsonEscape(encryptedPayload) + "\"}";
|
||||
String response;
|
||||
if (!shineWsRequest(gShineWs, "ApproveTrustedDeviceLogin", approveReq, response, SHINE_RPC_TIMEOUT_MS)) {
|
||||
errorOut = "ApproveTrustedDeviceLogin failed";
|
||||
return false;
|
||||
}
|
||||
uint64_t status = 0;
|
||||
if (!jsonInt64Field(response, "status", status) || status != 200) {
|
||||
errorOut = "ApproveTrustedDeviceLogin rejected";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut) {
|
||||
errorOut = "";
|
||||
String authError;
|
||||
if (!ensureShineSessionAuthenticated(authError)) {
|
||||
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
|
||||
return false;
|
||||
}
|
||||
String rejectReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
|
||||
+ "\",\"reason\":\"rejected_by_user\"}";
|
||||
String response;
|
||||
if (!shineWsRequest(gShineWs, "RejectTrustedDeviceLogin", rejectReq, response, SHINE_RPC_TIMEOUT_MS)) {
|
||||
errorOut = "RejectTrustedDeviceLogin failed";
|
||||
return false;
|
||||
}
|
||||
uint64_t status = 0;
|
||||
if (!jsonInt64Field(response, "status", status) || status != 200) {
|
||||
errorOut = "RejectTrustedDeviceLogin rejected";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ensureShineSessionAuthenticated(String &errorOut) {
|
||||
errorOut = "";
|
||||
String diagDetails;
|
||||
@@ -3788,6 +4295,10 @@ static int batteryPercentValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool batteryIsChargingNow() {
|
||||
return gPowerReady && gPower.isBatteryConnect() && gPower.isCharging();
|
||||
}
|
||||
|
||||
static int wifiSignalLevel() {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
return 0;
|
||||
@@ -3801,6 +4312,7 @@ static int wifiSignalLevel() {
|
||||
|
||||
static void drawTopStatusIndicators() {
|
||||
int batt = batteryPercentValue();
|
||||
bool charging = batteryIsChargingNow();
|
||||
String battText = batt >= 0 ? String(batt) + "%" : "--";
|
||||
|
||||
lv_obj_t *battLabel = lv_label_create(gRoot);
|
||||
@@ -3809,6 +4321,18 @@ static void drawTopStatusIndicators() {
|
||||
lv_obj_set_style_text_color(battLabel, lv_color_hex(0xC9D3DE), 0);
|
||||
lv_obj_set_pos(battLabel, 297, 18);
|
||||
|
||||
if (charging) {
|
||||
static const lv_point_t boltPoints[] = {
|
||||
{6, 0}, {1, 9}, {5, 9}, {2, 18}, {10, 7}, {6, 7}
|
||||
};
|
||||
lv_obj_t *chargeBolt = lv_line_create(gRoot);
|
||||
lv_line_set_points(chargeBolt, boltPoints, sizeof(boltPoints) / sizeof(boltPoints[0]));
|
||||
lv_obj_set_pos(chargeBolt, 337, 15);
|
||||
lv_obj_set_style_line_width(chargeBolt, 2, 0);
|
||||
lv_obj_set_style_line_color(chargeBolt, lv_color_hex(0xFFD34D), 0);
|
||||
lv_obj_set_style_line_rounded(chargeBolt, true, 0);
|
||||
}
|
||||
|
||||
lv_obj_t *battery = lv_obj_create(gRoot);
|
||||
lv_obj_set_size(battery, 32, 16);
|
||||
lv_obj_set_pos(battery, 349, 20);
|
||||
@@ -4170,6 +4694,22 @@ static void networkSelectCb(lv_event_t *event) {
|
||||
true);
|
||||
}
|
||||
|
||||
static void pairingSelectCb(lv_event_t *event) {
|
||||
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence) {
|
||||
return;
|
||||
}
|
||||
gLastHandledTouchSequence = gTouchSequence;
|
||||
gSuppressTouchUntilRelease = true;
|
||||
gBlockClick = true;
|
||||
|
||||
int index = static_cast<int>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(event)));
|
||||
if (index < 0 || index >= (int)gPairingRequests.size()) {
|
||||
return;
|
||||
}
|
||||
gSelectedPairingRequestIndex = index;
|
||||
showScreen(SCREEN_PAIRING_REQUEST_DETAIL);
|
||||
}
|
||||
|
||||
static void editorKeyCb(lv_event_t *event) {
|
||||
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence || !gInputTextArea) {
|
||||
return;
|
||||
@@ -4244,6 +4784,14 @@ static void actionButtonCb(lv_event_t *event) {
|
||||
case ACTION_OPEN_WALLET_QR:
|
||||
showScreen(SCREEN_WALLET_QR);
|
||||
break;
|
||||
case ACTION_OPEN_PAIRING_REQUESTS: {
|
||||
String error;
|
||||
if (!refreshPairingRequests(error)) {
|
||||
gPairingStatusMessage = error.isEmpty() ? "Failed to load requests" : error;
|
||||
}
|
||||
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||
break;
|
||||
}
|
||||
case ACTION_REGISTER_ACCOUNT:
|
||||
prepareRegisterAccountScreen();
|
||||
showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM);
|
||||
@@ -4401,6 +4949,44 @@ static void actionButtonCb(lv_event_t *event) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ACTION_PAIRING_REFRESH: {
|
||||
String error;
|
||||
if (!refreshPairingRequests(error)) {
|
||||
gPairingStatusMessage = error.isEmpty() ? "Failed to refresh requests" : error;
|
||||
}
|
||||
rebuildScreen();
|
||||
break;
|
||||
}
|
||||
case ACTION_PAIRING_APPROVE: {
|
||||
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
|
||||
String error;
|
||||
if (approvePairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
|
||||
gPairingStatusMessage = "Request approved";
|
||||
refreshPairingRequests(error);
|
||||
gSelectedPairingRequestIndex = -1;
|
||||
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||
} else {
|
||||
gPairingStatusMessage = error.isEmpty() ? "Approve failed" : error;
|
||||
rebuildScreen();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ACTION_PAIRING_REJECT: {
|
||||
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
|
||||
String error;
|
||||
if (rejectPairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
|
||||
gPairingStatusMessage = "Request rejected";
|
||||
refreshPairingRequests(error);
|
||||
gSelectedPairingRequestIndex = -1;
|
||||
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||
} else {
|
||||
gPairingStatusMessage = error.isEmpty() ? "Reject failed" : error;
|
||||
rebuildScreen();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ACTION_EDITOR_SAVE:
|
||||
applyEditorValue();
|
||||
break;
|
||||
@@ -4663,27 +5249,32 @@ static void drawSettingsMenu() {
|
||||
makeTitle("SETTINGS", 18, &lv_font_montserrat_24);
|
||||
makeBody("Swipe up/down to move. Swipe right to go home.", 56, 420);
|
||||
|
||||
int totalItems = settingsMenuCount();
|
||||
if (totalItems <= 2) {
|
||||
gSettingsScrollIndex = 0;
|
||||
} else if (gSettingsScrollIndex > totalItems - 2) {
|
||||
gSettingsScrollIndex = totalItems - 2;
|
||||
}
|
||||
if (gSettingsScrollIndex < 0) {
|
||||
gSettingsScrollIndex = 0;
|
||||
}
|
||||
|
||||
for (int visibleIndex = 0; visibleIndex < 2; ++visibleIndex) {
|
||||
int itemIndex = gSettingsScrollIndex + visibleIndex;
|
||||
if (itemIndex >= static_cast<int>(kMenuCount)) {
|
||||
if (itemIndex >= totalItems) {
|
||||
break;
|
||||
}
|
||||
|
||||
ActionId action = ACTION_NONE;
|
||||
if (itemIndex == 0) action = ACTION_OPEN_WIFI;
|
||||
if (itemIndex == 1) action = ACTION_OPEN_SERVER;
|
||||
if (itemIndex == 2) action = ACTION_OPEN_ACCOUNT;
|
||||
|
||||
makeButton(kMenuItems[itemIndex], 22, 132 + visibleIndex * 126, 436, 104,
|
||||
0x355C7D, action, &lv_font_montserrat_24);
|
||||
String label = settingsMenuLabel(itemIndex);
|
||||
makeButton(label.c_str(), 22, 132 + visibleIndex * 126, 436, 104,
|
||||
0x355C7D, settingsMenuAction(itemIndex), &lv_font_montserrat_24);
|
||||
}
|
||||
|
||||
lv_obj_t *hint = lv_label_create(gRoot);
|
||||
char hintText[48];
|
||||
snprintf(hintText, sizeof(hintText), "Items %d-%d of %d",
|
||||
gSettingsScrollIndex + 1,
|
||||
min(gSettingsScrollIndex + 2, static_cast<int>(kMenuCount)),
|
||||
static_cast<int>(kMenuCount));
|
||||
min(gSettingsScrollIndex + 2, totalItems),
|
||||
totalItems);
|
||||
lv_label_set_text(hint, hintText);
|
||||
lv_obj_set_style_text_font(hint, &lv_font_montserrat_16, 0);
|
||||
lv_obj_set_style_text_color(hint, lv_color_hex(0xA8B8C7), 0);
|
||||
@@ -4692,6 +5283,161 @@ static void drawSettingsMenu() {
|
||||
makeVersionTag();
|
||||
}
|
||||
|
||||
static void drawPairingRequestsScreen() {
|
||||
setRootStyle();
|
||||
makeTitle("REQUESTS:", 18, &lv_font_montserrat_24);
|
||||
lv_obj_t *titleLabel = lv_obj_get_child(gRoot, lv_obj_get_child_cnt(gRoot) - 1);
|
||||
if (titleLabel) {
|
||||
lv_obj_set_x(titleLabel, 194);
|
||||
}
|
||||
makeButton("REFRESH", 20, 18, 160, 44, 0x2A6F97, ACTION_PAIRING_REFRESH, &lv_font_montserrat_18);
|
||||
|
||||
lv_obj_t *countLabel = lv_label_create(gRoot);
|
||||
String countText = String((int)gPairingRequests.size());
|
||||
lv_label_set_text(countLabel, countText.c_str());
|
||||
lv_obj_set_style_text_font(countLabel, &lv_font_montserrat_36, 0);
|
||||
lv_obj_set_style_text_color(countLabel, lv_color_hex(0xD5DEE7), 0);
|
||||
lv_obj_align(countLabel, LV_ALIGN_TOP_RIGHT, -22, 10);
|
||||
|
||||
if (!gPairingStatusMessage.isEmpty()) {
|
||||
showMessageAt(gPairingStatusMessage, 72);
|
||||
}
|
||||
|
||||
lv_obj_t *panel = lv_obj_create(gRoot);
|
||||
lv_obj_set_size(panel, 440, 324);
|
||||
lv_obj_set_pos(panel, 20, gPairingStatusMessage.isEmpty() ? 84 : 104);
|
||||
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
|
||||
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(panel, 1, 0);
|
||||
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
|
||||
lv_obj_set_style_radius(panel, 14, 0);
|
||||
lv_obj_set_style_pad_all(panel, 10, 0);
|
||||
lv_obj_set_style_pad_row(panel, 10, 0);
|
||||
lv_obj_set_scroll_dir(panel, LV_DIR_VER);
|
||||
lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE);
|
||||
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
|
||||
if (gPairingRequests.empty()) {
|
||||
lv_obj_t *empty = lv_label_create(panel);
|
||||
lv_label_set_text(empty, "No active requests");
|
||||
lv_obj_set_width(empty, 392);
|
||||
lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_font(empty, &lv_font_montserrat_20, 0);
|
||||
lv_obj_set_style_text_color(empty, lv_color_hex(0xA8B8C7), 0);
|
||||
} else {
|
||||
for (int i = 0; i < (int)gPairingRequests.size(); ++i) {
|
||||
const PairingRequestUiItem &item = gPairingRequests[i];
|
||||
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
|
||||
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
|
||||
lv_obj_t *btn = lv_btn_create(panel);
|
||||
lv_obj_set_size(btn, 410, 146);
|
||||
lv_obj_set_style_radius(btn, 14, 0);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(0x203547), 0);
|
||||
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(btn, 2, 0);
|
||||
lv_obj_set_style_border_color(btn, lv_color_hex(0x4D6C82), 0);
|
||||
lv_obj_add_event_cb(btn, pairingSelectCb, LV_EVENT_CLICKED, reinterpret_cast<void *>(static_cast<uintptr_t>(i)));
|
||||
|
||||
lv_obj_t *codeLabel = lv_label_create(btn);
|
||||
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
|
||||
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
|
||||
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xFFFFFF), 0);
|
||||
lv_obj_set_pos(codeLabel, 18, 12);
|
||||
|
||||
lv_obj_t *nameLabel = lv_label_create(btn);
|
||||
lv_label_set_text(nameLabel, sessionNameText.c_str());
|
||||
lv_obj_set_width(nameLabel, 374);
|
||||
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
|
||||
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
|
||||
lv_obj_set_pos(nameLabel, 18, 56);
|
||||
|
||||
lv_obj_t *kindLabel = lv_label_create(btn);
|
||||
lv_label_set_text(kindLabel, sessionKindText.c_str());
|
||||
lv_obj_set_width(kindLabel, 374);
|
||||
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
|
||||
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
|
||||
lv_obj_set_pos(kindLabel, 18, 88);
|
||||
}
|
||||
}
|
||||
makeVersionTag();
|
||||
}
|
||||
|
||||
static void drawPairingRequestDetailScreen() {
|
||||
setRootStyle();
|
||||
makeTitle("REQUEST DETAIL", 18, &lv_font_montserrat_24);
|
||||
|
||||
if (gSelectedPairingRequestIndex < 0 || gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
|
||||
showMessageAt("Request not selected", 88);
|
||||
makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_OPEN_PAIRING_REQUESTS, &lv_font_montserrat_22);
|
||||
makeVersionTag();
|
||||
return;
|
||||
}
|
||||
|
||||
const PairingRequestUiItem &item = gPairingRequests[gSelectedPairingRequestIndex];
|
||||
String question = String("Connect session ") + pairingSessionNameLabel(item) + "?";
|
||||
String explain = item.requesterSessionType == 50
|
||||
? "Wallet session. No keys will be transferred."
|
||||
: "Client session. Only device key will be transferred. No additional keys will be sent.";
|
||||
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
|
||||
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
|
||||
|
||||
lv_obj_t *panel = lv_obj_create(gRoot);
|
||||
lv_obj_set_size(panel, 440, 248);
|
||||
lv_obj_set_pos(panel, 20, 86);
|
||||
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
|
||||
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(panel, 1, 0);
|
||||
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
|
||||
lv_obj_set_style_radius(panel, 14, 0);
|
||||
lv_obj_set_style_pad_all(panel, 16, 0);
|
||||
lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t *qLabel = lv_label_create(panel);
|
||||
lv_label_set_text(qLabel, question.c_str());
|
||||
lv_obj_set_width(qLabel, 404);
|
||||
lv_label_set_long_mode(qLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_font(qLabel, &lv_font_montserrat_22, 0);
|
||||
lv_obj_set_style_text_color(qLabel, lv_color_hex(0xFFFFFF), 0);
|
||||
lv_obj_set_pos(qLabel, 0, 0);
|
||||
|
||||
lv_obj_t *codeLabel = lv_label_create(panel);
|
||||
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
|
||||
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
|
||||
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xD5DEE7), 0);
|
||||
lv_obj_set_pos(codeLabel, 0, 48);
|
||||
|
||||
lv_obj_t *nameLabel = lv_label_create(panel);
|
||||
lv_label_set_text(nameLabel, sessionNameText.c_str());
|
||||
lv_obj_set_width(nameLabel, 404);
|
||||
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
|
||||
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
|
||||
lv_obj_set_pos(nameLabel, 0, 92);
|
||||
|
||||
lv_obj_t *kindLabel = lv_label_create(panel);
|
||||
lv_label_set_text(kindLabel, sessionKindText.c_str());
|
||||
lv_obj_set_width(kindLabel, 404);
|
||||
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
|
||||
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
|
||||
lv_obj_set_pos(kindLabel, 0, 124);
|
||||
|
||||
lv_obj_t *explainLabel = lv_label_create(panel);
|
||||
lv_label_set_text(explainLabel, explain.c_str());
|
||||
lv_obj_set_width(explainLabel, 404);
|
||||
lv_label_set_long_mode(explainLabel, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_font(explainLabel, &lv_font_montserrat_18, 0);
|
||||
lv_obj_set_style_text_color(explainLabel, lv_color_hex(0xB8C6D3), 0);
|
||||
lv_obj_set_pos(explainLabel, 0, 164);
|
||||
|
||||
makeButton("YES", 40, 364, 180, 76, 0x2A9D8F, ACTION_PAIRING_APPROVE, &lv_font_montserrat_22);
|
||||
makeButton("NO", 260, 364, 180, 76, 0x7D3A3A, ACTION_PAIRING_REJECT, &lv_font_montserrat_22);
|
||||
makeVersionTag();
|
||||
}
|
||||
|
||||
static lv_obj_t *makeNetworkButton(const char *text, int index, lv_coord_t y) {
|
||||
lv_obj_t *btn = lv_btn_create(gRoot);
|
||||
lv_obj_set_size(btn, 436, 52);
|
||||
@@ -5134,6 +5880,12 @@ static void rebuildScreen() {
|
||||
case SCREEN_SETTINGS_MENU:
|
||||
drawSettingsMenu();
|
||||
break;
|
||||
case SCREEN_PAIRING_REQUESTS:
|
||||
drawPairingRequestsScreen();
|
||||
break;
|
||||
case SCREEN_PAIRING_REQUEST_DETAIL:
|
||||
drawPairingRequestDetailScreen();
|
||||
break;
|
||||
case SCREEN_WIFI:
|
||||
drawWifiScreen();
|
||||
break;
|
||||
@@ -5198,8 +5950,8 @@ static void handleSettingsSwipe(SwipeDirection swipe) {
|
||||
|
||||
if (swipe == SWIPE_UP) {
|
||||
gSettingsScrollIndex++;
|
||||
if (gSettingsScrollIndex > static_cast<int>(kMenuCount) - 2) {
|
||||
gSettingsScrollIndex = static_cast<int>(kMenuCount) - 2;
|
||||
if (gSettingsScrollIndex > settingsMenuCount() - 2) {
|
||||
gSettingsScrollIndex = settingsMenuCount() - 2;
|
||||
}
|
||||
rebuildScreen();
|
||||
return;
|
||||
@@ -5221,6 +5973,18 @@ static void handleWifiSwipe(SwipeDirection swipe) {
|
||||
}
|
||||
}
|
||||
|
||||
static void handlePairingRequestsSwipe(SwipeDirection swipe) {
|
||||
if (swipe == SWIPE_RIGHT) {
|
||||
showScreen(SCREEN_SETTINGS_MENU);
|
||||
}
|
||||
}
|
||||
|
||||
static void handlePairingRequestDetailSwipe(SwipeDirection swipe) {
|
||||
if (swipe == SWIPE_RIGHT) {
|
||||
showScreen(SCREEN_PAIRING_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
static void handleServerSwipe(SwipeDirection swipe) {
|
||||
if (swipe == SWIPE_RIGHT) {
|
||||
showScreen(SCREEN_SETTINGS_MENU);
|
||||
@@ -5281,6 +6045,12 @@ static void handleSwipe(SwipeDirection swipe) {
|
||||
case SCREEN_SETTINGS_MENU:
|
||||
handleSettingsSwipe(swipe);
|
||||
break;
|
||||
case SCREEN_PAIRING_REQUESTS:
|
||||
handlePairingRequestsSwipe(swipe);
|
||||
break;
|
||||
case SCREEN_PAIRING_REQUEST_DETAIL:
|
||||
handlePairingRequestDetailSwipe(swipe);
|
||||
break;
|
||||
case SCREEN_WIFI:
|
||||
handleWifiSwipe(swipe);
|
||||
break;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
- реальное чтение баланса кошелька из `Solana RPC`;
|
||||
- проверка обязательных условий перед регистрацией;
|
||||
- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране;
|
||||
- прототип входящих запросов с подтверждением и отклонением;
|
||||
- живой экран заявок на подключение новых устройств через доверенную homeserver-сессию;
|
||||
- PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта);
|
||||
- базовые настройки, статус и главный экран;
|
||||
- сохранение `PDA` и `tx signature` после успешной регистрации.
|
||||
@@ -34,8 +34,8 @@
|
||||
|
||||
Что пока считается именно прототипом, а не финальной интеграцией:
|
||||
|
||||
- приём реальных входящих запросов на вход/подпись пока не подключён к живой сети;
|
||||
- входящие запросы пока демонстрационные, чтобы можно было проверить UX и логику подтверждения.
|
||||
- пока реализован только сценарий заявок на подключение устройств через доверенную сессию;
|
||||
- другие типы входящих запросов на подпись и произвольные approval-flow на этом устройстве ещё не подключены.
|
||||
|
||||
## Основная идея устройства
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- показывает адрес кошелька устройства;
|
||||
- позволяет пополнить баланс перед регистрацией;
|
||||
- после выполнения условий даёт зарегистрировать устройство как homeserver;
|
||||
- после регистрации может принимать входящие запросы на вход и на подпись.
|
||||
- после авторизации в SHiNE может подтверждать заявки на подключение новых устройств пользователя.
|
||||
|
||||
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
|
||||
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
|
||||
@@ -120,6 +120,11 @@
|
||||
|
||||
- Верхняя строка всегда показывает краткий статус устройства:
|
||||
`PIN`, `Wi-Fi`, `сервер`, `регистрация`.
|
||||
- В правом верхнем углу рядом с батареей:
|
||||
- сначала процент заряда;
|
||||
- затем, если устройство реально заряжается, маленькая иконка молнии;
|
||||
- затем контур батареи;
|
||||
- затем индикатор `Wi-Fi`.
|
||||
- Основной язык прототипа: русский.
|
||||
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
|
||||
- Русские строки на экране временно показываются в ASCII-транслитерации.
|
||||
@@ -452,60 +457,80 @@
|
||||
|
||||
## Экран REQUESTS
|
||||
|
||||
Показывает список демонстрационных запросов:
|
||||
|
||||
- `Вход в сессию`
|
||||
- `Подпись сообщения`
|
||||
|
||||
Для каждого запроса:
|
||||
|
||||
- тип;
|
||||
- источник;
|
||||
- короткий статус.
|
||||
|
||||
Кнопки:
|
||||
|
||||
- `Открыть запрос 1`
|
||||
- `Открыть запрос 2`
|
||||
- `Назад`
|
||||
|
||||
## Экран REQUEST_DETAIL
|
||||
|
||||
Показывает детали выбранного запроса:
|
||||
|
||||
- тип запроса;
|
||||
- кто запросил;
|
||||
- время;
|
||||
- описание;
|
||||
- отпечаток/идентификатор.
|
||||
|
||||
Кнопки:
|
||||
|
||||
- `Разрешить`
|
||||
- `Отклонить`
|
||||
- `Назад`
|
||||
|
||||
Поведение:
|
||||
|
||||
- после разрешения или отклонения запрос помечается обработанным;
|
||||
- экран списка отражает новый статус.
|
||||
|
||||
## Экран SETTINGS
|
||||
Экран доступен только если homeserver уже авторизован в SHiNE.
|
||||
|
||||
Показывает:
|
||||
|
||||
- текущий PIN;
|
||||
- базовые флаги безопасности;
|
||||
- технические действия прототипа.
|
||||
- слева сверху кнопку `REFRESH`;
|
||||
- заголовок `REQUESTS:` немного правее стандартного левого положения;
|
||||
- справа сверху только большую цифру числа активных заявок;
|
||||
- ниже прокручиваемый список активных pairing-заявок;
|
||||
- на экране одновременно видны примерно две плитки.
|
||||
|
||||
Каждая плитка показывает:
|
||||
|
||||
- код подключения из `10` цифр в виде `5` пар: `XX XX XX XX XX`;
|
||||
- строку `Session: <platform/name>`;
|
||||
- строку `Kind: Client session` или `Kind: Wallet session`.
|
||||
|
||||
Поведение:
|
||||
|
||||
- список берётся из живой операции `ListTrustedDeviceLoginRequests`;
|
||||
- если заявок нет, экран показывает `No active requests`;
|
||||
- отдельная строка вида `Active requests: N` на этом экране не показывается;
|
||||
- вертикальный скролл позволяет просматривать все активные заявки;
|
||||
- нажатие по плитке открывает `REQUEST_DETAIL`;
|
||||
- свайп вправо возвращает в `SETTINGS`.
|
||||
|
||||
## Экран REQUEST_DETAIL
|
||||
|
||||
Показывает детали выбранной pairing-заявки:
|
||||
|
||||
- вопрос `Connect session ...?`;
|
||||
- код подключения `XX XX XX XX XX`;
|
||||
- строку `Session: <platform/name>`;
|
||||
- строку `Kind: Client session` или `Kind: Wallet session`;
|
||||
- пояснение:
|
||||
- для client session: `Only device key will be transferred. No additional keys will be sent.`
|
||||
- для wallet session: `No keys will be transferred.`
|
||||
|
||||
Кнопки:
|
||||
|
||||
- `Сменить PIN`
|
||||
- `Сбросить онлайн`
|
||||
- `Полный сброс`
|
||||
- `Назад`
|
||||
- `YES`
|
||||
- `NO`
|
||||
|
||||
`Полный сброс` очищает весь локальный конфиг и возвращает устройство к стартовому состоянию.
|
||||
Поведение:
|
||||
|
||||
- `YES` подтверждает заявку:
|
||||
- для client session устройство передаёт только `device key`;
|
||||
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
|
||||
- `NO` отклоняет заявку;
|
||||
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
|
||||
- свайп вправо возвращает в `REQUESTS`.
|
||||
|
||||
## Экран SETTINGS
|
||||
|
||||
Показывает вертикальное меню крупных пунктов.
|
||||
|
||||
Если homeserver ещё не авторизован в SHiNE:
|
||||
|
||||
- `1. Wi-Fi`
|
||||
- `2. Server`
|
||||
- `3. Account`
|
||||
|
||||
Если homeserver уже авторизован:
|
||||
|
||||
- `1. Device requests`
|
||||
- `2. Wi-Fi`
|
||||
- `3. Server`
|
||||
- `4. Account`
|
||||
|
||||
Поведение:
|
||||
|
||||
- одновременно видны только две карточки;
|
||||
- список листается свайпом вверх/вниз;
|
||||
- свайп вправо возвращает на `HOME`;
|
||||
- пункт `Device requests` должен быть первым и появляется только для авторизованного homeserver.
|
||||
|
||||
## Экран PIN_EDIT
|
||||
|
||||
|
||||
Reference in New Issue
Block a user