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 { .strength-bar {
display: flex;
gap: 3px;
margin-top: 6px;
}
.strength-bar .seg {
flex: 1;
height: 4px; height: 4px;
background: #21262d; background: #21262d;
border-radius: 2px; border-radius: 2px;
margin-top: 6px; transition: background 0.2s;
overflow: hidden;
} }
.strength-bar-fill { /* zxcvbn score-driven colors. Higher-scored bars light up earlier bars too. */
height: 100%; .strength-bar.s0 .seg.i0 { background: #f85149; }
border-radius: 2px; .strength-bar.s1 .seg.i0,
transition: width 0.2s, background 0.2s; .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-label {
.strength-bar-fill.fair { background: #d29922; width: 50%; } font-size: 11px;
.strength-bar-fill.good { background: #3fb950; width: 75%; } margin-top: 3px;
.strength-bar-fill.strong { background: #58a6ff; width: 100%; } }
.strength-label.weak { color: #f85149; }
.strength-label.strong { color: #3fb950; }
.success-box { .success-box {
background: #0d1b0e; 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. /// Vault initialization wizard — 4-step flow for creating new relicario vaults.
/// ///
/// Step 1: Choose host type (Gitea / GitHub) /// Step 1: Choose host type (Gitea / GitHub)
@@ -7,7 +6,6 @@
/// Step 4: Finish (download reference image, push config to extension or copy JSON) /// Step 4: Finish (download reference image, push config to extension or copy JSON)
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
import type { GitHost } from '../service-worker/git-host';
import type { VaultConfig } from '../shared/types'; import type { VaultConfig } from '../shared/types';
// --- WASM module (loaded dynamically) --- // --- WASM module (loaded dynamically) ---
@@ -38,6 +36,8 @@ interface WizardState {
carrierImageBytes: Uint8Array | null; carrierImageBytes: Uint8Array | null;
passphrase: string; passphrase: string;
passphraseConfirm: string; passphraseConfirm: string;
// zxcvbn meter state — -1 means "not yet scored" (empty passphrase).
passphraseScore: number;
referenceImageBytes: Uint8Array | null; referenceImageBytes: Uint8Array | null;
creating: boolean; creating: boolean;
error: string | null; error: string | null;
@@ -55,6 +55,7 @@ const state: WizardState = {
carrierImageBytes: null, carrierImageBytes: null,
passphrase: '', passphrase: '',
passphraseConfirm: '', passphraseConfirm: '',
passphraseScore: -1,
referenceImageBytes: null, referenceImageBytes: null,
creating: false, creating: false,
error: null, error: null,
@@ -72,17 +73,57 @@ function escapeHtml(s: string): string {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' { /// Call the SW to score a passphrase with zxcvbn. Returns a score in [0, 4]
let score = 0; /// per the zxcvbn convention, or -1 if the message round-trip failed.
if (pw.length >= 8) score++; function ratePassphrase(passphrase: string): Promise<number> {
if (pw.length >= 14) score++; return new Promise((resolve) => {
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; try {
if (/[0-9]/.test(pw)) score++; chrome.runtime.sendMessage(
if (/[^a-zA-Z0-9]/.test(pw)) score++; { type: 'rate_passphrase', passphrase },
if (score <= 1) return 'weak'; (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => {
if (score <= 2) return 'fair'; if (chrome.runtime.lastError || !response?.ok) { resolve(-1); return; }
if (score <= 3) return 'good'; resolve(response.data?.score ?? -1);
return 'strong'; },
);
} 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 --- // --- Render ---
@@ -267,7 +308,14 @@ function attachStep2(): void {
// --- Step 3: Create Vault --- // --- Step 3: Create Vault ---
function renderStep3(): string { 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 ` return `
<div class="wizard-step"> <div class="wizard-step">
@@ -284,21 +332,23 @@ function renderStep3(): string {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="label" for="passphrase">passphrase</label> <label class="label" for="passphrase">passphrase</label>
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase"> <input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase" autocomplete="new-password">
${strength ? ` <div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
<div class="strength-bar"> <div class="seg i0"></div>
<div class="strength-bar-fill ${strength}"></div> <div class="seg i1"></div>
</div> <div class="seg i2"></div>
<p class="muted" style="margin-top:2px;">strength: ${strength}</p> <div class="seg i3"></div>
` : ''} <div class="seg i4"></div>
</div>
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="label" for="passphrase-confirm">confirm passphrase</label> <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>
<div class="form-actions"> <div class="form-actions">
<button class="btn" id="back-btn">back</button> <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'} ${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
</button> </button>
</div> </div>
@@ -324,22 +374,14 @@ function attachStep3(): void {
reader.readAsArrayBuffer(file); 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) => { document.getElementById('passphrase')?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value; state.passphrase = (e.target as HTMLInputElement).value;
// Update strength bar inline scheduleRate(state.passphrase, (score) => {
const strength = passphraseStrength(state.passphrase); state.passphraseScore = score;
const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null; updateStrengthUi();
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();
}
}); });
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => { document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
@@ -367,6 +409,15 @@ function attachStep3(): void {
render(); render();
return; 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) { if (state.passphrase !== state.passphraseConfirm) {
state.error = 'Passphrases do not match'; state.error = 'Passphrases do not match';
render(); render();
@@ -380,58 +431,41 @@ function attachStep3(): void {
try { try {
const w = await loadWasm(); const w = await loadWasm();
// 1. Generate 32-byte image secret // 1. Generate 32-byte image secret.
const imageSecret = new Uint8Array(32); const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret); crypto.getRandomValues(imageSecret);
// 2. Embed secret into carrier JPEG // 2. Embed secret into carrier JPEG.
state.referenceImageBytes = new Uint8Array( 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); const salt = new Uint8Array(32);
crypto.getRandomValues(salt); crypto.getRandomValues(salt);
// 4. Create KDF params
const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
// 5. Derive master key // 4. Derive a session handle via the typed-item unlock API.
const masterKey = w.derive_master_key( // (Single-shot master_key derivation is no longer exposed; the
state.passphrase, // handle is the only in-JS reference to the master key.)
imageSecret, const handle = w.unlock(state.passphrase, imageSecret, salt, paramsJson);
salt,
paramsJson,
);
// 6. Encrypt empty manifest // 5. Encrypt an empty schema-v2 manifest.
const manifestJson = '{"entries":{},"version":1}'; const manifestJson = '{"schema_version":2,"items":{}}';
const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey); 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 hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken); const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
await host.writeFile( await host.writeFile('.relicario/salt', salt, 'init: vault salt');
'.relicario/salt',
salt,
'init: vault salt',
);
const paramsBytes = new TextEncoder().encode(paramsJson); const paramsBytes = new TextEncoder().encode(paramsJson);
await host.writeFile( await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters');
'.relicario/params.json',
paramsBytes,
'init: KDF parameters',
);
const devicesJson = '{"devices":[]}'; const devicesJson = '{"devices":[]}';
const devicesBytes = new TextEncoder().encode(devicesJson); const devicesBytes = new TextEncoder().encode(devicesJson);
await host.writeFile( await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry');
'.relicario/devices.json',
devicesBytes,
'init: device registry',
);
await host.writeFile( await host.writeFile(
'manifest.enc', 'manifest.enc',
@@ -439,12 +473,15 @@ function attachStep3(): void {
'init: encrypted manifest', '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.creating = false;
state.step = 4; state.step = 4;
state.error = null; state.error = null;
// Detect extension // Detect extension.
detectExtension(); detectExtension();
render(); render();

View File

@@ -1,61 +1,65 @@
// Thin TypeScript declarations for the relicario-wasm bindings. // Thin TypeScript declarations for the relicario-wasm bindings.
// These are hand-written to mirror the #[wasm_bindgen] signatures in // These are hand-written to mirror the #[wasm_bindgen] signatures in
// crates/relicario-wasm/src/lib.rs; keep them in sync manually. // 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.
export class SessionHandle { declare module 'relicario-wasm' {
readonly value: number; export class SessionHandle {
free(): void; readonly value: number;
free(): void;
}
export class EncryptedAttachment {
readonly aid: string;
readonly bytes: Uint8Array;
free(): void;
}
export class TotpCode {
readonly code: string;
readonly expires_at: bigint;
free(): void;
}
export function unlock(
passphrase: string,
image_bytes: Uint8Array,
salt: Uint8Array,
params_json: string,
): SessionHandle;
export function lock(handle: SessionHandle): boolean;
export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;
export function attachment_encrypt(
handle: SessionHandle,
plaintext: Uint8Array,
max_bytes: bigint,
): EncryptedAttachment;
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;
export function new_item_id(): string;
export function new_field_id(): string;
export function generate_password(request_json: string): string;
export function generate_passphrase(request_json: string): string;
export function rate_passphrase(p: string): { score: number; guesses_log10: number };
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
export default function init(module_or_path?: unknown): Promise<void>;
export function initSync(args: { module: WebAssembly.Module }): void;
} }
export class EncryptedAttachment {
readonly aid: string;
readonly bytes: Uint8Array;
free(): void;
}
export class TotpCode {
readonly code: string;
readonly expires_at: bigint;
free(): void;
}
export function unlock(
passphrase: string,
image_bytes: Uint8Array,
salt: Uint8Array,
params_json: string,
): SessionHandle;
export function lock(handle: SessionHandle): boolean;
export function manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array;
export function item_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function item_encrypt(handle: SessionHandle, item_json: string): Uint8Array;
export function settings_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown;
export function settings_encrypt(handle: SessionHandle, settings_json: string): Uint8Array;
export function attachment_encrypt(
handle: SessionHandle,
plaintext: Uint8Array,
max_bytes: bigint,
): EncryptedAttachment;
export function attachment_decrypt(handle: SessionHandle, encrypted: Uint8Array): Uint8Array;
export function new_item_id(): string;
export function new_field_id(): string;
export function generate_password(request_json: string): string;
export function generate_passphrase(request_json: string): string;
export function rate_passphrase(p: string): { score: number; guesses_log10: number };
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
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", "rootDir": "./src",
"sourceMap": true, "sourceMap": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {
"relicario-wasm": ["./wasm/relicario_wasm.js"]
},
"baseUrl": "." "baseUrl": "."
}, },
"include": ["src/**/*"], "include": ["src/**/*"],