/** * recovery-words.ts — BIP39-compatible recovery word encoding for Peach vault recovery. * * The vault root key (256-bit random) is encoded as 12 words from the 2048-word * recovery-word-list. This is compatible with BIP39 mnemonic generation, using * the same word-to-index mapping and checksum scheme. * * Word count: 12 words = 132 bits total (128 bits entropy + 4-bit checksum). * Each word encodes 11 bits. 12 × 11 = 132 bits. */ import { WORD_LIST } from "./recovery-word-list"; const WORD_BITS = 11; // 2^11 = 2048 words per word const TOTAL_WORDS = 12; // (128 + 4) / 11 = 12 words; 128 entropy + 4-bit BIP39 checksum /** * Convert a 32-byte root key to 12 recovery words. * Encodes 128 bits of entropy + 4-bit BIP39 checksum as 12 eleven-bit word indices. * * SECURITY MODEL (PCH-01-005): the words encode ONLY rootKey[0..16]. The second * 16 bytes of the root key are NOT represented in the phrase. Recovery security is * therefore 128-bit — the standard for a 12-word BIP39 phrase — NOT 256-bit. The * 12 words are a 128-bit prefix, not independent secret material; do not assume the * full root key can be reconstructed from them. (See generateAndWrapRootKey in * vault.ts and wordsToRootKeyMaterial below.) */ export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise { if (rootKeyBytes.length !== 32) { throw new Error("root key must be 32 bytes"); } // SHA256 of the 16-byte entropy portion gives the checksum (first 4 bits). // Copy to a fresh 16-byte buffer — slice() on a Uint8Array returns a view with // the same underlying ArrayBuffer, so .buffer would still be 32 bytes. const entropy16 = rootKeyBytes.slice(0, 16); const entropyHash = new Uint8Array( await crypto.subtle.digest( "SHA-256", entropy16 /* Uint8Array — WebCrypto reads its byte content directly */ ) ); const checksum = entropyHash[0] >> 4; // 4-bit checksum // Pack 128 entropy bits + 4 checksum bits = 132 bits into a BigInt. // We only use the first 16 bytes of rootKeyBytes as entropy. let bitsBig = 0n; for (let i = 0; i < 16; i++) { bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]); } bitsBig = (bitsBig << 4n) | BigInt(checksum); // Slice into 12 × 11-bit indices, from most-significant bit first. const words: string[] = []; for (let i = 0; i < TOTAL_WORDS; i++) { const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS; const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); // 2047 max words.push(WORD_LIST[index]); } return words; } /** * Convert recovery words back to the 32-byte root key material. * Validates the checksum and throws on invalid word combinations. */ export async function wordsToRootKeyMaterial( words: string[] ): Promise { if (words.length !== TOTAL_WORDS) { throw new Error(`exactly ${TOTAL_WORDS} words required`); } // Map words to 11-bit indices const indices: number[] = []; for (let i = 0; i < words.length; i++) { // LV-1: BIP39 words are ASCII so normalize is a no-op today; the call // makes the invariant explicit and future-proofs localized word lists. const word = words[i].normalize("NFC").toLowerCase().trim(); const index = WORD_LIST.indexOf(word); if (index === -1) { throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`); } indices.push(index); } // Pack all 11-bit indices into a 132-bit BigInt, MSB first. let bitsBig = 0n; for (let i = 0; i < indices.length; i++) { bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]); } // Extract the first 128 bits as entropy bytes. // Encoding: bitsBig = (entropy_128bits << 4) | checksum_4bits // entropy[i] occupies bits [131-8i .. 124-8i], so shift = 124 - 8i. const entropy = new Uint8Array(16); for (let i = 0; i < 16; i++) { const byteShift = 124 - i * 8; entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn); } // Extract the 4-bit checksum from bits 3..0 (LSB side). const storedChecksum = Number(bitsBig & 0xfn); // Verify checksum const entropyHash = new Uint8Array( await crypto.subtle.digest("SHA-256", entropy) ); const computedChecksum = entropyHash[0] >> 4; if (storedChecksum !== computedChecksum) { throw new Error("invalid recovery words — checksum mismatch"); } // Pad to 32 bytes. The phrase carries only the 128-bit prefix (PCH-01-005), so // bytes 16..31 are ZERO here — this is word *material*, not the recovered root // key. It is fed straight into deriveRecoveryKey as HKDF IKM; the resulting KEK // unwraps the full 32-byte root key from the escrow blob. The padding is part of // the security model (128-bit recovery), not a placeholder to be "filled in". const rootKey = new Uint8Array(32); rootKey.set(entropy); return rootKey; } /** * Derive a recovery key from word material using HKDF-SHA256. * The recovery key is used to unwrap the vault's root key. */ export async function deriveRecoveryKey( wordMaterial: Uint8Array, salt: Uint8Array ): Promise { // HKDF base key: import word material as raw key const baseKey = await crypto.subtle.importKey( "raw", wordMaterial.buffer as ArrayBuffer, { name: "HKDF" }, false, ["deriveKey"] ); return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: salt.buffer as ArrayBuffer, info: new TextEncoder().encode("peach-recovery-v1"), }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } /** * Validate a list of words, returning invalid words. * Used for real-time validation in the recovery entry UI. */ export function findInvalidWords( words: string[] ): { index: number; word: string }[] { const invalid: { index: number; word: string }[] = []; for (let i = 0; i < words.length; i++) { const w = words[i]?.toLowerCase().trim(); if (!w || !WORD_LIST.includes(w)) { invalid.push({ index: i, word: words[i] ?? "" }); } } return invalid; } /** * Find word suggestions given a prefix (for autocomplete). * Returns up to 5 matching words starting with the given prefix. */ export function getWordSuggestions(prefix: string, maxResults = 5): string[] { const p = prefix.toLowerCase().trim(); if (!p || p.length < 2) return []; return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults); }