123456789101112import { WORD_LIST } from "./recovery-word-list";1314const WORD_BITS = 11; 15const TOTAL_WORDS = 12; 16171819202122232425262728export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {29 if (rootKeyBytes.length !== 32) {30 throw new Error("root key must be 32 bytes");31 }3233 34 35 36 const entropy16 = rootKeyBytes.slice(0, 16);37 const entropyHash = new Uint8Array(38 await crypto.subtle.digest(39 "SHA-256",40 entropy16 41 )42 );43 const checksum = entropyHash[0] >> 4; 4445 46 47 let bitsBig = 0n;48 for (let i = 0; i < 16; i++) {49 bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);50 }51 bitsBig = (bitsBig << 4n) | BigInt(checksum);5253 54 const words: string[] = [];55 for (let i = 0; i < TOTAL_WORDS; i++) {56 const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS;57 const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); 58 words.push(WORD_LIST[index]);59 }6061 return words;62}636465666768export async function wordsToRootKeyMaterial(69 words: string[]70): Promise<Uint8Array> {71 if (words.length !== TOTAL_WORDS) {72 throw new Error(`exactly ${TOTAL_WORDS} words required`);73 }7475 76 const indices: number[] = [];77 for (let i = 0; i < words.length; i++) {78 79 80 const word = words[i].normalize("NFC").toLowerCase().trim();81 const index = WORD_LIST.indexOf(word);82 if (index === -1) {83 throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);84 }85 indices.push(index);86 }8788 89 let bitsBig = 0n;90 for (let i = 0; i < indices.length; i++) {91 bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);92 }9394 95 96 97 const entropy = new Uint8Array(16);98 for (let i = 0; i < 16; i++) {99 const byteShift = 124 - i * 8;100 entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);101 }102103 104 const storedChecksum = Number(bitsBig & 0xfn);105106 107 const entropyHash = new Uint8Array(108 await crypto.subtle.digest("SHA-256", entropy)109 );110 const computedChecksum = entropyHash[0] >> 4;111112 if (storedChecksum !== computedChecksum) {113 throw new Error("invalid recovery words — checksum mismatch");114 }115116 117 118 119 120 121 const rootKey = new Uint8Array(32);122 rootKey.set(entropy);123 return rootKey;124}125126127128129130export async function deriveRecoveryKey(131 wordMaterial: Uint8Array,132 salt: Uint8Array133): Promise<CryptoKey> {134 135 const baseKey = await crypto.subtle.importKey(136 "raw",137 wordMaterial.buffer as ArrayBuffer,138 { name: "HKDF" },139 false,140 ["deriveKey"]141 );142143 return crypto.subtle.deriveKey(144 {145 name: "HKDF",146 hash: "SHA-256",147 salt: salt.buffer as ArrayBuffer,148 info: new TextEncoder().encode("peach-recovery-v1"),149 },150 baseKey,151 { name: "AES-GCM", length: 256 },152 false,153 ["encrypt", "decrypt"]154 );155}156157158159160161export function findInvalidWords(162 words: string[]163): { index: number; word: string }[] {164 const invalid: { index: number; word: string }[] = [];165 for (let i = 0; i < words.length; i++) {166 const w = words[i]?.toLowerCase().trim();167 if (!w || !WORD_LIST.includes(w)) {168 invalid.push({ index: i, word: words[i] ?? "" });169 }170 }171 return invalid;172}173174175176177178export function getWordSuggestions(prefix: string, maxResults = 5): string[] {179 const p = prefix.toLowerCase().trim();180 if (!p || p.length < 2) return [];181 return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults);182}