const encoder = new TextEncoder(); const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js'; function getCryptoApi() { const api = globalThis.crypto; if (!api || typeof api.getRandomValues !== 'function') { throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE); } return api; } function getSubtleApi() { const api = getCryptoApi(); if (!api.subtle) { throw new Error(WEB_CRYPTO_REQUIRED_MESSAGE); } return api.subtle; } function base64UrlToBase64(value) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); const padLen = (4 - (normalized.length % 4)) % 4; return normalized + '='.repeat(padLen); } function base64ToBase64Url(value) { return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; export function bytesToBase58(bytes) { const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []); if (input.length === 0) return ''; const digits = []; for (let i = 0; i < input.length; i += 1) { let carry = input[i]; for (let j = 0; j < digits.length; j += 1) { const value = (digits[j] * 256) + carry; digits[j] = value % 58; carry = Math.floor(value / 58); } while (carry > 0) { digits.push(carry % 58); carry = Math.floor(carry / 58); } } for (let i = 0; i < input.length && input[i] === 0; i += 1) { digits.push(0); } return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join(''); } export function base58ToBytes(value) { const text = String(value || '').trim(); if (!text) return new Uint8Array(); const digits = []; for (let i = 0; i < text.length; i += 1) { const char = text[i]; const index = BASE58_ALPHABET.indexOf(char); if (index < 0) throw new Error(`Недопустимый символ base58: ${char}`); let carry = index; for (let j = 0; j < digits.length; j += 1) { const acc = (digits[j] * 58) + carry; digits[j] = acc & 0xff; carry = acc >> 8; } while (carry > 0) { digits.push(carry & 0xff); carry >>= 8; } } for (let i = 0; i < text.length && text[i] === '1'; i += 1) { digits.push(0); } return new Uint8Array(digits.reverse()); } export function randomBase64(byteLen = 32) { const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen)); return bytesToBase64(bytes); } export function bytesToBase64(bytes) { let binary = ''; bytes.forEach((b) => { binary += String.fromCharCode(b); }); return btoa(binary); } export function bytesToBase64Url(bytes) { return base64ToBase64Url(bytesToBase64(bytes)); } export function base64ToBytes(base64) { const normalized = (base64 || '').trim(); const binary = atob(normalized); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return bytes; } export function base64UrlToBytes(value) { return base64ToBytes(base64UrlToBase64(String(value || '').trim())); } export function utf8Bytes(value) { return encoder.encode(value); } export async function sha256Bytes(bytes) { const digest = await getSubtleApi().digest('SHA-256', bytes); return new Uint8Array(digest); } export async function sha256Text(text) { return sha256Bytes(utf8Bytes(text)); } function normalizeLoginForKdf(login) { return String(login || '').trim().toLowerCase(); } async function makeArgon2Salt(login, suffix) { const normalizedLogin = normalizeLoginForKdf(login); const normalizedSuffix = String(suffix || '').trim(); const saltSource = `shine-auth-v2|login=${normalizedLogin}|suffix=${normalizedSuffix}`; const digest = await sha256Text(saltSource); return digest.slice(0, 16); } async function deriveMasterSecretArgon2id({ login, password, onProgress }) { const normalizedLogin = normalizeLoginForKdf(login); const normalizedPassword = String(password ?? ''); const salt = await makeArgon2Salt(normalizedLogin, 'master.secret'); const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`); const out = await argon2idAsync(passBytes, salt, { t: 2, m: 65536, p: 1, dkLen: 32, onProgress, }); return new Uint8Array(out); } function ed25519Pkcs8FromSeed(seed32) { if (seed32.length !== 32) { throw new Error('Для Ed25519 нужен seed длиной 32 байта'); } const prefix = new Uint8Array([ 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]); const out = new Uint8Array(prefix.length + seed32.length); out.set(prefix, 0); out.set(seed32, prefix.length); return out; } export async function deriveMasterSecretFromPassword(password, options = {}) { const normalizedPassword = String(password ?? ''); const normalizedLogin = String(options?.login ?? ''); const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : undefined; if (normalizedPassword.length === 0) { // Пустой пароль запрещён: упрощённый легаси-путь убран, регистрация/вход требуют непустой пароль. throw new Error('Пустой пароль запрещён: регистрация и вход требуют непустой пароль.'); } return deriveMasterSecretArgon2id({ login: normalizedLogin, password: normalizedPassword, onProgress, }); } export async function deriveOpaqueArgon2Hash(password, options = {}) { const normalizedPassword = String(password ?? ''); const normalizedLogin = String(options?.login ?? ''); const normalizedSuffix = String(options?.suffix || 'opaque.hash'); const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix); const passBytes = utf8Bytes(`${normalizeLoginForKdf(normalizedLogin)}\n${normalizedPassword}`); const out = await argon2idAsync(passBytes, salt, { t: 2, m: 65536, p: 1, dkLen: 32, }); return `argon2id$v=19$m=65536,t=2,p=1$${bytesToBase64(salt)}$${bytesToBase64(new Uint8Array(out))}`; } export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) { const secretBytes = masterSecret32 instanceof Uint8Array ? masterSecret32 : new Uint8Array(masterSecret32 || []); if (secretBytes.length !== 32) { throw new Error('Master secret должен быть длиной 32 байта'); } const material = `${bytesToBase64(secretBytes)}|${String(suffix || '')}`; const seed = await sha256Text(material); const pkcs8 = ed25519Pkcs8FromSeed(seed); const subtle = getSubtleApi(); const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']); const jwk = await subtle.exportKey('jwk', privateKey); if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519'); return { privateKey, publicKeyB64: bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))), privatePkcs8B64: bytesToBase64(pkcs8), }; } export async function deriveAesKeyFromStoragePwd(storagePwd, saltBytes) { const subtle = getSubtleApi(); const baseKey = await subtle.importKey( 'raw', utf8Bytes(storagePwd), { name: 'PBKDF2' }, false, ['deriveKey'], ); return subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 210000, hash: 'SHA-256', }, baseKey, { name: 'AES-GCM', length: 256, }, false, ['encrypt', 'decrypt'], ); } export async function encryptJsonWithStoragePwd(value, storagePwd) { const cryptoApi = getCryptoApi(); const subtle = getSubtleApi(); const salt = cryptoApi.getRandomValues(new Uint8Array(16)); const iv = cryptoApi.getRandomValues(new Uint8Array(12)); const key = await deriveAesKeyFromStoragePwd(storagePwd, salt); const plainBytes = utf8Bytes(JSON.stringify(value)); const cipher = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes); return { saltB64: bytesToBase64(salt), ivB64: bytesToBase64(iv), cipherB64: bytesToBase64(new Uint8Array(cipher)), }; } export async function decryptJsonWithStoragePwd(envelope, storagePwd) { const salt = base64ToBytes(envelope.saltB64); const iv = base64ToBytes(envelope.ivB64); const cipher = base64ToBytes(envelope.cipherB64); const key = await deriveAesKeyFromStoragePwd(storagePwd, salt); const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv }, key, cipher); const text = new TextDecoder().decode(plain); return JSON.parse(text); } export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt']) { return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages); } export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) { const key = await importAesKeyRaw(keyBytes, ['encrypt']); const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes); return new Uint8Array(cipher); } export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) { const key = await importAesKeyRaw(keyBytes, ['decrypt']); const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes); return new Uint8Array(plain); } export function randomBytes(byteLen = 32) { const out = new Uint8Array(byteLen); getCryptoApi().getRandomValues(out); return out; } export async function generateEd25519Pair() { return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); } export async function exportEd25519PublicKeyB64(publicKey) { const raw = await getSubtleApi().exportKey('raw', publicKey); return bytesToBase64(new Uint8Array(raw)); } export async function exportPkcs8B64(privateKey) { const raw = await getSubtleApi().exportKey('pkcs8', privateKey); return bytesToBase64(new Uint8Array(raw)); } export async function importPkcs8Ed25519(pkcs8B64) { return getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']); } export async function publicKeyB64FromPkcs8Ed25519(pkcs8B64) { const privateKey = await getSubtleApi().importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, true, ['sign']); const jwk = await getSubtleApi().exportKey('jwk', privateKey); if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519'); return bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))); } export async function signBase64(privateKey, text) { const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text)); return bytesToBase64(new Uint8Array(signature)); } export async function signBytes(privateKey, bytes) { const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes); return new Uint8Array(signature); }