cjkPassphrase.ts
1/**2 * Export passphrase validation, strength estimation, and generation3 * (spec §6).4 *5 * Three concerns, kept separate so the UI can call them in the order it6 * wants:7 *8 * 1. `evaluateStrength(phrase)` — synchronous, runs zxcvbn. Used on every9 * keystroke to update the strength meter live.10 *11 * 2. `checkPassphraseBreached(phrase)` — async, calls HIBP. Debounced at12 * the UI layer, not here. Non-blocking per §6.3: if the API is13 * unreachable we return `{ breached: false, apiReachable: false }`14 * and the UI shows a subtle warning. A positive breach match is15 * blocking.16 *17 * 3. `validateForExport(...)` — combines the hard requirements (§6.1)18 * with the confirm-match check. Returns the exact error codes listed19 * in §9 so the UI has one lookup table for messages.20 *21 * 4. `generateDicewarePassphrase(wordCount)` — 6-word default, CSPRNG22 * selection from the EFF large wordlist (§6.5).23 */2425import zxcvbn from "zxcvbn";26import effWordlist from "eff-diceware-passphrase/wordlist.json";2728// ---------------------------------------------------------------------------29// Strength tiers (spec §6.2)30// ---------------------------------------------------------------------------3132export type StrengthTier = "weak" | "fair" | "good" | "excellent";3334export interface StrengthResult {35 /** zxcvbn 0..4 score. */36 score: 0 | 1 | 2 | 3 | 4;37 tier: StrengthTier;38 /** True iff this passphrase is strong enough to enable the Export button. */39 meetsStrengthBar: boolean;40 /** Estimated guesses, straight from zxcvbn (for display + debug). */41 guesses: number;42 /**43 * First crack-time-seconds bucket from zxcvbn. Handy for UI copy like44 * "Centuries to crack" without reimplementing the heuristic.45 */46 crackTimeDisplay: string;47 /** First concrete feedback message from zxcvbn, if any. Empty string on "no feedback". */48 feedback: string;49}5051const ZXCVBN_SCORE_GOOD = 3;52export const MIN_PASSPHRASE_LENGTH = 16;5354/**55 * Map a zxcvbn score 0..4 onto a named tier from the spec table. The56 * mapping is deterministic: 0|1 → weak, 2 → fair, 3 → good, 4 → excellent.57 */58function tierForScore(score: number): StrengthTier {59 if (score <= 1) return "weak";60 if (score === 2) return "fair";61 if (score === 3) return "good";62 return "excellent";63}6465/**66 * Synchronous strength evaluation. Safe to call on every keystroke.67 * zxcvbn runs in 5–30ms for typical inputs — if this becomes a hot path68 * the UI can debounce, but we don't add latency here.69 */70export function evaluateStrength(passphrase: string): StrengthResult {71 if (passphrase.length === 0) {72 return {73 score: 0,74 tier: "weak",75 meetsStrengthBar: false,76 guesses: 0,77 crackTimeDisplay: "instant",78 feedback: "",79 };80 }8182 const result = zxcvbn(passphrase);83 const score = result.score as 0 | 1 | 2 | 3 | 4;84 const tier = tierForScore(score);8586 // "feedback.warning" is the primary message. "feedback.suggestions[0]"87 // is a softer hint (e.g., "Add another word or two"). Prefer warning88 // when present; otherwise surface the first suggestion.89 const feedback =90 result.feedback.warning ||91 (result.feedback.suggestions && result.feedback.suggestions[0]) ||92 "";9394 return {95 score,96 tier,97 meetsStrengthBar: score >= ZXCVBN_SCORE_GOOD,98 guesses: result.guesses,99 crackTimeDisplay: String(100 result.crack_times_display.offline_slow_hashing_1e4_per_second101 ),102 feedback,103 };104}105106// ---------------------------------------------------------------------------107// HIBP k-anonymity check (spec §6.3)108// ---------------------------------------------------------------------------109110export interface HibpResult {111 breached: boolean;112 /** False if the API couldn't be reached. When false, `breached` is also false. */113 apiReachable: boolean;114}115116const HIBP_RANGE_ENDPOINT = "https://api.pwnedpasswords.com/range/";117118/**119 * Check a passphrase against HaveIBeenPwned using the k-anonymity range120 * API. Only the first 5 hex chars of the SHA-1 are sent — the passphrase121 * itself never leaves the browser.122 *123 * The API is treated as advisory. Network failure returns `apiReachable:124 * false`; the caller should allow export with a subtle warning. A positive125 * match always returns `breached: true` and should block export.126 */127export async function checkPassphraseBreached(128 passphrase: string,129 signal?: AbortSignal130): Promise<HibpResult> {131 const hashHex = await sha1Hex(passphrase);132 const prefix = hashHex.slice(0, 5);133 const suffix = hashHex.slice(5);134135 try {136 const response = await fetch(`${HIBP_RANGE_ENDPOINT}${prefix}`, {137 method: "GET",138 // HIBP explicitly sets Access-Control-Allow-Origin: *, so a plain139 // fetch from the extension page works without host_permissions.140 headers: { "Add-Padding": "true" },141 signal,142 });143 if (!response.ok) {144 return { breached: false, apiReachable: false };145 }146 const text = await response.text();147 // Each line is `HASH_SUFFIX:COUNT`. A count of 0 means the hash was148 // inserted by the "Add-Padding" feature and is not a real match; we149 // must skip those rows or we'd report false positives.150 const lines = text.split(/\r?\n/);151 for (const line of lines) {152 const [hash, countStr] = line.split(":");153 if (!hash) continue;154 if (hash.toUpperCase() !== suffix) continue;155 const count = Number.parseInt(countStr ?? "0", 10);156 if (Number.isFinite(count) && count > 0) {157 return { breached: true, apiReachable: true };158 }159 }160 return { breached: false, apiReachable: true };161 } catch (err) {162 // AbortError must propagate — the caller cancelled intentionally.163 if (err instanceof DOMException && err.name === "AbortError") {164 throw err;165 }166 return { breached: false, apiReachable: false };167 }168}169170async function sha1Hex(input: string): Promise<string> {171 const bytes = new TextEncoder().encode(input);172 const hashBuffer = await crypto.subtle.digest("SHA-1", bytes as BufferSource);173 const hashBytes = new Uint8Array(hashBuffer);174 let hex = "";175 for (let i = 0; i < hashBytes.length; i++) {176 hex += hashBytes[i].toString(16).padStart(2, "0");177 }178 return hex.toUpperCase();179}180181// ---------------------------------------------------------------------------182// Top-level validation (spec §6.1, §9)183// ---------------------------------------------------------------------------184185export type ExportPassphraseErrorCode =186 | "PASSPHRASE_TOO_SHORT"187 | "PASSPHRASE_TOO_WEAK"188 | "PASSPHRASE_BREACHED"189 | "PASSPHRASE_MISMATCH";190191export interface ValidateForExportInput {192 passphrase: string;193 confirm: string;194 /**195 * Latest HIBP result, if one is available. If the caller hasn't finished196 * (or started) the HIBP check, pass `undefined` and validation will197 * skip the breach gate — suitable for driving the real-time button198 * state. Only pass a real `HibpResult` at the moment the user clicks199 * Export.200 */201 hibp?: HibpResult;202}203204export interface ValidateForExportResult {205 ok: boolean;206 /** The first failure encountered, in severity order. */207 error?: ExportPassphraseErrorCode;208 /** Populated even when `ok: true` so the UI can render the strength tier. */209 strength: StrengthResult;210}211212/**213 * Apply the §6.1 hard requirements plus the confirm-match gate. Order214 * of checks mirrors the order the user experiences problems: length215 * first (typo → short), then strength (user realises they need more216 * words), then breach (surprising, but most passwords aren't breached).217 * Mismatch is last because it only matters once the primary passphrase218 * passes the other three.219 */220export function validateForExport(221 input: ValidateForExportInput222): ValidateForExportResult {223 const strength = evaluateStrength(input.passphrase);224225 if (input.passphrase.length < MIN_PASSPHRASE_LENGTH) {226 return { ok: false, error: "PASSPHRASE_TOO_SHORT", strength };227 }228229 if (!strength.meetsStrengthBar) {230 return { ok: false, error: "PASSPHRASE_TOO_WEAK", strength };231 }232233 // A positive breach result blocks. An unreachable API is treated as a234 // soft pass — the UI still shows a warning, but doesn't block.235 if (input.hibp?.breached) {236 return { ok: false, error: "PASSPHRASE_BREACHED", strength };237 }238239 if (input.passphrase !== input.confirm) {240 return { ok: false, error: "PASSPHRASE_MISMATCH", strength };241 }242243 return { ok: true, strength };244}245246// ---------------------------------------------------------------------------247// Diceware generator (spec §6.5)248// ---------------------------------------------------------------------------249250/**251 * The EFF large wordlist. Imported directly from the package's JSON so we252 * avoid its Node-only RNG helpers in the popup bundle. 7776 = 6^5 entries, averaging253 * ~12.9 bits of entropy per word.254 */255const EFF_WORDS: string[] = effWordlist as string[];256257const DEFAULT_WORD_COUNT = 6; // ~77 bits of entropy at 12.925 bits/word258259/**260 * Generate a diceware passphrase using `crypto.getRandomValues` so the261 * output is CSPRNG-driven rather than Math.random.262 *263 * We select indices by generating 32-bit random values and taking264 * `value % 7776`. 2^32 mod 7776 is non-zero, so this introduces a tiny265 * modulo bias (≈ 1 / 2^19 per selection). For a 6-word passphrase that's266 * well below any practical attack — but if you care, pass a larger267 * `rejectionSampleLimit` to resample out-of-range values.268 *269 * The separator is a single space per spec §6.5 — the resulting string270 * can be pasted into the passphrase and confirm fields directly.271 */272export function generateDicewarePassphrase(273 wordCount: number = DEFAULT_WORD_COUNT274): string {275 if (!Number.isInteger(wordCount) || wordCount < 1) {276 throw new Error("generateDicewarePassphrase: wordCount must be ≥ 1");277 }278279 if (!EFF_WORDS || EFF_WORDS.length !== 7776) {280 throw new Error(281 `generateDicewarePassphrase: unexpected wordlist size (${EFF_WORDS?.length})`282 );283 }284285 const listLen = EFF_WORDS.length;286 // Largest multiple of listLen ≤ 2^32. Values ≥ this cutoff would287 // introduce modulo bias; we resample them.288 const bias_cutoff = Math.floor(0x100000000 / listLen) * listLen;289290 const words: string[] = [];291 // Oversample to reduce the chance of needing a second pass.292 const pool = new Uint32Array(wordCount * 2);293 crypto.getRandomValues(pool);294 let poolIdx = 0;295296 while (words.length < wordCount) {297 if (poolIdx >= pool.length) {298 // Rare: refill the pool from CSPRNG.299 crypto.getRandomValues(pool);300 poolIdx = 0;301 }302 const v = pool[poolIdx++];303 if (v >= bias_cutoff) continue; // rejection sample304 words.push(EFF_WORDS[v % listLen]);305 }306307 return words.join(" ");308}309310/**311 * Approximate entropy (bits) of a diceware passphrase drawn from the312 * EFF large wordlist. Useful for UI copy next to the Generate button.313 */314export function dicewareEntropyBits(wordCount: number): number {315 return wordCount * Math.log2(EFF_WORDS.length);316}