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:
@@ -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
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user