Обновление архитектуры ключей и кошельков

This commit is contained in:
AidarKC
2026-10-01 17:11:38 +03:00
parent f452ce1f1e
commit 8d22602761
51 changed files with 565 additions and 898 deletions
+18 -15
View File
@@ -1,6 +1,6 @@
import { loadEncryptedUserSecrets, updateEncryptedUserSecrets } from './key-vault.js';
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
import { deriveArweaveWalletFromClientKey32 } from './sawd-v1.js';
import { extractEd25519Seed32FromStoredValue } from './client-key-utils.js';
import { deriveArweaveWalletFromBlockchainKey32 } from './sawd-v1.js';
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const AR_TOPUP_URL = 'https://changenow.io/exchange?from=usd&to=ar&amount=10&fiatMode=true';
@@ -76,10 +76,11 @@ function pickCachedWallet(secrets) {
const cached = secrets?.arweaveWallet;
if (!cached || typeof cached !== 'object') return null;
const derivation = String(cached.derivation || '').trim();
const sourceKey = String(cached.sourceKey || '').trim();
const address = String(cached.address || '').trim();
const owner = String(cached.owner || '').trim();
const jwk = cached.jwk;
if (derivation !== 'SAWD-v1' || !address || !owner || !jwk || typeof jwk !== 'object') {
if (derivation !== 'SAWD-v1' || sourceKey !== 'blockchain' || !address || !owner || !jwk || typeof jwk !== 'object') {
return null;
}
if (!String(jwk.kty || '').trim() || !String(jwk.e || '').trim() || !String(jwk.n || '').trim() || !String(jwk.d || '').trim()) {
@@ -87,6 +88,7 @@ function pickCachedWallet(secrets) {
}
return {
derivation,
sourceKey,
address,
owner,
jwk,
@@ -150,13 +152,13 @@ function safeStatus(onStatus, text) {
}
export async function getArweaveWalletChoices({ login, storagePwd, onStatus } = {}) {
const derived = await getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus });
const derived = await getArweaveWalletFromStoredBlockchainKey({ login, storagePwd, onStatus });
const secrets = await loadEncryptedUserSecrets(String(login || '').trim(), String(storagePwd || '').trim());
const extras = normalizeExtraWallets(secrets);
return [
{
id: 'derived-client-key',
label: 'Стандартный из client key',
id: 'derived-blockchain-key',
label: 'Стандартный из ключа блокчейна',
address: derived.address,
owner: derived.owner,
jwk: derived.jwk,
@@ -206,11 +208,11 @@ export async function addArweaveWalletSecret({ login, storagePwd, secret, label
return wallet;
}
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
export async function getArweaveWalletFromStoredBlockchainKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к client.key');
throw new Error('Нет активной сессии для доступа к blockchain key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
@@ -220,23 +222,24 @@ export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, o
return cached;
}
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего client key. Это может занять немного времени.');
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего ключа блокчейна. Это может занять немного времени.');
const storedClientKey = String(secrets?.clientKey || '').trim();
if (!storedClientKey) {
throw new Error('На устройстве не найден client.key');
const storedBlockchainKey = String(secrets?.blockchainKey || '').trim();
if (!storedBlockchainKey) {
throw new Error('На устройстве не найден blockchain key');
}
const clientKey32 = extractClientKey32FromStoredValue(storedClientKey);
const blockchainKey32 = extractEd25519Seed32FromStoredValue(storedBlockchainKey);
let wallet;
try {
wallet = await deriveArweaveWalletFromClientKey32(clientKey32);
wallet = await deriveArweaveWalletFromBlockchainKey32(blockchainKey32);
} finally {
clientKey32.fill(0);
blockchainKey32.fill(0);
}
const cachedWallet = {
derivation: wallet.derivation,
sourceKey: 'blockchain',
address: wallet.address,
owner: wallet.owner,
jwk: wallet.jwk,
+20 -7
View File
@@ -674,7 +674,7 @@ function targetLoginBytes(value) {
function normalizeTargetForkNumber(value, actionName = 'target') {
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
if (!Number.isInteger(n) || n < 1 || n > 999) throw new Error(`Invalid toForkNumber for ${actionName}`);
return n;
}
@@ -1142,8 +1142,10 @@ export class AuthService {
async createSessionFromImportedSecrets(login, secrets) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('В QR-коде нет логина');
const clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim();
if (!clientKey) throw new Error('В QR-коде нет client key для входа');
const clientKey = String(secrets?.clientKey || '').trim();
const blockchainKey = String(secrets?.blockchainKey || '').trim();
if (!clientKey) throw new Error('В QR-коде нет ключа доступа для входа');
if (!blockchainKey) throw new Error('В QR-коде нет ключа блокчейна');
const privateKey = await importPkcs8Ed25519(clientKey);
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey);
@@ -1219,7 +1221,7 @@ export class AuthService {
}
}
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
async persistSelectedKeys(login, storagePwd, keyBundle) {
let currentSecrets = {};
try {
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
@@ -1230,12 +1232,23 @@ export class AuthService {
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
}
const clientKey = String(keyBundle?.clientPair?.privatePkcs8B64 || '').trim();
const blockchainKey = String(keyBundle?.blockchainPair?.privatePkcs8B64 || '').trim();
if (!clientKey || !blockchainKey) {
throw new Error('Для входа нужно сохранить client key и blockchain key');
}
const secrets = {
...currentSecrets,
clientKey: keyBundle.clientPair.privatePkcs8B64,
clientKey,
blockchainKey,
};
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
// SAWD-v1 wallet зависит от blockchain key. При смене ключа старый кэш
// нельзя переносить в новую identity: он будет лениво выведен заново.
if (String(currentSecrets?.blockchainKey || '').trim() !== blockchainKey) {
delete secrets.arweaveWallet;
}
delete secrets.rootKey;
await saveEncryptedUserSecrets(login, storagePwd, secrets);
}
+9 -6
View File
@@ -18,14 +18,14 @@ function parseKeypairJson64(raw) {
if (!isByteArrayLike(parsed)) return null;
const asArray = Array.from(parsed);
if (asArray.length < 32) {
throw new Error('Некорректный JSON ключ client.key: ожидалось минимум 32 байта');
throw new Error('Некорректный JSON Ed25519-ключ: ожидалось минимум 32 байта');
}
const out = new Uint8Array(asArray.length);
for (let i = 0; i < asArray.length; i += 1) {
const n = Number(asArray[i]);
if (!Number.isInteger(n) || n < 0 || n > 255) {
throw new Error('Некорректный JSON ключ client.key: найдены не-байтовые значения');
throw new Error('Некорректный JSON Ed25519-ключ: найдены не-байтовые значения');
}
out[i] = n;
}
@@ -34,13 +34,13 @@ function parseKeypairJson64(raw) {
export function extractSeed32FromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key');
if (bytes.length < 32) throw new Error('Некорректный PKCS8 Ed25519-ключ');
return bytes.slice(bytes.length - 32);
}
export function extractClientKey32FromStoredValue(storedClientKey) {
const raw = String(storedClientKey || '').trim();
if (!raw) throw new Error('Пустой client.key');
export function extractEd25519Seed32FromStoredValue(storedKey) {
const raw = String(storedKey || '').trim();
if (!raw) throw new Error('Пустой приватный Ed25519-ключ');
const jsonBytes = parseKeypairJson64(raw);
if (jsonBytes) {
@@ -49,3 +49,6 @@ export function extractClientKey32FromStoredValue(storedClientKey) {
return extractSeed32FromPkcs8B64(raw);
}
// Legacy API name retained for callers outside the wallet subsystem.
export const extractClientKey32FromStoredValue = extractEd25519Seed32FromStoredValue;
@@ -73,15 +73,15 @@ function normalizeKeys(keys = {}) {
return {
clientKey: String(keys?.clientKey || '').trim(),
blockchainKey: String(keys?.blockchainKey || '').trim(),
rootKey: String(keys?.rootKey || '').trim(),
};
}
export function detectPairingPayloadType(keys = {}) {
const normalized = normalizeKeys(keys);
if (normalized.rootKey) return 3;
if (normalized.blockchainKey) return 2;
return 1;
if (!normalized.clientKey || !normalized.blockchainKey) {
throw new Error('Для подключения устройства нужны ключ доступа и ключ блокчейна');
}
return 2;
}
export async function deriveEspPairingPasswordHash(login, password) {
@@ -159,13 +159,14 @@ export async function decryptPairingPayloadFromEnvelope(encryptedPayload, reques
}
export function buildSecretsPayload({ login, keys, mode }) {
const normalizedKeys = normalizeKeys(keys);
return {
v: 1,
type: 'shine-esp-pairing-transfer',
login: String(login || '').trim(),
mode: String(mode || 'device-only').trim() || 'device-only',
keys: normalizeKeys(keys),
payloadType: detectPairingPayloadType(keys),
mode: String(mode || 'full-device').trim() || 'full-device',
keys: normalizedKeys,
payloadType: detectPairingPayloadType(normalizedKeys),
createdAtMs: Date.now(),
};
}
+11 -6
View File
@@ -160,12 +160,17 @@ export class KeyRotationClient {
}
const state=await this.notifyPdaRotation(signature);
if(storagePwd) {
await updateEncryptedUserSecrets(login, storagePwd, (current)=>({
...(current||{}),
rootKey:newBundle.rootPair.privatePkcs8B64,
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64,
}));
await updateEncryptedUserSecrets(login, storagePwd, (current)=>{
const next={
...(current||{}),
blockchainKey:newBundle.blockchainPair.privatePkcs8B64,
clientKey:newBundle.clientPair.privatePkcs8B64,
};
// Arweave SAWD-v1 привязан к blockchain key: после ротации старый JWK-cache недействителен.
delete next.arweaveWallet;
delete next.rootKey;
return next;
});
}
return state;
}
+30 -4
View File
@@ -12,6 +12,15 @@ function normalizeLoginStorageKey(login) {
return String(login || '').trim().toLowerCase();
}
function sanitizePersistentSecrets(keys) {
const source = keys && typeof keys === 'object' ? keys : {};
const safe = { ...source };
// Recovery/root private key is intentionally ephemeral: it may exist in RAM
// during a protected operation, but must never be persisted on the device.
delete safe.rootKey;
return safe;
}
function openDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
@@ -53,7 +62,8 @@ async function get(storeName, key) {
}
export async function saveEncryptedUserSecrets(login, storagePwd, keys) {
const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd);
const safeKeys = sanitizePersistentSecrets(keys);
const encrypted = await encryptJsonWithStoragePwd(safeKeys, storagePwd);
const normalizedLogin = normalizeLoginStorageKey(login);
await put(STORE_SECRETS, {
login: normalizedLogin,
@@ -71,7 +81,22 @@ export async function loadEncryptedUserSecrets(login, storagePwd) {
if (!row?.encrypted) {
throw new Error('На устройстве нет сохранённых ключей для этого логина');
}
return decryptJsonWithStoragePwd(row.encrypted, storagePwd);
const decrypted = await decryptJsonWithStoragePwd(row.encrypted, storagePwd);
const safe = sanitizePersistentSecrets(decrypted);
// Одноразовая очистка контейнеров, созданных старыми версиями UI, где rootKey
// мог быть сохранён. Перезаписываем тем же storagePwd уже без Recovery private key.
if (decrypted && typeof decrypted === 'object' && Object.prototype.hasOwnProperty.call(decrypted, 'rootKey')) {
const encrypted = await encryptJsonWithStoragePwd(safe, storagePwd);
await put(STORE_SECRETS, {
login: normalizedLogin,
encrypted,
updatedAtMs: Date.now(),
});
}
return safe;
}
export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
@@ -84,8 +109,9 @@ export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
if (!next || typeof next !== 'object') {
throw new Error('updateEncryptedUserSecrets: updater должен вернуть объект secrets');
}
await saveEncryptedUserSecrets(login, storagePwd, next);
return next;
const safe = sanitizePersistentSecrets(next);
await saveEncryptedUserSecrets(login, storagePwd, safe);
return safe;
}
export async function saveSessionMaterial(login, material) {
+16 -19
View File
@@ -22,30 +22,29 @@ function base64UrlToBytes(value) {
}
export function keyLabel(id) {
if (id === 'root') return 'root';
if (id === 'blockchain') return 'blockchain';
if (id === 'device') return 'device';
if (id === 'blockchain') return 'ключ блокчейна';
if (id === 'device' || id === 'client') return 'ключ доступа';
return id;
}
export function describeTransferKeys(keys = {}) {
const out = [];
if (keys.clientKey) out.push('device');
if (keys.blockchainKey) out.push('blockchain');
if (keys.rootKey) out.push('root');
if (keys.clientKey) out.push('ключ доступа');
if (keys.blockchainKey) out.push('ключ блокчейна');
return out;
}
export function makeKeyTransferText({ login, keys }) {
const clientKey = String(keys?.clientKey || '').trim();
const blockchainKey = String(keys?.blockchainKey || '').trim();
if (!clientKey || !blockchainKey) {
throw new Error('Для подключения нужны ключ доступа и ключ блокчейна');
}
const payload = {
v: 1,
type: 'shine-key-transfer',
login: String(login || '').trim(),
keys: {
clientKey: String(keys?.clientKey || ''),
blockchainKey: String(keys?.blockchainKey || ''),
rootKey: String(keys?.rootKey || ''),
},
keys: { clientKey, blockchainKey },
createdAtMs: Date.now(),
};
const json = JSON.stringify(payload);
@@ -65,17 +64,15 @@ export function parseKeyTransferText(text) {
const login = String(payload.login || '').trim();
if (!login) throw new Error('В QR-коде нет логина');
const keys = payload.keys && typeof payload.keys === 'object' ? payload.keys : {};
if (!keys.clientKey && !keys.blockchainKey && !keys.rootKey) {
throw new Error('В QR-коде нет ключей');
const clientKey = String(keys.clientKey || '').trim();
const blockchainKey = String(keys.blockchainKey || '').trim();
if (!clientKey || !blockchainKey) {
throw new Error('В QR-коде нет полного набора рабочих ключей');
}
return {
login,
keys: {
clientKey: String(keys.clientKey || ''),
blockchainKey: String(keys.blockchainKey || ''),
rootKey: String(keys.rootKey || ''),
},
keyTypes: describeTransferKeys(keys),
keys: { clientKey, blockchainKey },
keyTypes: describeTransferKeys({ clientKey, blockchainKey }),
};
}
+21 -15
View File
@@ -323,20 +323,26 @@ function toJwkB64(value) {
return base64UrlEncode(bigIntToUnsignedBytes(value));
}
async function deriveArweaveWalletParts(clientKey32) {
if (!(clientKey32 instanceof Uint8Array)) {
throw new Error('SAWD-v1: clientKey32 должен быть Uint8Array');
async function deriveArweaveWalletParts(blockchainKey32) {
if (!(blockchainKey32 instanceof Uint8Array)) {
throw new Error('SAWD-v1: blockchainKey32 должен быть Uint8Array');
}
if (clientKey32.length !== 32) {
throw new Error('SAWD-v1: clientKey32 должен быть ровно 32 байта');
if (blockchainKey32.length !== 32) {
throw new Error('SAWD-v1: blockchainKey32 должен быть ровно 32 байта');
}
const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, clientKey32);
const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, blockchainKey32);
const masterSeedKey = await importHmacKey(masterSeed32);
const pResult = await derivePrimeWithImportedKey(masterSeedKey, 'p');
let qResult = await derivePrimeWithImportedKey(masterSeedKey, 'q');
while (qResult.prime === pResult.prime) {
// Две 2048-битные простые не гарантируют 4096-битный product: n может иметь
// 4095 бит. До публичного запуска legacy-output не фиксирован, поэтому SAWD-v1
// детерминированно продолжает q-sequence, пока modulus не станет ровно RSA_BITS.
while (
qResult.prime === pResult.prime
|| bitLength(pResult.prime * qResult.prime) !== RSA_BITS
) {
qResult = await derivePrimeWithImportedKey(masterSeedKey, 'q', qResult.index + 1n);
}
@@ -381,8 +387,8 @@ async function deriveArweaveWalletParts(clientKey32) {
};
}
export async function deriveArweaveWalletFromClientKey32(clientKey32) {
const result = await deriveArweaveWalletParts(clientKey32);
export async function deriveArweaveWalletFromBlockchainKey32(blockchainKey32) {
const result = await deriveArweaveWalletParts(blockchainKey32);
return {
derivation: result.derivation,
jwk: result.jwk,
@@ -395,7 +401,7 @@ export async function selfTestSawdV1() {
const invalid = new Uint8Array(31);
let invalidFailed = false;
try {
await deriveArweaveWalletFromClientKey32(invalid);
await deriveArweaveWalletFromBlockchainKey32(invalid);
} catch {
invalidFailed = true;
}
@@ -403,13 +409,13 @@ export async function selfTestSawdV1() {
throw new Error('SAWD-v1 self-test: длина != 32 должна приводить к ошибке');
}
const clientKey = new Uint8Array(32);
for (let i = 0; i < clientKey.length; i += 1) {
clientKey[i] = i + 1;
const blockchainKey = new Uint8Array(32);
for (let i = 0; i < blockchainKey.length; i += 1) {
blockchainKey[i] = i + 1;
}
const first = await deriveArweaveWalletParts(clientKey);
const second = await deriveArweaveWalletParts(clientKey);
const first = await deriveArweaveWalletParts(blockchainKey);
const second = await deriveArweaveWalletParts(blockchainKey);
if (!first.address || !second.address) {
throw new Error('SAWD-v1 self-test: адрес пустой');
+70 -6
View File
@@ -1,4 +1,4 @@
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
import { extractEd25519Seed32FromStoredValue } from './client-key-utils.js';
import { base64ToBytes } from './crypto-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
@@ -104,7 +104,7 @@ function decodeBase58(input) {
async function keypairFromPkcs8(pkcs8B64) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
const seed32 = extractEd25519Seed32FromStoredValue(pkcs8B64);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
@@ -114,7 +114,7 @@ async function keypairFromPkcs8(pkcs8B64) {
async function keypairFromStoredSecret(storedSecret) {
const solana = await loadSolanaLib();
const seed32 = extractClientKey32FromStoredValue(storedSecret);
const seed32 = extractEd25519Seed32FromStoredValue(storedSecret);
try {
return solana.Keypair.fromSeed(seed32);
} finally {
@@ -161,7 +161,7 @@ export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) {
};
}
export async function getWalletFromStoredClientKey({ login, storagePwd }) {
) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
@@ -200,7 +200,7 @@ export async function getWalletFromStoredBlockchainKey({ login, storagePwd }) {
};
}
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
@@ -227,7 +227,7 @@ export async function getStoredSolanaWalletChoices({ login, storagePwd } = {}) {
return [{
id: 'blockchain-key',
keySource: 'blockchain',
label: 'blockchain key',
label: 'Ключ блокчейна',
address: wallet.address,
keypair: wallet.keypair,
}];
@@ -289,6 +289,70 @@ export async function transferSol({ endpoint, fromKeypair, toAddress, amountSol
return { endpoint: rpc, signature, lamports };
}
export async function transferAllSol({ endpoint, fromKeypair, toAddress }) {
const solana = await loadSolanaLib();
const rpc = normalizeEndpoint(endpoint);
const cleanTo = String(toAddress || '').trim();
if (!fromKeypair?.publicKey) throw new Error('Не передан старый Solana-кошелёк');
if (!cleanTo) throw new Error('Не указан новый адрес кошелька');
if (fromKeypair.publicKey.toBase58() === cleanTo) {
throw new Error('Старый и новый кошельки совпадают');
}
const conn = new solana.Connection(rpc, 'confirmed');
const balanceLamports = await conn.getBalance(fromKeypair.publicKey, 'confirmed');
if (balanceLamports <= 0) {
return { endpoint: rpc, signature: '', lamports: 0, feeLamports: 0, balanceLamports, skipped: true };
}
const latest = await conn.getLatestBlockhash('confirmed');
const probeTx = new solana.Transaction({
feePayer: fromKeypair.publicKey,
recentBlockhash: latest.blockhash,
}).add(solana.SystemProgram.transfer({
fromPubkey: fromKeypair.publicKey,
toPubkey: new solana.PublicKey(cleanTo),
lamports: 1,
}));
const feeResult = await conn.getFeeForMessage(probeTx.compileMessage(), 'confirmed');
const feeLamports = Number(feeResult?.value ?? 5000);
const transferLamports = balanceLamports - feeLamports;
if (transferLamports <= 0) {
return {
endpoint: rpc,
signature: '',
lamports: 0,
feeLamports,
balanceLamports,
skipped: true,
};
}
const tx = new solana.Transaction({
feePayer: fromKeypair.publicKey,
recentBlockhash: latest.blockhash,
}).add(solana.SystemProgram.transfer({
fromPubkey: fromKeypair.publicKey,
toPubkey: new solana.PublicKey(cleanTo),
lamports: transferLamports,
}));
tx.sign(fromKeypair);
const signature = await conn.sendRawTransaction(tx.serialize(), { skipPreflight: false });
await conn.confirmTransaction({
signature,
blockhash: latest.blockhash,
lastValidBlockHeight: latest.lastValidBlockHeight,
}, 'confirmed');
return {
endpoint: rpc,
signature,
lamports: transferLamports,
feeLamports,
balanceLamports,
skipped: false,
};
}
export function formatSol(value, digits = 6) {
const n = Number(value);
if (!Number.isFinite(n)) return '0';
+1 -1
View File
@@ -146,7 +146,7 @@ export function toUserMessage(error, fallback = 'Действие не выпо
}
if (code === 'UNSUPPORTED_KEY_ALGORITHM' || text.includes('unsupported key algorithm')) {
return 'Ключ устройства не поддерживается сервером. Очистите локальные ключи и войдите заново.';
return 'Ключ доступа не поддерживается сервером. Очистите локальные ключи и войдите заново.';
}
if (!raw) return fallback;