// Local at-rest encryption of the wallet's private key, using only the platform // WebCrypto (crypto.subtle) — no extra dependency, no WASM. The password derives // an AES-GCM key via PBKDF2; the password itself is never stored, never sent to // the server, and is not part of the identity (it only protects the local copy // of the private key). The identity's source of truth is the BIP39 seed. // PBKDF2 work factor. 210k SHA-256 iterations is the OWASP 2023 floor for // PBKDF2-HMAC-SHA256; stored alongside the blob so a future bump stays readable. const PBKDF2_ITERS = 210_000; // EncryptedBlob is the at-rest form of a secret: AES-256-GCM ciphertext plus the // public KDF parameters needed to re-derive the key from the password. None of // these fields is secret on its own — only the password (never stored) unlocks it. export interface EncryptedBlob { kdf: "PBKDF2-SHA256"; iters: number; salt: string; // hex, 16 random bytes (PBKDF2 salt) iv: string; // hex, 12 random bytes (AES-GCM nonce) ciphertext: string; // hex (includes the GCM auth tag) } function toHex(b: Uint8Array): string { let s = ""; for (const x of b) s += x.toString(16).padStart(2, "0"); return s; } function fromHex(h: string): Uint8Array { const out = new Uint8Array(h.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); } return out; } async function deriveAesKey( password: string, salt: Uint8Array, iters: number, ): Promise { const enc = new TextEncoder(); const baseKey = await crypto.subtle.importKey( "raw", enc.encode(password), "PBKDF2", false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt: salt as BufferSource, iterations: iters, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } // encryptSecret seals `plaintext` under `password` with a fresh random salt+iv. export async function encryptSecret( plaintext: Uint8Array, password: string, ): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveAesKey(password, salt, PBKDF2_ITERS); const ct = await crypto.subtle.encrypt( { name: "AES-GCM", iv: iv as BufferSource }, key, plaintext as BufferSource, ); return { kdf: "PBKDF2-SHA256", iters: PBKDF2_ITERS, salt: toHex(salt), iv: toHex(iv), ciphertext: toHex(new Uint8Array(ct)), }; } // WrongPasswordError is thrown when GCM authentication fails on decrypt — almost // always a wrong password (or a corrupted blob). Callers map it to a friendly // "contraseña incorrecta" message. export class WrongPasswordError extends Error { constructor() { super("wrong password"); this.name = "WrongPasswordError"; } } // decryptSecret re-derives the key from `password` and opens the blob. A wrong // password makes GCM verification fail, surfaced as WrongPasswordError. export async function decryptSecret( blob: EncryptedBlob, password: string, ): Promise { const key = await deriveAesKey(password, fromHex(blob.salt), blob.iters); try { const pt = await crypto.subtle.decrypt( { name: "AES-GCM", iv: fromHex(blob.iv) as BufferSource }, key, fromHex(blob.ciphertext) as BufferSource, ); return new Uint8Array(pt); } catch { throw new WrongPasswordError(); } } // JSON convenience: encrypt/decrypt a JS value as UTF-8 JSON. We use this to seal // the whole WalletIdentity object (the private halves) under the password. export async function encryptJSON( value: unknown, password: string, ): Promise { return encryptSecret(new TextEncoder().encode(JSON.stringify(value)), password); } export async function decryptJSON( blob: EncryptedBlob, password: string, ): Promise { const bytes = await decryptSecret(blob, password); return JSON.parse(new TextDecoder().decode(bytes)) as T; }