diff --git a/extension/src/setup/setup-helpers.ts b/extension/src/setup/setup-helpers.ts new file mode 100644 index 0000000..31087c3 --- /dev/null +++ b/extension/src/setup/setup-helpers.ts @@ -0,0 +1,84 @@ +/// Pure helpers for the setup wizard. Anything that doesn't read or mutate +/// the wizard state lives here so setup.ts can focus on flow and rendering. +/// +/// `updateStrengthUi` deliberately stays in setup.ts because it walks the +/// live `state` object — keeping it here would force every caller to pass +/// the entire wizard state through. + +export interface Strength { + score: number; + guessesLog10: number; +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4] +/// and guesses_log10, or -1 on both if the round-trip failed. +export function ratePassphrase(passphrase: string): Promise { + return new Promise((resolve) => { + try { + chrome.runtime.sendMessage( + { type: 'rate_passphrase', passphrase }, + (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { + if (chrome.runtime.lastError) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); + resolve({ score: -1, guessesLog10: -1 }); return; + } + if (!response?.ok) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase rejected by SW:', response); + resolve({ score: -1, guessesLog10: -1 }); return; + } + resolve({ + score: response.data?.score ?? -1, + guessesLog10: response.data?.guesses_log10 ?? -1, + }); + }, + ); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase threw:', err); + resolve({ score: -1, guessesLog10: -1 }); + } + }); +} + +/// 150ms debounce around the rate_passphrase call so we don't hammer the SW +/// on every keystroke. The last invocation wins. +let rateDebounceTimer: ReturnType | null = null; +export function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void { + if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); + rateDebounceTimer = setTimeout(async () => { + rateDebounceTimer = null; + if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; } + onResult(await ratePassphrase(passphrase)); + }, 150); +} + +export const STRENGTH_LABELS: Record = { + 0: { text: 'very weak', cls: 's-very-weak' }, + 1: { text: 'weak', cls: 's-weak' }, + 2: { text: 'fair', cls: 's-fair' }, + 3: { text: 'good', cls: 's-good' }, + 4: { text: 'strong', cls: 's-strong' }, +}; + +/// Render the entropy readout as "~10^N guesses to crack" or a friendlier +/// shorthand for large values. Returns empty string when no data. +export function entropyText(guessesLog10: number): string { + if (guessesLog10 < 0) return ''; + const rounded = Math.round(guessesLog10); + if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`; + if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`; + if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`; + if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`; + if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`; + return `~10^${rounded} guesses — effectively uncrackable`; +} diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index db73702..90eec22 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -9,6 +9,13 @@ import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; import { addDevice } from '../service-worker/devices'; import { probeVault } from './probe'; +import { + escapeHtml, + ratePassphrase, + scheduleRate, + STRENGTH_LABELS, + entropyText, +} from './setup-helpers'; import type { VaultConfig } from '../shared/types'; import type { SessionHandle } from 'relicario-wasm'; @@ -85,82 +92,7 @@ const state: WizardState = { deviceName: '', }; -// --- Helpers --- - -function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -interface Strength { score: number; guessesLog10: number } - -/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4] -/// and guesses_log10, or -1 on both if the round-trip failed. -function ratePassphrase(passphrase: string): Promise { - return new Promise((resolve) => { - try { - chrome.runtime.sendMessage( - { type: 'rate_passphrase', passphrase }, - (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { - if (chrome.runtime.lastError) { - // eslint-disable-next-line no-console - console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); - resolve({ score: -1, guessesLog10: -1 }); return; - } - if (!response?.ok) { - // eslint-disable-next-line no-console - console.warn('[relicario setup] rate_passphrase rejected by SW:', response); - resolve({ score: -1, guessesLog10: -1 }); return; - } - resolve({ - score: response.data?.score ?? -1, - guessesLog10: response.data?.guesses_log10 ?? -1, - }); - }, - ); - } catch (err) { - // eslint-disable-next-line no-console - console.warn('[relicario setup] rate_passphrase threw:', err); - resolve({ score: -1, guessesLog10: -1 }); - } - }); -} - -/// 150ms debounce around the rate_passphrase call so we don't hammer the SW -/// on every keystroke. The last invocation wins. -let rateDebounceTimer: ReturnType | null = null; -function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void { - if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); - rateDebounceTimer = setTimeout(async () => { - rateDebounceTimer = null; - if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; } - onResult(await ratePassphrase(passphrase)); - }, 150); -} - -const STRENGTH_LABELS: Record = { - 0: { text: 'very weak', cls: 's-very-weak' }, - 1: { text: 'weak', cls: 's-weak' }, - 2: { text: 'fair', cls: 's-fair' }, - 3: { text: 'good', cls: 's-good' }, - 4: { text: 'strong', cls: 's-strong' }, -}; - -/// Render the entropy readout as "~10^N guesses to crack" or a friendlier -/// shorthand for large values. Returns empty string when no data. -function entropyText(guessesLog10: number): string { - if (guessesLog10 < 0) return ''; - const rounded = Math.round(guessesLog10); - if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`; - if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`; - if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`; - if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`; - if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`; - return `~10^${rounded} guesses — effectively uncrackable`; -} +// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- /// Update just the meter DOM without a full re-render (so the input keeps /// focus and the user's cursor position is preserved). Also updates the