fix(extension): binary-safe message transport + carrier-image guard

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
This commit is contained in:
adlee-was-taken
2026-06-25 18:38:37 -04:00
parent 74cee8ac67
commit 56adb68935
11 changed files with 268 additions and 13 deletions

View File

@@ -327,10 +327,14 @@ describe('fill_credentials captured-tab verification', () => {
// --- 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)
// Setup is allowed the subset of popup-only messages the wizard actually
// sends:
// - save_setup (final wire-up)
// - rate_passphrase (zxcvbn meter during passphrase entry)
// - is_unlocked (step-4 extension detection)
// - create_vault ("name this device" → create)
// - attach_vault ("name this device" → attach)
// - generate_recovery_qr (done-step recovery-QR banner)
// Everything else popup-only must be rejected from setup.
describe('setup tab exception scope', () => {
@@ -350,6 +354,48 @@ describe('setup tab exception scope', () => {
expect(res).toMatchObject({ ok: true });
});
it('accepts create_vault from the setup tab (reaches handler, not rejected)', async () => {
const state = makeState();
const res = await route(
{
type: 'create_vault',
config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' },
passphrase: 'correct horse battery staple parapet',
carrierImageBytes: new ArrayBuffer(8),
deviceName: 'Chrome on Linux',
},
state,
makeSetupSender(),
);
expect(res).not.toEqual({ ok: false, error: 'unauthorized_sender' });
});
it('accepts attach_vault from the setup tab (reaches handler, not rejected)', async () => {
const state = makeState();
const res = await route(
{
type: 'attach_vault',
config: { hostType: 'github', hostUrl: '', repoPath: '', apiToken: '' },
passphrase: 'correct horse battery staple parapet',
referenceImageBytes: new ArrayBuffer(8),
deviceName: 'Chrome on Linux',
},
state,
makeSetupSender(),
);
expect(res).not.toEqual({ ok: false, error: 'unauthorized_sender' });
});
it('accepts generate_recovery_qr from the setup tab (done-step banner)', async () => {
const state = makeState();
const res = await route(
{ type: 'generate_recovery_qr' } as Request,
state,
makeSetupSender(),
);
expect(res).not.toEqual({ ok: false, error: 'unauthorized_sender' });
});
it('rejects fill_credentials from the setup tab (outside the allowlist)', async () => {
const state = makeState();
const res = await route(

View File

@@ -16,14 +16,21 @@ export interface RouterState {
wasm: any;
}
/// Popup-only messages the setup tab is also allowed to send.
/// 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(