import { base64ToBytes, bytesToBase64, exportEd25519PublicKeyB64, exportPkcs8B64, generateEd25519Pair, sha256Bytes, sha256Text, utf8Bytes, } from './crypto-utils.js'; import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519, } from 'https://esm.sh/@noble/curves@1.5.0/ed25519'; const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:'; const PAIRING_HASH_PREFIX = 'sha256$'; const PAIRING_HASH_VERSION = 'shine-pairing'; const ED25519_PKCS8_PREFIX = new Uint8Array([ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]); function getCryptoApi() { const api = globalThis.crypto; if (!api?.subtle || typeof api.getRandomValues !== 'function') { throw new Error('Криптография браузера недоступна. Откройте приложение через HTTPS или localhost.'); } return api; } function bytesToBase64Url(bytes) { return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function base64UrlToBytes(value) { const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4); return base64ToBytes(padded); } function extractSeedFromPkcs8(pkcs8B64) { const raw = base64ToBytes(pkcs8B64); if (raw.length !== ED25519_PKCS8_PREFIX.length + 32) { throw new Error('Некорректный приватный Ed25519 ключ'); } for (let i = 0; i < ED25519_PKCS8_PREFIX.length; i += 1) { if (raw[i] !== ED25519_PKCS8_PREFIX[i]) { throw new Error('Неподдерживаемый формат приватного Ed25519 ключа'); } } return raw.slice(ED25519_PKCS8_PREFIX.length); } function extractSessionPublicKeyB64(sessionKey) { const raw = String(sessionKey || '').trim(); if (!raw.startsWith('ed25519/')) { throw new Error('Неподдерживаемый requesterSessionKey'); } const publicKeyB64 = raw.slice('ed25519/'.length).trim(); if (!publicKeyB64) { throw new Error('Пустой requesterSessionKey'); } return publicKeyB64; } async function importAesKeyFromSharedSecret(sharedSecretBytes) { const digest = await sha256Bytes(sharedSecretBytes); return getCryptoApi().subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); } 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; } export async function deriveEspPairingPasswordHash(login, password) { const loginLower = String(login || '').trim().toLowerCase(); const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`; const digest = await sha256Text(preimage); const hex = [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join(''); return `${PAIRING_HASH_PREFIX}${hex}`; } export async function createRequesterPairingMaterial() { const sessionPair = await generateEd25519Pair(); const sessionPublicB64 = await exportEd25519PublicKeyB64(sessionPair.publicKey); return { sessionKey: `ed25519/${sessionPublicB64}`, sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey), }; } 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)); const ephemeralPriv = x25519.utils.randomPrivateKey(); const ephemeralPub = x25519.getPublicKey(ephemeralPriv); const sharedSecret = x25519.getSharedSecret(ephemeralPriv, requesterMontPub); const aesKey = await importAesKeyFromSharedSecret(sharedSecret); const iv = getCryptoApi().getRandomValues(new Uint8Array(12)); const plainBytes = utf8Bytes(JSON.stringify(payload)); const cipher = await getCryptoApi().subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, plainBytes); const envelope = { v: 1, alg: 'x25519-aes256-gcm', ephPubB64: bytesToBase64(ephemeralPub), ivB64: bytesToBase64(iv), cipherB64: bytesToBase64(new Uint8Array(cipher)), createdAtMs: Date.now(), }; return `${PAIRING_ENVELOPE_PREFIX}${bytesToBase64Url(utf8Bytes(JSON.stringify(envelope)))}`; } export async function decryptPairingPayloadFromEnvelope(encryptedPayload, requesterPairingMaterial) { const raw = String(encryptedPayload || '').trim(); if (!raw.startsWith(PAIRING_ENVELOPE_PREFIX)) { throw new Error('Неподдерживаемый формат pairing payload'); } const jsonBytes = base64UrlToBytes(raw.slice(PAIRING_ENVELOPE_PREFIX.length)); const envelope = JSON.parse(new TextDecoder().decode(jsonBytes)); if (Number(envelope?.v) !== 1 || String(envelope?.alg || '') !== 'x25519-aes256-gcm') { throw new Error('Неподдерживаемая версия pairing payload'); } const requesterSeed = extractSeedFromPkcs8(String(requesterPairingMaterial?.sessionPrivPkcs8 || '')); const requesterMontPriv = edwardsToMontgomeryPriv(requesterSeed); const sharedSecret = x25519.getSharedSecret(requesterMontPriv, base64ToBytes(String(envelope?.ephPubB64 || ''))); const aesKey = await importAesKeyFromSharedSecret(sharedSecret); const plain = await getCryptoApi().subtle.decrypt( { name: 'AES-GCM', iv: base64ToBytes(String(envelope?.ivB64 || '')) }, aesKey, base64ToBytes(String(envelope?.cipherB64 || '')), ); const payload = JSON.parse(new TextDecoder().decode(plain)); return { ...payload, keys: normalizeKeys(payload?.keys), }; } export function buildSecretsPayload({ login, keys, mode }) { 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), createdAtMs: Date.now(), }; } export function buildSessionAttachPayload({ login, session }) { return { v: 1, type: 'shine-esp-session-attach', login: String(login || '').trim(), session: { sessionId: String(session?.sessionId || '').trim(), sessionKey: String(session?.sessionKey || '').trim(), storagePwd: String(session?.storagePwd || '').trim(), sessionType: Number(session?.sessionType || 50) || 50, clientPlatform: String(session?.clientPlatform || '').trim(), }, createdAtMs: Date.now(), }; }