fix(ext): allow rate_passphrase + is_unlocked from setup tab; add diagnostic logging

Bug: setup tab's zxcvbn meter silently stayed at score=-1 because the
router's isSetup exception only allowed save_setup, so rate_passphrase
got unauthorized_sender. Result: the "create vault" button stayed
disabled forever even with a strong passphrase.

Fix: add a narrow SETUP_ALLOWED set containing save_setup,
rate_passphrase, and is_unlocked (step-4 extension detection). Reject
everything else from the setup tab. Also clean up setup.ts's unlock
call — it was passing the raw 32-byte imageSecret where JPEG bytes with
embedded secret are required; the Rust-side unlock calls imgsecret::
extract internally.

Diagnostic logging across the message path so the next silent failure
speaks up:
- [relicario setup]    staged logs through vault-init; console.error
                       with the failure stage name in the UI banner.
- [relicario setup]    rate_passphrase lastError / rejected / threw
                       branches each log their own warning.
- [relicario router]   console.warn on unauthorized_sender (with sender
                       classification) and unknown_message_type.
- [relicario sw]       first-message wasm init announced; per-message
                       non-ok result logged; thrown errors console.error'd.

Tests: +3 setup-allowlist tests (rate_passphrase accepted, is_unlocked
accepted, fill_credentials + unlock rejected). 55/55 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-22 19:32:00 -04:00
parent 3238ef4dd4
commit 4341124d38
5 changed files with 369 additions and 27 deletions

View File

@@ -46,11 +46,25 @@ const state: RouterState = {
chrome.runtime.onMessage.addListener(
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
(async () => {
if (!state.wasm) state.wasm = await initWasm();
if (!state.wasm) {
// eslint-disable-next-line no-console
console.log('[relicario sw] initializing WASM on first message');
state.wasm = await initWasm();
}
return route(request, state, sender);
})()
.then(sendResponse)
.catch((err: Error) => sendResponse({ ok: false, error: err.message }));
.then((r) => {
if (!r.ok) {
// eslint-disable-next-line no-console
console.warn(`[relicario sw] ${request.type} -> error:`, r.error);
}
sendResponse(r);
})
.catch((err: Error) => {
// eslint-disable-next-line no-console
console.error(`[relicario sw] ${request.type} threw:`, err);
sendResponse({ ok: false, error: err.message });
});
return true; // async response
},
);

View File

@@ -309,10 +309,32 @@ describe('fill_credentials captured-tab verification', () => {
});
});
// --- save_setup exception scope: setup tab is ONLY allowed save_setup ---
// --- setup-tab exception scope ---
//
// Setup is allowed a narrow subset of popup-only messages:
// - save_setup (final wire-up)
// - rate_passphrase (zxcvbn meter during passphrase entry)
// - is_unlocked (step-4 extension detection)
// Everything else popup-only must be rejected from setup.
describe('save_setup exception scope', () => {
it('rejects fill_credentials from the setup tab (setup can only save_setup)', async () => {
describe('setup tab exception scope', () => {
it('accepts rate_passphrase from the setup tab (zxcvbn meter)', async () => {
const state = makeState();
const res = await route(
{ type: 'rate_passphrase', passphrase: 'correct horse battery staple parapet' },
state,
makeSetupSender(),
);
expect(res).toMatchObject({ ok: true });
});
it('accepts is_unlocked from the setup tab (step-4 detection)', async () => {
const state = makeState();
const res = await route({ type: 'is_unlocked' }, state, makeSetupSender());
expect(res).toMatchObject({ ok: true });
});
it('rejects fill_credentials from the setup tab (outside the allowlist)', async () => {
const state = makeState();
const res = await route(
{
@@ -326,6 +348,16 @@ describe('save_setup exception scope', () => {
);
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
});
it('rejects unlock from the setup tab (outside the allowlist)', async () => {
const state = makeState();
const res = await route(
{ type: 'unlock', passphrase: 'hunter2' },
state,
makeSetupSender(),
);
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });
});
});
// --- isContent rejects unknown sender.id ---

View File

@@ -2,7 +2,7 @@
/// to popup-only or content-callable handlers. Unauthorized senders are
/// rejected with { ok: false, error: 'unauthorized_sender' }.
import type { Request, Response } from '../../shared/messages';
import type { PopupMessage, Request, Response } from '../../shared/messages';
import { POPUP_ONLY_TYPES, CONTENT_CALLABLE_TYPES } from '../../shared/messages';
import type { Manifest } from '../../shared/types';
import type { GitHost } from '../git-host';
@@ -16,6 +16,16 @@ export interface RouterState {
wasm: any;
}
/// Popup-only messages the setup tab is also allowed to send.
/// - save_setup: wires vault config + image into chrome.storage.local at end of init.
/// - rate_passphrase: drives the zxcvbn strength meter during passphrase entry.
/// - is_unlocked: setup step-4 pings the extension to detect "save config to extension" availability.
const SETUP_ALLOWED: ReadonlySet<PopupMessage['type']> = new Set<PopupMessage['type']>([
'save_setup',
'rate_passphrase',
'is_unlocked',
]);
export async function route(
msg: Request,
state: RouterState,
@@ -32,17 +42,29 @@ export async function route(
&& sender.id === chrome.runtime.id;
if (POPUP_ONLY_TYPES.has(msg.type as never)) {
// save_setup gets one exception: allowed from the setup tab too.
if (!(isPopup || (msg.type === 'save_setup' && isSetup))) {
if (!(isPopup || (isSetup && SETUP_ALLOWED.has(msg.type as PopupMessage['type'])))) {
// eslint-disable-next-line no-console
console.warn('[relicario router] rejected popup-only message from wrong sender', {
type: msg.type, senderUrl, isPopup, isSetup, isContent,
});
return { ok: false, error: 'unauthorized_sender' };
}
return popupOnly.handle(msg as never, state, sender);
}
if (CONTENT_CALLABLE_TYPES.has(msg.type as never)) {
if (!isContent) return { ok: false, error: 'unauthorized_sender' };
if (!isContent) {
// eslint-disable-next-line no-console
console.warn('[relicario router] rejected content-only message from wrong sender', {
type: msg.type, senderUrl, isPopup, isSetup, isContent,
frameId: sender.frameId, senderId: sender.id,
});
return { ok: false, error: 'unauthorized_sender' };
}
return contentCallable.handle(msg as never, state, sender);
}
// eslint-disable-next-line no-console
console.warn('[relicario router] unknown message type', { type: (msg as { type: string }).type });
return { ok: false, error: 'unknown_message_type' };
}

View File

@@ -81,11 +81,22 @@ function ratePassphrase(passphrase: string): Promise<number> {
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; }
if (chrome.runtime.lastError) {
// eslint-disable-next-line no-console
console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError);
resolve(-1); return;
}
if (!response?.ok) {
// eslint-disable-next-line no-console
console.warn('[relicario setup] rate_passphrase rejected by SW:', response);
resolve(-1); return;
}
resolve(response.data?.score ?? -1);
},
);
} catch {
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[relicario setup] rate_passphrase threw:', err);
resolve(-1);
}
});
@@ -428,66 +439,85 @@ function attachStep3(): void {
state.error = null;
render();
// Structured logging so silent failures become visible in DevTools.
// eslint-disable-next-line no-console
const log = (stage: string, detail?: unknown) => console.log(`[relicario setup] ${stage}`, detail ?? '');
let stage = 'init';
try {
stage = 'load wasm';
log(stage);
const w = await loadWasm();
// 1. Generate 32-byte image secret.
stage = 'generate image secret';
log(stage);
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
// 2. Embed secret into carrier JPEG.
stage = 'embed image secret';
log(stage, { carrierBytes: state.carrierImageBytes.byteLength });
state.referenceImageBytes = new Uint8Array(
w.embed_image_secret(state.carrierImageBytes, imageSecret),
);
log('embedded', { referenceBytes: state.referenceImageBytes.byteLength });
// 3. Generate 32-byte salt + KDF params.
stage = 'generate salt';
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
// 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);
stage = 'derive session handle';
log(stage);
// unlock() takes JPEG bytes with embedded secret (it extracts internally),
// not the raw 32-byte secret.
const handle = w.unlock(state.passphrase, state.referenceImageBytes, salt, paramsJson);
log('handle acquired');
// 5. Encrypt an empty schema-v2 manifest.
stage = 'encrypt empty manifest';
log(stage);
const manifestJson = '{"schema_version":2,"items":{}}';
const encryptedManifest = w.manifest_encrypt(handle, manifestJson);
log('manifest encrypted', { bytes: encryptedManifest.length });
// 6. Push vault files via git API.
stage = 'push vault files';
log(stage);
const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
log('write .relicario/salt');
await host.writeFile('.relicario/salt', salt, 'init: vault salt');
log('write .relicario/params.json');
const paramsBytes = new TextEncoder().encode(paramsJson);
await host.writeFile('.relicario/params.json', paramsBytes, 'init: KDF parameters');
log('write .relicario/devices.json');
const devicesJson = '{"devices":[]}';
const devicesBytes = new TextEncoder().encode(devicesJson);
await host.writeFile('.relicario/devices.json', devicesBytes, 'init: device registry');
log('write manifest.enc');
await host.writeFile(
'manifest.enc',
new Uint8Array(encryptedManifest),
'init: encrypted manifest',
);
// 7. Release the handle — the SW's own unlock will re-derive.
stage = 'release handle';
w.lock(handle);
// 8. Advance to step 4.
log('vault created — advancing to step 4');
state.creating = false;
state.step = 4;
state.error = null;
// Detect extension.
detectExtension();
render();
} catch (err: unknown) {
// eslint-disable-next-line no-console
console.error(`[relicario setup] vault creation FAILED during "${stage}":`, err);
state.creating = false;
state.error = `Vault creation failed: ${err instanceof Error ? err.message : String(err)}`;
const detail = err instanceof Error ? err.message : String(err);
state.error = `Vault creation failed at "${stage}": ${detail}`;
render();
}
});