diff --git a/extension/setup.html b/extension/setup.html index fd77af9..e7b0b08 100644 --- a/extension/setup.html +++ b/extension/setup.html @@ -51,21 +51,21 @@ .strength-bar { display: flex; gap: 3px; - margin-top: 6px; + margin-top: 8px; } .strength-bar .seg { flex: 1; - height: 4px; + height: 5px; background: #21262d; - border-radius: 2px; - transition: background 0.2s; + border-radius: 3px; + transition: background 0.25s ease, box-shadow 0.25s ease; } /* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */ .strength-bar.s0 .seg.i0 { background: #f85149; } .strength-bar.s1 .seg.i0, - .strength-bar.s1 .seg.i1 { background: #db6d28; } + .strength-bar.s1 .seg.i1 { background: #f08d49; } .strength-bar.s2 .seg.i0, .strength-bar.s2 .seg.i1, .strength-bar.s2 .seg.i2 { background: #d29922; } @@ -73,14 +73,101 @@ .strength-bar.s3 .seg.i1, .strength-bar.s3 .seg.i2, .strength-bar.s3 .seg.i3 { background: #3fb950; } - .strength-bar.s4 .seg { background: #3fb950; } + .strength-bar.s4 .seg { + background: #56d364; + box-shadow: 0 0 4px rgba(86, 211, 100, 0.4); + } + .strength-row { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-top: 5px; + } .strength-label { font-size: 11px; - margin-top: 3px; + margin: 0; + text-transform: lowercase; + letter-spacing: 0.03em; + transition: color 0.2s ease; + } + .strength-label.s-very-weak { color: #f85149; } + .strength-label.s-weak { color: #f08d49; } + .strength-label.s-fair { color: #d29922; } + .strength-label.s-good { color: #3fb950; } + .strength-label.s-strong { color: #56d364; font-weight: 600; } + + .char-counter { + font-size: 10px; + color: #6e7681; + margin: 0; + font-variant-numeric: tabular-nums; + } + + .entropy-line { + font-size: 10px; + color: #8b949e; + margin-top: 2px; + font-family: "SF Mono", "JetBrains Mono", monospace; + min-height: 1em; + } + + .pass-help { + background: #0d1117; + border: 1px solid #21262d; + border-left: 2px solid #1f6feb; + border-radius: 4px; + padding: 8px 12px; + font-size: 11px; + color: #8b949e; + line-height: 1.55; + margin: 10px 0; + } + .pass-help strong { color: #c9d1d9; } + + .passphrase-field { + position: relative; + } + .passphrase-field input { + padding-right: 76px; /* room for match indicator + eye button */ + } + .eye-btn { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + height: 24px; + padding: 0 8px; + background: transparent; + border: 1px solid #30363d; + border-radius: 3px; + color: #8b949e; + cursor: pointer; + font-size: 10px; + font-family: inherit; + text-transform: lowercase; + letter-spacing: 0.03em; + } + .eye-btn:hover { color: #c9d1d9; border-color: #484f58; } + + .match-indicator { + position: absolute; + right: 50px; + top: 50%; + transform: translateY(-50%); + font-size: 16px; + line-height: 1; + pointer-events: none; + transition: color 0.15s ease, opacity 0.15s ease; + } + .match-indicator.ok { color: #3fb950; } + .match-indicator.bad { color: #f85149; } + + /* Primary button explicitly dims when disabled so the gate is obvious. */ + .btn-primary:disabled { + opacity: 0.45; + cursor: not-allowed; } - .strength-label.weak { color: #f85149; } - .strength-label.strong { color: #3fb950; } .success-box { background: #0d1b0e; diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 3e901c9..218c554 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -38,6 +38,9 @@ interface WizardState { passphraseConfirm: string; // zxcvbn meter state — -1 means "not yet scored" (empty passphrase). passphraseScore: number; + passphraseGuessesLog10: number; // -1 before first rating + passphraseVisible: boolean; + confirmVisible: boolean; referenceImageBytes: Uint8Array | null; creating: boolean; error: string | null; @@ -56,6 +59,9 @@ const state: WizardState = { passphrase: '', passphraseConfirm: '', passphraseScore: -1, + passphraseGuessesLog10: -1, + passphraseVisible: false, + confirmVisible: false, referenceImageBytes: null, creating: false, error: null, @@ -73,9 +79,11 @@ function escapeHtml(s: string): string { .replace(/"/g, '"'); } -/// Call the SW to score a passphrase with zxcvbn. Returns a score in [0, 4] -/// per the zxcvbn convention, or -1 if the message round-trip failed. -function ratePassphrase(passphrase: string): Promise { +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( @@ -84,20 +92,23 @@ function ratePassphrase(passphrase: string): Promise { if (chrome.runtime.lastError) { // eslint-disable-next-line no-console console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); - resolve(-1); return; + 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(-1); return; + resolve({ score: -1, guessesLog10: -1 }); return; } - resolve(response.data?.score ?? -1); + 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(-1); + resolve({ score: -1, guessesLog10: -1 }); } }); } @@ -105,36 +116,101 @@ function ratePassphrase(passphrase: string): Promise { /// 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, onScore: (score: number) => void): void { +function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void { if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); rateDebounceTimer = setTimeout(async () => { rateDebounceTimer = null; - if (!passphrase) { onScore(-1); return; } - onScore(await ratePassphrase(passphrase)); + 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`; +} + /// Update just the meter DOM without a full re-render (so the input keeps -/// focus and the user's cursor position is preserved). +/// focus and the user's cursor position is preserved). Also updates the +/// char counter and confirm-match indicator live. function updateStrengthUi(): void { const bar = document.getElementById('strength-bar'); const label = document.getElementById('strength-label'); + const entropy = document.getElementById('entropy-line'); + const counter = document.getElementById('passphrase-counter'); + const matchInd = document.getElementById('match-indicator'); const create = document.getElementById('create-btn') as HTMLButtonElement | null; + const score = state.passphraseScore; + const guessesLog10 = state.passphraseGuessesLog10; + if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`; + if (label) { if (score < 0) { label.className = 'strength-label'; label.innerHTML = ' '; - } else if (score >= 3) { - label.className = 'strength-label strong'; - label.textContent = 'Strong enough'; } else { - label.className = 'strength-label weak'; - label.textContent = 'Too weak'; + const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0]; + label.className = `strength-label ${meta.cls}`; + label.textContent = meta.text; } } - if (create) create.disabled = state.creating || score < 3; + + if (entropy) { + const txt = entropyText(guessesLog10); + entropy.textContent = txt; + entropy.style.visibility = txt ? 'visible' : 'hidden'; + } + + if (counter) { + const n = state.passphrase.length; + counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`; + } + + if (matchInd) { + const p = state.passphrase; + const c = state.passphraseConfirm; + if (!p || !c) { + matchInd.className = 'match-indicator'; + matchInd.textContent = ''; + } else if (p === c) { + matchInd.className = 'match-indicator ok'; + matchInd.textContent = '✓'; + } else { + matchInd.className = 'match-indicator bad'; + matchInd.textContent = '✗'; + } + } + + const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm; + if (create) { + const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk; + create.disabled = disabled; + create.title = disabled + ? (score < 3 + ? 'passphrase must score "good" or better' + : !state.passphraseConfirm ? 'confirm your passphrase' + : !matchOk ? 'passphrases do not match' + : '') + : ''; + } } // --- Render --- @@ -320,17 +396,34 @@ function attachStep2(): void { function renderStep3(): string { const score = state.passphraseScore; + const guessesLog10 = state.passphraseGuessesLog10; const hasScore = score >= 0; const meterClass = hasScore ? `s${score}` : ''; - const labelClass = hasScore ? (score >= 3 ? 'strong' : 'weak') : ''; - const labelText = !hasScore - ? ' ' - : (score >= 3 ? 'Strong enough' : 'Too weak'); - const gateDisabled = state.creating || score < 3; + const labelMeta = hasScore ? STRENGTH_LABELS[score] : null; + const labelClass = labelMeta?.cls ?? ''; + const labelText = labelMeta?.text ?? ' '; + const entropy = entropyText(guessesLog10); + + const p = state.passphrase; + const c = state.passphraseConfirm; + const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad'; + const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : ''; + + const pType = state.passphraseVisible ? 'text' : 'password'; + const cType = state.confirmVisible ? 'text' : 'password'; + const pToggle = state.passphraseVisible ? 'hide' : 'show'; + const cToggle = state.confirmVisible ? 'hide' : 'show'; + + const matchOk = !c || p === c; + const gateDisabled = state.creating || score < 3 || !c || !matchOk; + + const nChars = p.length; + const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`; return ` create vault + carrier image (JPEG) @@ -341,9 +434,18 @@ function renderStep3(): string { A 256-bit secret will be steganographically embedded in this image. + + + A long phrase of unrelated words is stronger than a short complex password. + Your vault needs good (score ≥ 3) to continue. + + passphrase - + + + ${pToggle} + @@ -351,12 +453,22 @@ function renderStep3(): string { - ${labelText} + + ${labelText} + ${escapeHtml(counterText)} + + ${escapeHtml(entropy || ' ')} + confirm passphrase - + + + ${matchGlyph} + ${cToggle} + + back @@ -387,16 +499,41 @@ function attachStep3(): void { // Track passphrase changes inline (no full re-render) so the input keeps focus. // zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate. - document.getElementById('passphrase')?.addEventListener('input', (e) => { + const passInput = document.getElementById('passphrase') as HTMLInputElement | null; + passInput?.addEventListener('input', (e) => { state.passphrase = (e.target as HTMLInputElement).value; - scheduleRate(state.passphrase, (score) => { - state.passphraseScore = score; + // Update char counter + match indicator + button gate immediately on every keystroke. + updateStrengthUi(); + // Score updates on the 150ms debounce to avoid SW hammering. + scheduleRate(state.passphrase, (s) => { + state.passphraseScore = s.score; + state.passphraseGuessesLog10 = s.guessesLog10; updateStrengthUi(); }); }); - document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => { + const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null; + confirmInput?.addEventListener('input', (e) => { state.passphraseConfirm = (e.target as HTMLInputElement).value; + updateStrengthUi(); + }); + + // Eye toggles — flip the input type and label without a full re-render so + // focus + cursor position survive the click. + document.getElementById('eye-btn')?.addEventListener('click', () => { + state.passphraseVisible = !state.passphraseVisible; + if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password'; + const btn = document.getElementById('eye-btn'); + if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show'; + passInput?.focus(); + }); + + document.getElementById('confirm-eye-btn')?.addEventListener('click', () => { + state.confirmVisible = !state.confirmVisible; + if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password'; + const btn = document.getElementById('confirm-eye-btn'); + if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show'; + confirmInput?.focus(); }); document.getElementById('back-btn')?.addEventListener('click', () => { @@ -423,7 +560,9 @@ function attachStep3(): void { // Re-rate synchronously in case the button was clicked before the // debounced rater fired. Defence in depth — the button is already // disabled in the UI when score < 3 (audit H3). - state.passphraseScore = await ratePassphrase(state.passphrase); + const strength = await ratePassphrase(state.passphrase); + state.passphraseScore = strength.score; + state.passphraseGuessesLog10 = strength.guessesLog10; if (state.passphraseScore < 3) { state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).'; render();
A 256-bit secret will be steganographically embedded in this image.
${labelText}
${escapeHtml(counterText)}
${escapeHtml(entropy || ' ')}