// Generates an MF-1 known-answer vector using the BROWSER's exact recovery // algorithm (recovery-words.ts) against the REAL browser word list. // Read-only against peach-browser. Output is pinned into the Android KAT. import { readFileSync } from "node:fs"; const listSrc = readFileSync( "/Users/june/projects/peach-browser/src/shared/recovery-word-list.ts", "utf8" ); // Extract quoted words in source order. const WORD_LIST = [...listSrc.matchAll(/"([a-z]+)"/g)].map((m) => m[1]); if (WORD_LIST.length < 2048) throw new Error("word list parse failed: " + WORD_LIST.length); const WORD_BITS = 11n; const TOTAL_WORDS = 12; const hex = (u8) => [...u8].map((b) => b.toString(16).padStart(2, "0")).join(""); const unhex = (s) => new Uint8Array(s.match(/../g).map((h) => parseInt(h, 16))); async function rootKeyToWords(rootKeyBytes) { const entropy16 = rootKeyBytes.slice(0, 16); const entropyHash = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy16)); const checksum = entropyHash[0] >> 4; let bitsBig = 0n; for (let i = 0; i < 16; i++) bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]); bitsBig = (bitsBig << 4n) | BigInt(checksum); const words = []; for (let i = 0; i < TOTAL_WORDS; i++) { const shift = BigInt(TOTAL_WORDS - 1 - i) * WORD_BITS; const index = Number((bitsBig >> shift) & 0x7ffn); words.push(WORD_LIST[index]); } return words; } async function wordsToRootKeyMaterial(words) { const indices = words.map((w) => { const i = WORD_LIST.indexOf(w.toLowerCase().trim()); if (i === -1) throw new Error("unknown word " + w); return i; }); let bitsBig = 0n; for (const idx of indices) bitsBig = (bitsBig << WORD_BITS) | BigInt(idx); const entropy = new Uint8Array(16); for (let i = 0; i < 16; i++) entropy[i] = Number((bitsBig >> BigInt(124 - i * 8)) & 0xffn); const storedChecksum = Number(bitsBig & 0xfn); const entropyHash = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy)); if (storedChecksum !== entropyHash[0] >> 4) throw new Error("checksum mismatch"); const rootKey = new Uint8Array(32); rootKey.set(entropy); return rootKey; } async function deriveRecoveryKeyRaw(wordMaterial, salt) { const baseKey = await crypto.subtle.importKey("raw", wordMaterial, { name: "HKDF" }, false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode("peach-recovery-v1") }, baseKey, 256 ); return new Uint8Array(bits); } // Find a deterministic root key whose words round-trip cleanly. async function findRootKey() { for (let seed = 1; seed < 100000; seed++) { const rk = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode("peach-mf1-kat-" + seed))); const words = await rootKeyToWords(rk); try { const mat = await wordsToRootKeyMaterial(words); // require exact round-trip of first 16 bytes too if (hex(mat.slice(0, 16)) === hex(rk.slice(0, 16))) return { rk, words, seed }; } catch {} } throw new Error("no key found"); } const { rk, words, seed } = await findRootKey(); const salt = unhex("000102030405060708090a0b0c0d0e0f"); const nonce = unhex("0b0a090807060504030201ff"); const material = await wordsToRootKeyMaterial(words); const recoveryKeyRaw = await deriveRecoveryKeyRaw(material, salt); // Wrap hex(rootKey) ASCII under recoveryKey, AAD = [1], fixed nonce. const aesKey = await crypto.subtle.importKey("raw", recoveryKeyRaw, { name: "AES-GCM" }, false, ["encrypt"]); const plaintext = new TextEncoder().encode(hex(rk)); // 64 ASCII bytes const wrapped = new Uint8Array( await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: new Uint8Array([1]), tagLength: 128 }, aesKey, plaintext ) ); const ct = wrapped.slice(0, wrapped.length - 16); const tag = wrapped.slice(wrapped.length - 16); console.log(JSON.stringify({ seed, rootKeyHex: hex(rk), words, wordMaterialHex: hex(material), recoveryKeyHex: hex(recoveryKeyRaw), saltHex: hex(salt), nonceHex: hex(nonce), ciphertextHex: hex(ct), tagHex: hex(tag), }, null, 2));