// Browser/Node WebCrypto only: this module never reads storage or sends data. import { NETWORK } from './network.ts'; const ITERATIONS = 600_000; const MAX_BYTES = 32 * 1024; const encoder = new TextEncoder(); export type WalletBackup = { format: 'wikideck-wallet-backup'; version: 1; network: typeof NETWORK; kdf: 'PBKDF2-SHA256'; iterations: 600000; cipher: 'AES-256-GCM'; salt: string; iv: string; publicKey: string; ciphertext: string; }; function encode(bytes: ArrayBuffer | Uint8Array): string { return btoa(String.fromCharCode(...new Uint8Array(bytes))); } function decode(value: unknown, min: number, max: number): Uint8Array { if (typeof value !== 'string' || value.length > Math.ceil(max / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { throw new Error('Invalid backup encoding.'); } const bytes = Uint8Array.from(atob(value), char => char.charCodeAt(0)); if (bytes.length < min || bytes.length > max || encode(bytes) !== value) { throw new Error('Invalid backup field size or encoding.'); } return bytes; } function passwordBytes(password: string, minimum: number): Uint8Array { if (typeof password !== 'string' || password.length < minimum || password.length > 1024) { throw new Error(`Password must contain ${minimum}–1024 characters.`); } return encoder.encode(password); } async function encryptionKey(password: string, salt: Uint8Array, usage: 'encrypt' | 'decrypt') { const bytes = passwordBytes(password, usage === 'encrypt' ? 12 : 1); try { const material = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, ['deriveKey']); return await crypto.subtle.deriveKey( { name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS }, material, { name: 'AES-GCM', length: 256 }, false, [usage], ); } finally { bytes.fill(0); } } // Fixed-order AAD authenticates every header field, including identity/network. function additionalData(backup: WalletBackup): Uint8Array { return encoder.encode(JSON.stringify([ backup.format, backup.version, backup.network, backup.kdf, backup.iterations, backup.cipher, backup.salt, backup.iv, backup.publicKey, ])); } function validateBackup(value: unknown): WalletBackup { if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { throw new Error('Invalid wallet backup.'); } const names = ['format', 'version', 'network', 'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext']; const descriptors = Object.getOwnPropertyDescriptors(value); if (Reflect.ownKeys(value).length !== names.length || names.some(name => !Object.hasOwn(descriptors, name) || !Object.hasOwn(descriptors[name], 'value'))) { throw new Error('Invalid wallet backup fields.'); } const backup = value as WalletBackup; if (backup.format !== 'wikideck-wallet-backup' || backup.version !== 1 || backup.network !== NETWORK || backup.kdf !== 'PBKDF2-SHA256' || backup.iterations !== ITERATIONS || backup.cipher !== 'AES-256-GCM') { throw new Error('Unsupported wallet backup format, network, or cryptography.'); } decode(backup.salt, 16, 16); decode(backup.iv, 12, 12); decode(backup.publicKey, 91, 91); decode(backup.ciphertext, 17, 4096); if (encoder.encode(JSON.stringify(backup)).length > MAX_BYTES) throw new Error('Backup exceeds 32 KB.'); // Snapshot primitives so callers cannot change headers during awaits. return { format: backup.format, version: backup.version, network: backup.network, kdf: backup.kdf, iterations: backup.iterations, cipher: backup.cipher, salt: backup.salt, iv: backup.iv, publicKey: backup.publicKey, ciphertext: backup.ciphertext }; } async function validatePair(privateKey: CryptoKey, publicKey: string): Promise { if (!privateKey || privateKey.type !== 'private' || privateKey.algorithm.name !== 'ECDSA' || (privateKey.algorithm as EcKeyAlgorithm).namedCurve !== 'P-256' || !privateKey.extractable || !privateKey.usages.includes('sign')) { throw new Error('An exportable P-256 ECDSA signing key is required.'); } const spki = decode(publicKey, 91, 91); const verifyingKey = await crypto.subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']); if (encode(await crypto.subtle.exportKey('spki', verifyingKey)) !== publicKey) { throw new Error('Public key must be canonical SPKI DER.'); } const challenge = crypto.getRandomValues(new Uint8Array(32)); const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, challenge); if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifyingKey, signature, challenge)) { throw new Error('Wallet key pair does not match.'); } const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', spki)); return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join(''); } export async function encryptWallet(privateKey: CryptoKey, publicKey: string, password: string): Promise { passwordBytes(password, 12).fill(0); await validatePair(privateKey, publicKey); const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 1, network: NETWORK, kdf: 'PBKDF2-SHA256', iterations: ITERATIONS, cipher: 'AES-256-GCM', salt: encode(salt), iv: encode(iv), publicKey, ciphertext: '' }; const key = await encryptionKey(password, salt, 'encrypt'); const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', privateKey)); try { backup.ciphertext = encode(await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: additionalData(backup), tagLength: 128 }, key, pkcs8, )); return validateBackup(backup); } finally { pkcs8.fill(0); } } export async function decryptWallet(backup: unknown, password: string): Promise<{ privateKey: CryptoKey; publicKey: string; address: string; }> { const validated = validateBackup(backup); // Bound work before PBKDF2. const key = await encryptionKey(password, decode(validated.salt, 16, 16), 'decrypt'); let plaintext: Uint8Array | undefined; try { plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: decode(validated.iv, 12, 12), additionalData: additionalData(validated), tagLength: 128, }, key, decode(validated.ciphertext, 17, 4096))); const privateKey = await crypto.subtle.importKey('pkcs8', plaintext, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']); const address = await validatePair(privateKey, validated.publicKey); return { privateKey, publicKey: validated.publicKey, address }; } catch { throw new Error('Cannot unlock backup: incorrect password, damaged backup, or mismatched wallet keys.'); } finally { plaintext?.fill(0); } }