feat(ext/setup): polished passphrase entry UX

Setup wizard step 3 now has self-explanatory passphrase feedback:

- Strength meter: 5 segments with smooth color transitions
  (very-weak/weak/fair/good/strong). Tier 4 gets a subtle glow.
- Nuanced label (lowercase, tracked): "very weak" / "weak" / "fair" /
  "good" / "strong" — color-matched to each tier.
- Entropy readout line: "~10^N guesses — <time to crack>" with
  tiered shorthand (trivial / minutes-on-GPU / hours-to-days /
  years-on-consumer / beyond consumer / uncrackable).
- Live char counter in the strength row.
- Eye toggle buttons on both passphrase fields. Flip type="password"
  <-> type="text" without re-render, preserving focus + cursor.
- Live match indicator (✓ / ✗) between the confirm field and its eye
  toggle. Updates per keystroke.
- Create button gate widened: now requires score >= 3 AND confirm
  field filled AND confirm matches. Disabled button carries a
  tooltip saying which condition failed.
- Contextual help box above the passphrase field explaining the
  "long phrase > complex password" idea + the score >= 3 threshold.

All live-update paths (counter, label, entropy, match indicator,
button gate) go through updateStrengthUi() which targets the DOM
directly — no full re-render, so focus/cursor survive every keystroke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-22 19:38:50 -04:00
parent 4341124d38
commit 69bb58c977
2 changed files with 265 additions and 39 deletions

View File

@@ -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, '&quot;');
}
/// 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<number> {
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<Strength> {
return new Promise((resolve) => {
try {
chrome.runtime.sendMessage(
@@ -84,20 +92,23 @@ function ratePassphrase(passphrase: string): Promise<number> {
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<number> {
/// 150ms debounce around the rate_passphrase call so we don't hammer the SW
/// on every keystroke. The last invocation wins.
let rateDebounceTimer: ReturnType<typeof setTimeout> | 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<number, { text: string; cls: string }> = {
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 = '&nbsp;';
} 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
? '&nbsp;'
: (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 ?? '&nbsp;';
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 `
<div class="wizard-step">
<h3>create vault</h3>
<div class="form-group">
<label class="label">carrier image (JPEG)</label>
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
@@ -341,9 +434,18 @@ function renderStep3(): string {
</div>
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
</div>
<div class="pass-help">
A long phrase of unrelated words is stronger than a short complex password.
Your vault needs <strong>good</strong> (score&nbsp;≥&nbsp;3) to continue.
</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase" autocomplete="new-password">
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
</div>
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
<div class="seg i0"></div>
<div class="seg i1"></div>
@@ -351,12 +453,22 @@ function renderStep3(): string {
<div class="seg i3"></div>
<div class="seg i4"></div>
</div>
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
<div class="strength-row">
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
</div>
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
</div>
<div class="form-group">
<label class="label" for="passphrase-confirm">confirm passphrase</label>
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase" autocomplete="new-password">
<div class="passphrase-field">
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
@@ -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();