mf1_kat_gen.mjs

4.1 KB · raw

1// Generates an MF-1 known-answer vector using the BROWSER's exact recovery2// algorithm (recovery-words.ts) against the REAL browser word list.3// Read-only against peach-browser. Output is pinned into the Android KAT.4import { readFileSync } from "node:fs";56const listSrc = readFileSync(7  "/Users/june/projects/peach-browser/src/shared/recovery-word-list.ts",8  "utf8"9);10// Extract quoted words in source order.11const WORD_LIST = [...listSrc.matchAll(/"([a-z]+)"/g)].map((m) => m[1]);12if (WORD_LIST.length < 2048) throw new Error("word list parse failed: " + WORD_LIST.length);1314const WORD_BITS = 11n;15const TOTAL_WORDS = 12;16const hex = (u8) => [...u8].map((b) => b.toString(16).padStart(2, "0")).join("");17const unhex = (s) => new Uint8Array(s.match(/../g).map((h) => parseInt(h, 16)));1819async function rootKeyToWords(rootKeyBytes) {20  const entropy16 = rootKeyBytes.slice(0, 16);21  const entropyHash = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy16));22  const checksum = entropyHash[0] >> 4;23  let bitsBig = 0n;24  for (let i = 0; i < 16; i++) bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);25  bitsBig = (bitsBig << 4n) | BigInt(checksum);26  const words = [];27  for (let i = 0; i < TOTAL_WORDS; i++) {28    const shift = BigInt(TOTAL_WORDS - 1 - i) * WORD_BITS;29    const index = Number((bitsBig >> shift) & 0x7ffn);30    words.push(WORD_LIST[index]);31  }32  return words;33}3435async function wordsToRootKeyMaterial(words) {36  const indices = words.map((w) => {37    const i = WORD_LIST.indexOf(w.toLowerCase().trim());38    if (i === -1) throw new Error("unknown word " + w);39    return i;40  });41  let bitsBig = 0n;42  for (const idx of indices) bitsBig = (bitsBig << WORD_BITS) | BigInt(idx);43  const entropy = new Uint8Array(16);44  for (let i = 0; i < 16; i++) entropy[i] = Number((bitsBig >> BigInt(124 - i * 8)) & 0xffn);45  const storedChecksum = Number(bitsBig & 0xfn);46  const entropyHash = new Uint8Array(await crypto.subtle.digest("SHA-256", entropy));47  if (storedChecksum !== entropyHash[0] >> 4) throw new Error("checksum mismatch");48  const rootKey = new Uint8Array(32);49  rootKey.set(entropy);50  return rootKey;51}5253async function deriveRecoveryKeyRaw(wordMaterial, salt) {54  const baseKey = await crypto.subtle.importKey("raw", wordMaterial, { name: "HKDF" }, false, ["deriveBits"]);55  const bits = await crypto.subtle.deriveBits(56    { name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode("peach-recovery-v1") },57    baseKey,58    25659  );60  return new Uint8Array(bits);61}6263// Find a deterministic root key whose words round-trip cleanly.64async function findRootKey() {65  for (let seed = 1; seed < 100000; seed++) {66    const rk = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode("peach-mf1-kat-" + seed)));67    const words = await rootKeyToWords(rk);68    try {69      const mat = await wordsToRootKeyMaterial(words);70      // require exact round-trip of first 16 bytes too71      if (hex(mat.slice(0, 16)) === hex(rk.slice(0, 16))) return { rk, words, seed };72    } catch {}73  }74  throw new Error("no key found");75}7677const { rk, words, seed } = await findRootKey();78const salt = unhex("000102030405060708090a0b0c0d0e0f");79const nonce = unhex("0b0a090807060504030201ff");80const material = await wordsToRootKeyMaterial(words);81const recoveryKeyRaw = await deriveRecoveryKeyRaw(material, salt);8283// Wrap hex(rootKey) ASCII under recoveryKey, AAD = [1], fixed nonce.84const aesKey = await crypto.subtle.importKey("raw", recoveryKeyRaw, { name: "AES-GCM" }, false, ["encrypt"]);85const plaintext = new TextEncoder().encode(hex(rk)); // 64 ASCII bytes86const wrapped = new Uint8Array(87  await crypto.subtle.encrypt(88    { name: "AES-GCM", iv: nonce, additionalData: new Uint8Array([1]), tagLength: 128 },89    aesKey,90    plaintext91  )92);93const ct = wrapped.slice(0, wrapped.length - 16);94const tag = wrapped.slice(wrapped.length - 16);9596console.log(JSON.stringify({97  seed,98  rootKeyHex: hex(rk),99  words,100  wordMaterialHex: hex(material),101  recoveryKeyHex: hex(recoveryKeyRaw),102  saltHex: hex(salt),103  nonceHex: hex(nonce),104  ciphertextHex: hex(ct),105  tagHex: hex(tag),106}, null, 2));