diff --git a/.gitignore b/.gitignore index 5a1214c..cbf0c09 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,7 @@ shine-solana/shine/scripts/**/*.env shine-solana/shine/scripts/**/TEMP_*.md # Локальные артефакты и внешние материалы ESP32-подпроекта +ESP32/esp32-config-tool/ ESP32/**/.git/ ESP32/**/.idea/ ESP32-wallet/.idea/ diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index e5941e0..d7c28a0 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -281,6 +281,7 @@ sha256$ Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`. Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку. +Поле `shortCode` теперь содержит `10` цифр. В UI его рекомендуется показывать как `5` пар, например: `49 20 70 91 23`. TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам. @@ -295,7 +296,7 @@ TTL заявки фиксирован на сервере и сейчас все "payload": { "pairingId": "base64url", "state": "created", - "shortCode": "4920709", + "shortCode": "4920709123", "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", "expiresAtMs": 1781441990538, "trustedSessionOnline": true @@ -337,7 +338,7 @@ TTL заявки фиксирован на сервере и сейчас все "requesterSessionType": 1, "requesterClientPlatform": "Android", "payloadType": 1, - "shortCode": "4920709", + "shortCode": "4920709123", "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", "createdAtMs": 1781441810538, "expiresAtMs": 1781441990538, @@ -425,7 +426,7 @@ TTL заявки фиксирован на сервере и сейчас все "payload": { "pairingId": "base64url", "state": "approved", - "shortCode": "4920709", + "shortCode": "4920709123", "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", "payloadType": 1, "encryptedPayload": "AQIDBA==", diff --git a/Dev_Docs/Pending_Features/2026-06-19_1605_call-decline-ui-and-ring-stop.md b/Dev_Docs/Pending_Features/2026-06-19_1605_call-decline-ui-and-ring-stop.md new file mode 100644 index 0000000..9b57221 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-19_1605_call-decline-ui-and-ring-stop.md @@ -0,0 +1,22 @@ +# Быстрое скрытие экрана звонка и остановка гудков при отклонении + +- краткое описание фичи: + - Исправлена нижняя подпись вкладки личных сообщений на `личные`. + - Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии. + - На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа. + +- что именно проверять: + - В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`. + - Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`. + - Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается. + - Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки. + - Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения. + +- ожидаемый результат: + - Подпись в нижней панели корректная. + - При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков. + - Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены. + - Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями. + +- статус: + - `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md b/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md new file mode 100644 index 0000000..2eb2dbe --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md @@ -0,0 +1,24 @@ +# ESP32 homeserver: заявки на подключение устройств + +- краткое описание фичи: + - на ESP32 homeserver в `SETTINGS` добавлен первый пункт `Device requests`, который появляется только после авторизации homeserver в SHiNE; + - экран показывает список активных pairing-заявок, позволяет открыть каждую заявку и подтвердить или отклонить её; + - формат кода подключения изменён на `10` цифр и показывается как `5` пар. + +- что проверять: + - на обычном клиенте и в wallet-plugin код отображается как `XX XX XX XX XX`; + - на доверенном веб-клиенте экран `Подключить по коду` показывает все активные заявки без поля ручного ввода; + - на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым; + - `REFRESH` реально загружает активные заявки; + - на экране видно две плитки, список листается вертикально; + - client-session заявка после `YES` подключается с передачей только `device key`; + - wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session; + - `NO` отклоняет заявку и она исчезает из списка активных. + +- ожидаемый результат: + - все три клиента используют единый формат кода; + - активные заявки видны без ручного ввода кода; + - ESP32 может одобрять и отклонять живые pairing-заявки пользователя. + +- статус: + - pending diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index 902ec85..61a4f81 100644 --- a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -60,7 +60,7 @@ - хранит включённость pairing и optional `passwordHash` в формате `sha256$`; - хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`; -- рассчитывает короткий код `shortCode` из `7` цифр; +- рассчитывает короткий код `shortCode` из `10` цифр; - рассчитывает длинный `fingerprintB58` из `SHA-256` заявки; - уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены; - хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 6255cb3..954cc03 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -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 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 &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 &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 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 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(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 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 plain(payloadJson.length()); + for (int i = 0; i < (int)payloadJson.length(); ++i) plain[i] = (uint8_t)payloadJson.charAt(i); + std::vector 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 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(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(reinterpret_cast(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(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(kMenuCount)), - static_cast(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(static_cast(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(kMenuCount) - 2) { - gSettingsScrollIndex = static_cast(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; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index 7273343..f61eed3 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -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: `; +- строку `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: `; +- строку `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 diff --git a/SHiNE-browser-plugin-wallet/background.js b/SHiNE-browser-plugin-wallet/background.js index 31d8006..c90a59a 100644 --- a/SHiNE-browser-plugin-wallet/background.js +++ b/SHiNE-browser-plugin-wallet/background.js @@ -362,7 +362,7 @@ async function startPairing({ login, usePassword, password }) { state.pairingId = String(payload?.pairingId || '').trim(); state.expiresAtMs = Number(payload?.expiresAtMs || 0); - state.shortCode = String(payload?.shortCode || '0000000'); + state.shortCode = String(payload?.shortCode || ''); state.trustedSessionOnline = !!payload?.trustedSessionOnline; if (!state.pairingId) { throw new Error('Сервер не вернул pairingId.'); @@ -375,7 +375,7 @@ async function startPairing({ login, usePassword, password }) { setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info'); return { pairingId: state.pairingId, - shortCode: String(payload?.shortCode || '0000000'), + shortCode: String(payload?.shortCode || ''), expiresAtMs: state.expiresAtMs, trustedSessionOnline: !!payload?.trustedSessionOnline, }; diff --git a/SHiNE-browser-plugin-wallet/js/lib/device-pairing.js b/SHiNE-browser-plugin-wallet/js/lib/device-pairing.js index f1b68e8..61e708c 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/device-pairing.js +++ b/SHiNE-browser-plugin-wallet/js/lib/device-pairing.js @@ -59,6 +59,15 @@ export async function createRequesterPairingMaterial() { }; } +export function normalizePairingShortCode(value, digits = 10) { + return String(value || '').replace(/\D+/g, '').slice(0, digits).padStart(digits, '0'); +} + +export function formatPairingShortCode(value) { + const normalized = normalizePairingShortCode(value, 10); + return normalized.match(/.{1,2}/g)?.join(' ') || normalized; +} + export async function deriveEspPairingPasswordHash(login, password) { const loginLower = String(login || '').trim().toLowerCase(); const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`; diff --git a/SHiNE-browser-plugin-wallet/popup.html b/SHiNE-browser-plugin-wallet/popup.html index 5467e40..dcd8b98 100644 --- a/SHiNE-browser-plugin-wallet/popup.html +++ b/SHiNE-browser-plugin-wallet/popup.html @@ -78,7 +78,7 @@