cjkPassphrase.ts

10.8 KB · raw

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}