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:
@@ -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;
|
||||||
|
|||||||
@@ -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, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = ' ';
|
||||||
|
} 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
|
||||||
|
? ' '
|
||||||
|
: (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 class="seg i2"></div>
|
||||||
|
<div class="seg i3"></div>
|
||||||
|
<div class="seg i4"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
|
<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();
|
||||||
|
|||||||
10
extension/src/wasm.d.ts
vendored
10
extension/src/wasm.d.ts
vendored
@@ -1,7 +1,13 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
|
declare module 'relicario-wasm' {
|
||||||
export class SessionHandle {
|
export class SessionHandle {
|
||||||
readonly value: number;
|
readonly value: number;
|
||||||
free(): void;
|
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;
|
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>;
|
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;
|
export function initSync(args: { module: WebAssembly.Module }): void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/**/*"],
|
||||||
|
|||||||
Reference in New Issue
Block a user