feat(ext/setup): zxcvbn strength meter + score>=3 gate (audit H3)

Replaces the ad-hoc char-class passphraseStrength() with a 5-segment
bar backed by a SW round-trip to rate_passphrase (zxcvbn). Input
handler debounces 150ms so we don't hammer the worker per keystroke.

The create-vault button is disabled unless the last score is ≥ 3
(zxcvbn's "safely unguessable" threshold), and the handler re-rates
synchronously on click as defence-in-depth. Label flips between "Too
weak" (red) and "Strong enough" (green).

Also:
- rewrites the vault-creation path to use the typed-item unlock +
  manifest_encrypt APIs (derive_master_key/encrypt_manifest are gone);
  the new initial manifest is { schema_version: 2, items: {} }.
- wasm.d.ts is now a pure `declare module 'relicario-wasm'` block;
  tsconfig's stale `paths` alias is removed.
- @ts-nocheck removed from setup.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-20 21:38:50 -04:00
parent 76bb61aa10
commit f3b915a635
4 changed files with 194 additions and 141 deletions

View File

@@ -49,23 +49,38 @@
}
.strength-bar {
display: flex;
gap: 3px;
margin-top: 6px;
}
.strength-bar .seg {
flex: 1;
height: 4px;
background: #21262d;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
transition: background 0.2s;
}
.strength-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.2s, background 0.2s;
}
/* 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.s2 .seg.i0,
.strength-bar.s2 .seg.i1,
.strength-bar.s2 .seg.i2 { background: #d29922; }
.strength-bar.s3 .seg.i0,
.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-fill.weak { background: #f85149; width: 25%; }
.strength-bar-fill.fair { background: #d29922; width: 50%; }
.strength-bar-fill.good { background: #3fb950; width: 75%; }
.strength-bar-fill.strong { background: #58a6ff; width: 100%; }
.strength-label {
font-size: 11px;
margin-top: 3px;
}
.strength-label.weak { color: #f85149; }
.strength-label.strong { color: #3fb950; }
.success-box {
background: #0d1b0e;

View File

@@ -1,4 +1,3 @@
// @ts-nocheck — transitional: downstream files updated in Slice 6 (item-* rewrites) / Slice 4 (vitest setup) / Slice 5 (content + setup rewires)
/// Vault initialization wizard — 4-step flow for creating new relicario vaults.
///
/// Step 1: Choose host type (Gitea / GitHub)
@@ -7,7 +6,6 @@
/// Step 4: Finish (download reference image, push config to extension or copy JSON)
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
import type { GitHost } from '../service-worker/git-host';
import type { VaultConfig } from '../shared/types';
// --- WASM module (loaded dynamically) ---
@@ -38,6 +36,8 @@ interface WizardState {
carrierImageBytes: Uint8Array | null;
passphrase: string;
passphraseConfirm: string;
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
passphraseScore: number;
referenceImageBytes: Uint8Array | null;
creating: boolean;
error: string | null;
@@ -55,6 +55,7 @@ const state: WizardState = {
carrierImageBytes: null,
passphrase: '',
passphraseConfirm: '',
passphraseScore: -1,
referenceImageBytes: null,
creating: false,
error: null,
@@ -72,17 +73,57 @@ function escapeHtml(s: string): string {
.replace(/"/g, '&quot;');
}
function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' {
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 14) score++;
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^a-zA-Z0-9]/.test(pw)) score++;
if (score <= 1) return 'weak';
if (score <= 2) return 'fair';
if (score <= 3) return 'good';
return 'strong';
/// 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> {
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 || !response?.ok) { resolve(-1); return; }
resolve(response.data?.score ?? -1);
},
);
} catch {
resolve(-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<typeof setTimeout> | null = null;
function scheduleRate(passphrase: string, onScore: (score: number) => void): void {
if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer);
rateDebounceTimer = setTimeout(async () => {
rateDebounceTimer = null;
if (!passphrase) { onScore(-1); return; }
onScore(await ratePassphrase(passphrase));
}, 150);
}
/// Update just the meter DOM without a full re-render (so the input keeps
/// focus and the user's cursor position is preserved).
function updateStrengthUi(): void {
const bar = document.getElementById('strength-bar');
const label = document.getElementById('strength-label');
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
const score = state.passphraseScore;
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';
}
}
if (create) create.disabled = state.creating || score < 3;
}
// --- Render ---
@@ -267,7 +308,14 @@ function attachStep2(): void {
// --- Step 3: Create Vault ---
function renderStep3(): string {
const strength = state.passphrase ? passphraseStrength(state.passphrase) : null;
const score = state.passphraseScore;
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;
return `
<div class="wizard-step">
@@ -284,21 +332,23 @@ function renderStep3(): string {
</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">
${strength ? `
<div class="strength-bar">
<div class="strength-bar-fill ${strength}"></div>
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase" autocomplete="new-password">
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
<div class="seg i0"></div>
<div class="seg i1"></div>
<div class="seg i2"></div>
<div class="seg i3"></div>
<div class="seg i4"></div>
</div>
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
` : ''}
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</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">
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase" autocomplete="new-password">
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="create-btn" ${state.creating ? 'disabled' : ''}>
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>
${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
</button>
</div>
@@ -324,22 +374,14 @@ function attachStep3(): void {
reader.readAsArrayBuffer(file);
});
// Track passphrase changes without full re-render
// 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) => {
state.passphrase = (e.target as HTMLInputElement).value;
// Update strength bar inline
const strength = passphraseStrength(state.passphrase);
const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null;
const label = document.querySelector('.strength-bar + .muted') as HTMLElement | null;
if (bar) {
bar.className = `strength-bar-fill ${strength}`;
}
if (label) {
label.textContent = `strength: ${strength}`;
}
if (!bar && state.passphrase) {
render();
}
scheduleRate(state.passphrase, (score) => {
state.passphraseScore = score;
updateStrengthUi();
});
});
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
@@ -367,6 +409,15 @@ function attachStep3(): void {
render();
return;
}
// 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);
if (state.passphraseScore < 3) {
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
render();
return;
}
if (state.passphrase !== state.passphraseConfirm) {
state.error = 'Passphrases do not match';
render();
@@ -380,58 +431,41 @@ function attachStep3(): void {
try {
const w = await loadWasm();
// 1. Generate 32-byte image secret
// 1. Generate 32-byte image secret.
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
// 2. Embed secret into carrier JPEG
// 2. Embed secret into carrier JPEG.
state.referenceImageBytes = new Uint8Array(
w.embed_image_secret(state.carrierImageBytes, imageSecret)
w.embed_image_secret(state.carrierImageBytes, imageSecret),
);
// 3. Generate 32-byte salt
// 3. Generate 32-byte salt + KDF params.
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
// 4. Create KDF params
const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
// 5. Derive master key
const masterKey = w.derive_master_key(
state.passphrase,
imageSecret,
salt,
paramsJson,
);
// 4. Derive a session handle via the typed-item unlock API.
// (Single-shot master_key derivation is no longer exposed; the
// handle is the only in-JS reference to the master key.)
const handle = w.unlock(state.passphrase, imageSecret, salt, paramsJson);
// 6. Encrypt empty manifest
const manifestJson = '{"entries":{},"version":1}';
const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey);
// 5. Encrypt an empty schema-v2 manifest.
const manifestJson = '{"schema_version":2,"items":{}}';
const encryptedManifest = w.manifest_encrypt(handle, manifestJson);
// 7. Push vault files via git API
// 6. Push vault files via git API.
const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
await host.writeFile(
'.relicario/salt',
salt,
'init: vault salt',
);
await host.writeFile('.relicario/salt', salt, 'init: vault salt');
const paramsBytes = new TextEncoder().encode(paramsJson);
await host.writeFile(
'.relicario/params.json',
paramsBytes,
'init: KDF parameters',
);
await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters');
const devicesJson = '{"devices":[]}';
const devicesBytes = new TextEncoder().encode(devicesJson);
await host.writeFile(
'.relicario/devices.json',
devicesBytes,
'init: device registry',
);
await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry');
await host.writeFile(
'manifest.enc',
@@ -439,12 +473,15 @@ function attachStep3(): void {
'init: encrypted manifest',
);
// 8. Advance to step 4
// 7. Release the handle — the SW's own unlock will re-derive.
w.lock(handle);
// 8. Advance to step 4.
state.creating = false;
state.step = 4;
state.error = null;
// Detect extension
// Detect extension.
detectExtension();
render();

View File

@@ -1,7 +1,13 @@
// Thin TypeScript declarations for the relicario-wasm bindings.
// These are hand-written to mirror the #[wasm_bindgen] signatures in
// crates/relicario-wasm/src/lib.rs; keep them in sync manually.
//
// Declared under the bare specifier 'relicario-wasm' so `typeof
// import('relicario-wasm')` resolves in setup.ts. Webpack doesn't
// actually resolve the module — setup.ts loads the auto-generated
// wasm/relicario_wasm.js via a webpackIgnore dynamic import at runtime.
declare module 'relicario-wasm' {
export class SessionHandle {
readonly value: number;
free(): void;
@@ -54,8 +60,6 @@ export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uin
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
// Initializer (wasm-bindgen's default init function).
export default function init(module_or_path?: unknown): Promise<void>;
// wasm-bindgen's sync init — Chrome MV3 service workers can't use dynamic import().
export function initSync(args: { module: WebAssembly.Module }): void;
}

View File

@@ -9,9 +9,6 @@
"rootDir": "./src",
"sourceMap": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {
"relicario-wasm": ["./wasm/relicario_wasm.js"]
},
"baseUrl": "."
},
"include": ["src/**/*"],