/** * Export passphrase validation, strength estimation, and generation * (spec §6). * * Three concerns, kept separate so the UI can call them in the order it * wants: * * 1. `evaluateStrength(phrase)` — synchronous, runs zxcvbn. Used on every * keystroke to update the strength meter live. * * 2. `checkPassphraseBreached(phrase)` — async, calls HIBP. Debounced at * the UI layer, not here. Non-blocking per §6.3: if the API is * unreachable we return `{ breached: false, apiReachable: false }` * and the UI shows a subtle warning. A positive breach match is * blocking. * * 3. `validateForExport(...)` — combines the hard requirements (§6.1) * with the confirm-match check. Returns the exact error codes listed * in §9 so the UI has one lookup table for messages. * * 4. `generateDicewarePassphrase(wordCount)` — 6-word default, CSPRNG * selection from the EFF large wordlist (§6.5). */ import zxcvbn from "zxcvbn"; import effWordlist from "eff-diceware-passphrase/wordlist.json"; // --------------------------------------------------------------------------- // Strength tiers (spec §6.2) // --------------------------------------------------------------------------- export type StrengthTier = "weak" | "fair" | "good" | "excellent"; export interface StrengthResult { /** zxcvbn 0..4 score. */ score: 0 | 1 | 2 | 3 | 4; tier: StrengthTier; /** True iff this passphrase is strong enough to enable the Export button. */ meetsStrengthBar: boolean; /** Estimated guesses, straight from zxcvbn (for display + debug). */ guesses: number; /** * First crack-time-seconds bucket from zxcvbn. Handy for UI copy like * "Centuries to crack" without reimplementing the heuristic. */ crackTimeDisplay: string; /** First concrete feedback message from zxcvbn, if any. Empty string on "no feedback". */ feedback: string; } const ZXCVBN_SCORE_GOOD = 3; export const MIN_PASSPHRASE_LENGTH = 16; /** * Map a zxcvbn score 0..4 onto a named tier from the spec table. The * mapping is deterministic: 0|1 → weak, 2 → fair, 3 → good, 4 → excellent. */ function tierForScore(score: number): StrengthTier { if (score <= 1) return "weak"; if (score === 2) return "fair"; if (score === 3) return "good"; return "excellent"; } /** * Synchronous strength evaluation. Safe to call on every keystroke. * zxcvbn runs in 5–30ms for typical inputs — if this becomes a hot path * the UI can debounce, but we don't add latency here. */ export function evaluateStrength(passphrase: string): StrengthResult { if (passphrase.length === 0) { return { score: 0, tier: "weak", meetsStrengthBar: false, guesses: 0, crackTimeDisplay: "instant", feedback: "", }; } const result = zxcvbn(passphrase); const score = result.score as 0 | 1 | 2 | 3 | 4; const tier = tierForScore(score); // "feedback.warning" is the primary message. "feedback.suggestions[0]" // is a softer hint (e.g., "Add another word or two"). Prefer warning // when present; otherwise surface the first suggestion. const feedback = result.feedback.warning || (result.feedback.suggestions && result.feedback.suggestions[0]) || ""; return { score, tier, meetsStrengthBar: score >= ZXCVBN_SCORE_GOOD, guesses: result.guesses, crackTimeDisplay: String( result.crack_times_display.offline_slow_hashing_1e4_per_second ), feedback, }; } // --------------------------------------------------------------------------- // HIBP k-anonymity check (spec §6.3) // --------------------------------------------------------------------------- export interface HibpResult { breached: boolean; /** False if the API couldn't be reached. When false, `breached` is also false. */ apiReachable: boolean; } const HIBP_RANGE_ENDPOINT = "https://api.pwnedpasswords.com/range/"; /** * Check a passphrase against HaveIBeenPwned using the k-anonymity range * API. Only the first 5 hex chars of the SHA-1 are sent — the passphrase * itself never leaves the browser. * * The API is treated as advisory. Network failure returns `apiReachable: * false`; the caller should allow export with a subtle warning. A positive * match always returns `breached: true` and should block export. */ export async function checkPassphraseBreached( passphrase: string, signal?: AbortSignal ): Promise { const hashHex = await sha1Hex(passphrase); const prefix = hashHex.slice(0, 5); const suffix = hashHex.slice(5); try { const response = await fetch(`${HIBP_RANGE_ENDPOINT}${prefix}`, { method: "GET", // HIBP explicitly sets Access-Control-Allow-Origin: *, so a plain // fetch from the extension page works without host_permissions. headers: { "Add-Padding": "true" }, signal, }); if (!response.ok) { return { breached: false, apiReachable: false }; } const text = await response.text(); // Each line is `HASH_SUFFIX:COUNT`. A count of 0 means the hash was // inserted by the "Add-Padding" feature and is not a real match; we // must skip those rows or we'd report false positives. const lines = text.split(/\r?\n/); for (const line of lines) { const [hash, countStr] = line.split(":"); if (!hash) continue; if (hash.toUpperCase() !== suffix) continue; const count = Number.parseInt(countStr ?? "0", 10); if (Number.isFinite(count) && count > 0) { return { breached: true, apiReachable: true }; } } return { breached: false, apiReachable: true }; } catch (err) { // AbortError must propagate — the caller cancelled intentionally. if (err instanceof DOMException && err.name === "AbortError") { throw err; } return { breached: false, apiReachable: false }; } } async function sha1Hex(input: string): Promise { const bytes = new TextEncoder().encode(input); const hashBuffer = await crypto.subtle.digest("SHA-1", bytes as BufferSource); const hashBytes = new Uint8Array(hashBuffer); let hex = ""; for (let i = 0; i < hashBytes.length; i++) { hex += hashBytes[i].toString(16).padStart(2, "0"); } return hex.toUpperCase(); } // --------------------------------------------------------------------------- // Top-level validation (spec §6.1, §9) // --------------------------------------------------------------------------- export type ExportPassphraseErrorCode = | "PASSPHRASE_TOO_SHORT" | "PASSPHRASE_TOO_WEAK" | "PASSPHRASE_BREACHED" | "PASSPHRASE_MISMATCH"; export interface ValidateForExportInput { passphrase: string; confirm: string; /** * Latest HIBP result, if one is available. If the caller hasn't finished * (or started) the HIBP check, pass `undefined` and validation will * skip the breach gate — suitable for driving the real-time button * state. Only pass a real `HibpResult` at the moment the user clicks * Export. */ hibp?: HibpResult; } export interface ValidateForExportResult { ok: boolean; /** The first failure encountered, in severity order. */ error?: ExportPassphraseErrorCode; /** Populated even when `ok: true` so the UI can render the strength tier. */ strength: StrengthResult; } /** * Apply the §6.1 hard requirements plus the confirm-match gate. Order * of checks mirrors the order the user experiences problems: length * first (typo → short), then strength (user realises they need more * words), then breach (surprising, but most passwords aren't breached). * Mismatch is last because it only matters once the primary passphrase * passes the other three. */ export function validateForExport( input: ValidateForExportInput ): ValidateForExportResult { const strength = evaluateStrength(input.passphrase); if (input.passphrase.length < MIN_PASSPHRASE_LENGTH) { return { ok: false, error: "PASSPHRASE_TOO_SHORT", strength }; } if (!strength.meetsStrengthBar) { return { ok: false, error: "PASSPHRASE_TOO_WEAK", strength }; } // A positive breach result blocks. An unreachable API is treated as a // soft pass — the UI still shows a warning, but doesn't block. if (input.hibp?.breached) { return { ok: false, error: "PASSPHRASE_BREACHED", strength }; } if (input.passphrase !== input.confirm) { return { ok: false, error: "PASSPHRASE_MISMATCH", strength }; } return { ok: true, strength }; } // --------------------------------------------------------------------------- // Diceware generator (spec §6.5) // --------------------------------------------------------------------------- /** * The EFF large wordlist. Imported directly from the package's JSON so we * avoid its Node-only RNG helpers in the popup bundle. 7776 = 6^5 entries, averaging * ~12.9 bits of entropy per word. */ const EFF_WORDS: string[] = effWordlist as string[]; const DEFAULT_WORD_COUNT = 6; // ~77 bits of entropy at 12.925 bits/word /** * Generate a diceware passphrase using `crypto.getRandomValues` so the * output is CSPRNG-driven rather than Math.random. * * We select indices by generating 32-bit random values and taking * `value % 7776`. 2^32 mod 7776 is non-zero, so this introduces a tiny * modulo bias (≈ 1 / 2^19 per selection). For a 6-word passphrase that's * well below any practical attack — but if you care, pass a larger * `rejectionSampleLimit` to resample out-of-range values. * * The separator is a single space per spec §6.5 — the resulting string * can be pasted into the passphrase and confirm fields directly. */ export function generateDicewarePassphrase( wordCount: number = DEFAULT_WORD_COUNT ): string { if (!Number.isInteger(wordCount) || wordCount < 1) { throw new Error("generateDicewarePassphrase: wordCount must be ≥ 1"); } if (!EFF_WORDS || EFF_WORDS.length !== 7776) { throw new Error( `generateDicewarePassphrase: unexpected wordlist size (${EFF_WORDS?.length})` ); } const listLen = EFF_WORDS.length; // Largest multiple of listLen ≤ 2^32. Values ≥ this cutoff would // introduce modulo bias; we resample them. const bias_cutoff = Math.floor(0x100000000 / listLen) * listLen; const words: string[] = []; // Oversample to reduce the chance of needing a second pass. const pool = new Uint32Array(wordCount * 2); crypto.getRandomValues(pool); let poolIdx = 0; while (words.length < wordCount) { if (poolIdx >= pool.length) { // Rare: refill the pool from CSPRNG. crypto.getRandomValues(pool); poolIdx = 0; } const v = pool[poolIdx++]; if (v >= bias_cutoff) continue; // rejection sample words.push(EFF_WORDS[v % listLen]); } return words.join(" "); } /** * Approximate entropy (bits) of a diceware passphrase drawn from the * EFF large wordlist. Useful for UI copy next to the Generate button. */ export function dicewareEntropyBits(wordCount: number): number { return wordCount * Math.log2(EFF_WORDS.length); }