SHiNE-server/shine-UI/js/services/device-pairing-service.js

161 lines
5.8 KiB
JavaScript

import {
base64ToBytes,
bytesToBase64,
deriveOpaqueArgon2Hash,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
sha256Bytes,
utf8Bytes,
} from './crypto-utils.js';
import {
edwardsToMontgomeryPriv,
edwardsToMontgomeryPub,
x25519,
} from 'https://esm.sh/@noble/curves@1.5.0/ed25519';
const PAIRING_HASH_SUFFIX = 'esp.pairing.password';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
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 {
deviceKey: String(keys?.deviceKey || '').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) {
return deriveOpaqueArgon2Hash(password, {
login,
suffix: PAIRING_HASH_SUFFIX,
});
}
export async function createRequesterPairingMaterial() {
const sessionPair = await generateEd25519Pair();
const sessionPublicB64 = await exportEd25519PublicKeyB64(sessionPair.publicKey);
return {
sessionKey: `ed25519/${sessionPublicB64}`,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
};
}
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(),
};
}