| 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 {
-
+
-
@@ -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/**/*"],