179 lines
6.6 KiB
JavaScript
179 lines
6.6 KiB
JavaScript
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 {
|
|
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) {
|
|
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 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(),
|
|
};
|
|
}
|