chrome.runtime.sendMessage serializes with a JSON-like algorithm that drops ArrayBuffer/TypedArray payloads — they arrive as empty objects, so a carrier JPEG reached the service worker as 0 bytes and failed with the opaque WASM error "no SOF marker found in JPEG". This broke extension vault creation. - shared/message-binary.ts: encodeBinary/decodeBinary deep-walk a message tree and base64-envelope every binary buffer so it survives the channel (chunked base64 to avoid stack overflow on multi-MB buffers); plain number[] untouched. - Wire encode/decode at all four message boundaries: popup, service-worker onMessage listener (decode request, encode response), setup swSend, vault postToServiceWorker. - handleCreateVault: magic-byte guard classifies empty / non-JPEG carriers into actionable errors (carrier_image_empty / carrier_image_not_jpeg) instead of the opaque WASM error; error-copy.ts adds user-facing text; setup errors now route through lookupErrorCopy. - router SETUP_ALLOWED: add create_vault / attach_vault / generate_recovery_qr — the Phase-3 setup wizard sends these from the setup page and they were being rejected as unauthorized_sender. - Tests: message-binary round-trip suite (7) + carrier-guard (2) + setup allowlist (3). 435/435 vitest, build:all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pe8qw5KePDqAEBsAxnVQuJ
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
/// Single chrome.runtime.onMessage entry. Classifies the sender and dispatches
|
|
/// to popup-only or content-callable handlers. Unauthorized senders are
|
|
/// rejected with { ok: false, error: 'unauthorized_sender' }.
|
|
|
|
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';
|
|
import * as popupOnly from './popup-only';
|
|
import * as contentCallable from './content-callable';
|
|
|
|
export interface RouterState {
|
|
manifest: Manifest | null;
|
|
gitHost: GitHost | null;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
wasm: any;
|
|
}
|
|
|
|
/// Popup-only messages the setup tab is also allowed to send. The setup page
|
|
/// (setup.html) is an extension-internal page — same trust level as the popup —
|
|
/// so this set mirrors exactly what the wizard sends, no more.
|
|
/// - 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.
|
|
/// - create_vault / attach_vault: the "name this device" step performs the full vault create/attach.
|
|
/// - generate_recovery_qr: the done-step recovery-QR banner generates a QR before the user leaves.
|
|
const SETUP_ALLOWED: ReadonlySet<PopupMessage['type']> = new Set<PopupMessage['type']>([
|
|
'save_setup',
|
|
'rate_passphrase',
|
|
'is_unlocked',
|
|
'create_vault',
|
|
'attach_vault',
|
|
'generate_recovery_qr',
|
|
]);
|
|
|
|
export async function route(
|
|
msg: Request,
|
|
state: RouterState,
|
|
sender: chrome.runtime.MessageSender,
|
|
): Promise<Response> {
|
|
const popupUrl = chrome.runtime.getURL('popup.html');
|
|
const vaultUrl = chrome.runtime.getURL('vault.html');
|
|
const setupUrl = chrome.runtime.getURL('setup.html');
|
|
const senderUrl = sender.url ?? '';
|
|
|
|
const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl);
|
|
const isSetup = senderUrl.startsWith(setupUrl);
|
|
const isContent = sender.tab !== undefined
|
|
&& sender.frameId === 0
|
|
&& sender.id === chrome.runtime.id;
|
|
|
|
if (POPUP_ONLY_TYPES.has(msg.type as never)) {
|
|
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) {
|
|
// 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' };
|
|
}
|