diff --git a/extension/setup.html b/extension/setup.html index 5a2dd7a..fd77af9 100644 --- a/extension/setup.html +++ b/extension/setup.html @@ -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; diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 8922a33..75a9f9b 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -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, '"'); } -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 { + 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 | 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 = ' '; + } 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 + ? ' ' + : (score >= 3 ? 'Strong enough' : 'Too weak'); + const gateDisabled = state.creating || score < 3; return `
@@ -284,21 +332,23 @@ function renderStep3(): string {
- - ${strength ? ` -
-
-
-

strength: ${strength}

- ` : ''} + + +

${labelText}

- +
-
@@ -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(); diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index a6c3818..ccd1681 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -1,61 +1,65 @@ // 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. -export class SessionHandle { - readonly value: number; - free(): void; +declare module 'relicario-wasm' { + export class SessionHandle { + 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; + 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; - -// wasm-bindgen's sync init — Chrome MV3 service workers can't use dynamic import(). -export function initSync(args: { module: WebAssembly.Module }): void; diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 12146cb..89512ab 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -9,9 +9,6 @@ "rootDir": "./src", "sourceMap": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "paths": { - "relicario-wasm": ["./wasm/relicario_wasm.js"] - }, "baseUrl": "." }, "include": ["src/**/*"],