Обновить pairing устройств и доработать ESP32 UI

This commit is contained in:
AidarKC 2026-06-19 20:47:56 +04:00
parent cc074a941f
commit a788d8bcf5
18 changed files with 1020 additions and 126 deletions

1
.gitignore vendored
View File

@ -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/

View File

@ -281,6 +281,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `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==",

View File

@ -0,0 +1,22 @@
# Быстрое скрытие экрана звонка и остановка гудков при отклонении
- краткое описание фичи:
- Исправлена нижняя подпись вкладки личных сообщений на `личные`.
- Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии.
- На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа.
- что именно проверять:
- В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`.
- Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`.
- Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается.
- Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки.
- Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения.
- ожидаемый результат:
- Подпись в нижней панели корректная.
- При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков.
- Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены.
- Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями.
- статус:
- `pending`

View File

@ -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

View File

@ -60,7 +60,7 @@
- хранит включённость pairing и optional `passwordHash` в формате `sha256$<hex>`;
- хранит pairing-заявки всех статусов, но в список активных для доверённого устройства отдаёт только pending `created`;
- рассчитывает короткий код `shortCode` из `7` цифр;
- рассчитывает короткий код `shortCode` из `10` цифр;
- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки;
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.

View File

@ -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;

View File

@ -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

View File

@ -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,
};

View File

@ -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 ?? '')}`;

View File

@ -78,7 +78,7 @@
<div id="pairing-card" class="card hidden">
<div class="card-title">Код подключения</div>
<div id="short-code" class="code">0000000</div>
<div id="short-code" class="code">00 00 00 00 00</div>
<p id="pairing-hint" class="muted small">
Покажите код на доверенном устройстве в разделе «Подключить по коду».
</p>

View File

@ -1,3 +1,5 @@
import { formatPairingShortCode } from './js/lib/device-pairing.js';
const els = {
serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'),
@ -159,9 +161,9 @@ function applyState(nextState) {
const pairing = state?.pairing || {};
if (pairing.active) {
els.pairingCard.classList.remove('hidden');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '0000000');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '');
els.shortCode.dataset.shortCode = shortCode;
els.shortCode.textContent = shortCode;
els.shortCode.textContent = formatPairingShortCode(shortCode);
els.pairingHint.textContent = pairing.trustedSessionOnline
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
@ -170,7 +172,7 @@ function applyState(nextState) {
els.startBtn.disabled = true;
} else {
els.pairingCard.classList.add('hidden');
els.shortCode.textContent = '0000000';
els.shortCode.textContent = formatPairingShortCode('');
delete els.shortCode.dataset.shortCode;
els.pairingExpire.textContent = '';
els.startBtn.disabled = false;

View File

@ -136,8 +136,8 @@ final class EspPairingSupport {
| ((digest[1] & 0xFFL) << 16)
| ((digest[2] & 0xFFL) << 8)
| (digest[3] & 0xFFL);
long shortCodeNum = code % 10_000_000L;
String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum);
long shortCodeNum = code % 10_000_000_000L;
String shortCode = String.format(Locale.ROOT, "%010d", shortCodeNum);
return new PairingFingerprint(shortCode, toBase58(digest));
}

View File

@ -1,2 +1,2 @@
client.version=1.2.218
server.version=1.2.206
client.version=1.2.219
server.version=1.2.207

View File

@ -3,7 +3,7 @@ import { state } from '../state.js';
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
const ITEMS = [
{ pageId: 'messages-list', label: 'Личные сообщения', icon: '💬' },
{ pageId: 'messages-list', label: 'личные', icon: '💬' },
{ pageId: 'channels-list', label: 'Каналы', icon: '📢' },
{ pageId: 'network-view', label: 'Связи', icon: '🕸' },
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },

View File

@ -14,6 +14,7 @@ import {
buildSessionAttachPayload,
deriveEspPairingPasswordHash,
encryptPairingPayloadForRequester,
formatPairingShortCode,
} from '../services/device-pairing-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@ -29,8 +30,10 @@ function setStatus(statusEl, message, kind = 'info') {
statusEl.style.display = message ? '' : 'none';
}
function normalizeCode(value) {
return String(value || '').replace(/\D+/g, '').slice(0, 7);
const SESSION_TYPE_WALLET = 50;
function pairingSessionKindLabel(sessionType) {
return Number(sessionType || 0) === SESSION_TYPE_WALLET ? 'Wallet session' : 'Client session';
}
function buildTransferKeys(savedKeys, { withExtras = false }) {
@ -54,19 +57,19 @@ function buildTransferKeys(savedKeys, { withExtras = false }) {
}
function requestCardHtml(request) {
const shortCode = String(request?.shortCode || '').trim() || '0000000';
const shortCode = formatPairingShortCode(request?.shortCode || '');
const client = String(request?.requesterClientPlatform || 'unknown');
const requesterSessionType = Number(request?.requesterSessionType || 0);
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
const sessionOnly = requesterSessionType === 50;
const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET;
const sessionKind = pairingSessionKindLabel(requesterSessionType);
return `
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px;">
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
<span class="meta-muted">Платформа: ${client}</span>
<span class="meta-muted">Тип сессии: ${requesterSessionType || '—'}</span>
<span class="meta-muted">Тип payload: ${Number(request?.payloadType || 0)}</span>
<span class="meta-muted">Session name: ${client}</span>
<span class="meta-muted">Session kind: ${sessionKind}</span>
<span class="meta-muted">Истекает: ${expiresText}</span>
</div>
</div>
@ -198,14 +201,11 @@ export function render({ navigate }) {
const requestsCard = document.createElement('div');
requestsCard.className = 'card stack';
requestsCard.innerHTML = `
<div class="row" style="align-items:flex-end; gap:10px; flex-wrap:wrap;">
<label class="stack" style="flex:1 1 180px;">
<span class="field-label">Код нового устройства</span>
<input class="input" id="pairing-code-filter" inputmode="numeric" maxlength="7" placeholder="7 цифр" />
</label>
<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<span class="field-label">Активные заявки</span>
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
</div>
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
<div class="stack" id="pairing-requests-list"></div>
`;
@ -213,7 +213,6 @@ export function render({ navigate }) {
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary');
const codeFilterInput = requestsCard.querySelector('#pairing-code-filter');
const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests');
const requestsListEl = requestsCard.querySelector('#pairing-requests-list');
@ -413,23 +412,17 @@ export function render({ navigate }) {
};
const renderRequests = () => {
const filterCode = normalizeCode(codeFilterInput.value);
const filtered = filterCode
? requests.filter((item) => normalizeCode(item?.shortCode) === filterCode)
: requests;
requestsListEl.innerHTML = '';
if (!filtered.length) {
if (!requests.length) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = filterCode
? 'Заявка с таким кодом пока не найдена.'
: 'Активных заявок сейчас нет.';
empty.textContent = 'Активных заявок сейчас нет.';
requestsListEl.append(empty);
return;
}
filtered.forEach((request) => {
requests.forEach((request) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = requestCardHtml(request);
requestsListEl.append(wrapper.firstElementChild);
@ -470,12 +463,12 @@ export function render({ navigate }) {
const approveRequest = async (request, mode) => {
const withExtras = mode === 'with-extras';
let payload;
if (!withExtras && Number(request?.requesterSessionType || 0) === 50) {
if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) {
const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({
login: state.session.login,
devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(),
sessionKey: String(request?.requesterSessionKey || '').trim(),
sessionType: Number(request?.requesterSessionType || 50) || 50,
sessionType: Number(request?.requesterSessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET,
clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin',
clientInfo: 'Wallet session approved via device pairing',
});
@ -493,7 +486,7 @@ export function render({ navigate }) {
}
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === 50;
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET;
showToast(
withExtras
? 'Ключи переданы на новое устройство'
@ -600,10 +593,6 @@ export function render({ navigate }) {
refreshBtn.addEventListener('click', () => {
void reloadRequests();
});
codeFilterInput.addEventListener('input', () => {
codeFilterInput.value = normalizeCode(codeFilterInput.value);
renderRequests();
});
requestsListEl.addEventListener('click', async (event) => {
const target = event.target;

View File

@ -12,7 +12,12 @@ import {
terminateCurrentSession,
} from '../state.js';
import { showToast } from '../services/channels-ux.js';
import { decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash, createRequesterPairingMaterial } from '../services/device-pairing-service.js';
import {
decryptPairingPayloadFromEnvelope,
deriveEspPairingPasswordHash,
createRequesterPairingMaterial,
formatPairingShortCode,
} from '../services/device-pairing-service.js';
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@ -30,7 +35,7 @@ function codeCardHtml() {
return `
<div class="card stack">
<p class="field-label">Код подключения</p>
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.18em;">0000000</div>
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.12em;">00 00 00 00 00</div>
<p class="meta-muted" id="pairing-status-hint">Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».</p>
<p class="meta-muted" id="pairing-online-hint"></p>
<p class="meta-muted" id="pairing-expire-hint"></p>
@ -47,7 +52,7 @@ function formatRemaining(ms) {
function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) {
resultWrap.style.display = 'none';
shortCodeEl.textContent = '0000000';
shortCodeEl.textContent = formatPairingShortCode('');
statusHintEl.textContent = 'Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».';
onlineHintEl.textContent = '';
expireHintEl.textContent = '';
@ -327,7 +332,7 @@ export function render({ navigate }) {
throw new Error('Сервер не вернул pairingId.');
}
shortCodeEl.textContent = String(payload?.shortCode || '0000000');
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
onlineHintEl.textContent = payload?.trustedSessionOnline
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'

View File

@ -362,6 +362,7 @@ function startTone(nextToneName) {
function getCallStateSnapshot() {
const call = getActiveCall();
if (!call) return null;
if (call.localUiDismissed) return null;
const callPhase = String(call.phase || '').trim();
return {
callId: call.callId,
@ -386,6 +387,12 @@ function notifyCallState() {
});
}
function dismissCallUiLocally(call) {
if (!call) return;
call.localUiDismissed = true;
notifyCallState();
}
function setStatus(call, statusText, phase = '') {
if (!call) return;
call.statusText = String(statusText || '').trim();
@ -1453,10 +1460,18 @@ export async function acceptIncomingCall() {
export async function declineIncomingCall() {
const call = getActiveCall();
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
try {
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
} catch {}
await finalizeCall(call, { localReasonCode: 'declined', debugReason: 'declined_by_user' });
dismissCallUiLocally(call);
const declinePromise = (async () => {
try {
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
} catch {}
})();
await finalizeCall(call, {
localReasonCode: 'declined',
debugReason: 'declined_by_user',
suppressRemoteSignal: true,
});
await declinePromise;
}
export async function handleIncomingCallSignal(evt) {
@ -1499,14 +1514,35 @@ export async function handleIncomingCallSignal(evt) {
}
}
} else {
const remoteSessionLocked = Boolean(
call.initialOfferSent
|| call.connectedAtMs
|| call.phase === 'connecting'
|| call.phase === 'active'
|| call.phase === 'reconnecting',
);
const terminalSignalFromAnotherSession =
Boolean(call.remoteSessionId)
&& Boolean(fromSessionId)
&& call.remoteSessionId !== fromSessionId
&& (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP);
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
await emitDebug(
call,
'info',
'signal_from_non_selected_session_ignored',
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
return;
if (terminalSignalFromAnotherSession && !remoteSessionLocked) {
await emitDebug(
call,
'info',
'terminal_signal_from_non_selected_session_allowed_before_lock',
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
} else {
await emitDebug(
call,
'info',
'signal_from_non_selected_session_ignored',
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
return;
}
}
if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
@ -1632,6 +1668,7 @@ export async function handleIncomingCallSignal(evt) {
export async function hangupActiveCall() {
if (!activeCallId) return;
const call = getCall(activeCallId);
dismissCallUiLocally(call);
await finalizeCall(call, {
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
debugReason: 'hangup_by_user',

View File

@ -101,6 +101,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 encryptPairingPayloadForRequester(requesterSessionKey, payload) {
const requesterPublicB64 = extractSessionPublicKeyB64(requesterSessionKey);
const requesterMontPub = edwardsToMontgomeryPub(base64ToBytes(requesterPublicB64));