123456789101112import { WORD_LIST } from "./recovery-word-list";1314const WORD_BITS = 11; 15const TOTAL_WORDS = 12; 161718192021export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {22 if (rootKeyBytes.length !== 32) {23 throw new Error("root key must be 32 bytes");24 }2526 27 28 29 const entropy16 = rootKeyBytes.slice(0, 16);30 const entropyHash = new Uint8Array(31 await crypto.subtle.digest(32 "SHA-256",33 entropy16 34 )35 );36 const checksum = entropyHash[0] >> 4; 3738 39 40 let bitsBig = 0n;41 for (let i = 0; i < 16; i++) {42 bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);43 }44 bitsBig = (bitsBig << 4n) | BigInt(checksum);4546 47 const words: string[] = [];48 for (let i = 0; i < TOTAL_WORDS; i++) {49 const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS;50 const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); 51 words.push(WORD_LIST[index]);52 }5354 return words;55}565758596061export async function wordsToRootKeyMaterial(62 words: string[]63): Promise<Uint8Array> {64 if (words.length !== TOTAL_WORDS) {65 throw new Error(`exactly ${TOTAL_WORDS} words required`);66 }6768 69 const indices: number[] = [];70 for (let i = 0; i < words.length; i++) {71 const word = words[i].toLowerCase().trim();72 const index = WORD_LIST.indexOf(word);73 if (index === -1) {74 throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);75 }76 indices.push(index);77 }7879 80 let bitsBig = 0n;81 for (let i = 0; i < indices.length; i++) {82 bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);83 }8485 86 87 88 const entropy = new Uint8Array(16);89 for (let i = 0; i < 16; i++) {90 const byteShift = 124 - i * 8;91 entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);92 }9394 95 const storedChecksum = Number(bitsBig & 0xfn);9697 98 const entropyHash = new Uint8Array(99 await crypto.subtle.digest("SHA-256", entropy)100 );101 const computedChecksum = entropyHash[0] >> 4;102103 if (storedChecksum !== computedChecksum) {104 throw new Error("invalid recovery words — checksum mismatch");105 }106107 108 const rootKey = new Uint8Array(32);109 rootKey.set(entropy);110 return rootKey;111}112113114115116117export async function deriveRecoveryKey(118 wordMaterial: Uint8Array,119 salt: Uint8Array120): Promise<CryptoKey> {121 122 const baseKey = await crypto.subtle.importKey(123 "raw",124 wordMaterial.buffer as ArrayBuffer,125 { name: "HKDF" },126 false,127 ["deriveKey"]128 );129130 return crypto.subtle.deriveKey(131 {132 name: "HKDF",133 hash: "SHA-256",134 salt: salt.buffer as ArrayBuffer,135 info: new TextEncoder().encode("peach-recovery-v1"),136 },137 baseKey,138 { name: "AES-GCM", length: 256 },139 false,140 ["encrypt", "decrypt"]141 );142}143144145146147148export function findInvalidWords(149 words: string[]150): { index: number; word: string }[] {151 const invalid: { index: number; word: string }[] = [];152 for (let i = 0; i < words.length; i++) {153 const w = words[i]?.toLowerCase().trim();154 if (!w || !WORD_LIST.includes(w)) {155 invalid.push({ index: i, word: words[i] ?? "" });156 }157 }158 return invalid;159}160161162163164165export function getWordSuggestions(prefix: string, maxResults = 5): string[] {166 const p = prefix.toLowerCase().trim();167 if (!p || p.length < 2) return [];168 return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults);169}