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

@@ -5,6 +5,7 @@
import type { Request, Response } from '../shared/messages';
import { lookupErrorCopy } from '../shared/error-copy';
import { encodeBinary, decodeBinary } from '../shared/message-binary';
import type { ItemId, ManifestEntry, Item } from '../shared/types';
import { registerHost } from '../shared/state';
import { renderUnlock } from './components/unlock';
@@ -100,7 +101,10 @@ export function setState(partial: Partial<PopupState>): void {
export function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
// Binary payloads (attachments, backups) are base64-enveloped for the
// channel; decode the response before handing it back. See message-binary.ts.
chrome.runtime.sendMessage(encodeBinary(request), (raw: Response) => {
let response = decodeBinary(raw);
if (response && !response.ok && response.error) {
// Replace cryptic low-level errors with user-readable messages.
response = { ok: false, error: humanizeError(response.error) };

View File

@@ -116,7 +116,9 @@ function makeState(wasm: ReturnType<typeof makeWasm>): PopupState {
const BASE_MSG = {
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
passphrase: 'pw',
carrierImageBytes: new Uint8Array([0, 0, 0]).buffer,
// Must begin with the JPEG SOI magic (0xFF 0xD8) to pass handleCreateVault's
// pre-embed format guard; the WASM embed itself is mocked.
carrierImageBytes: new Uint8Array([0xff, 0xd8, 0x00]).buffer,
deviceName: 'Dev',
};
@@ -214,6 +216,30 @@ describe('handleCreateVault', () => {
// Ownership was NOT transferred — setCurrent must NOT have been called.
expect(setCurrent).not.toHaveBeenCalled();
});
it('rejects a non-JPEG carrier with carrier_image_not_jpeg before touching WASM', async () => {
const wasm = makeWasm();
const state = makeState(wasm);
// PNG magic — must be classified, not handed to embed_image_secret.
const png = { ...BASE_MSG, carrierImageBytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer };
const resp = await vault.handleCreateVault(png, state);
expect(resp).toEqual({ ok: false, error: 'carrier_image_not_jpeg' });
expect(wasm.embed_image_secret).not.toHaveBeenCalled();
expect(wasm.unlock).not.toHaveBeenCalled();
});
it('rejects an empty carrier with carrier_image_empty', async () => {
const wasm = makeWasm();
const state = makeState(wasm);
const empty = { ...BASE_MSG, carrierImageBytes: new ArrayBuffer(0) };
const resp = await vault.handleCreateVault(empty, state);
expect(resp).toEqual({ ok: false, error: 'carrier_image_empty' });
expect(wasm.embed_image_secret).not.toHaveBeenCalled();
});
});
// --- attach_vault ---

View File

@@ -4,6 +4,7 @@
import type { Request, Response, SessionTimeoutConfig } from '../shared/messages';
import type { RouterState } from './router/index';
import { route } from './router/index';
import { encodeBinary, decodeBinary } from '../shared/message-binary';
import * as vault from './vault';
import { clearCurrent } from './session';
import * as sessionTimer from './session-timer';
@@ -74,7 +75,10 @@ chrome.commands.onCommand.addListener((command) => {
});
chrome.runtime.onMessage.addListener(
(request: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
(rawRequest: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
// Binary payloads (carrier/reference images, attachments, backups) cross
// the channel as base64 envelopes — decode before routing. See message-binary.ts.
const request = decodeBinary(rawRequest);
(async () => {
// Plan C Phase 5: invert the reset rule. Reset on every message
// except a documented passive-read exclusion set, so an active
@@ -94,7 +98,8 @@ chrome.runtime.onMessage.addListener(
// eslint-disable-next-line no-console
console.warn(`[relicario sw] ${request.type} -> error:`, r.error);
}
sendResponse(r);
// Re-encode binary in the response so it survives the channel back.
sendResponse(encodeBinary(r));
})
.catch((err: Error) => {
// eslint-disable-next-line no-console

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(

View File

@@ -55,6 +55,16 @@ export async function handleCreateVault(
let handle: SessionHandle | null = null;
try {
const carrierBytes = new Uint8Array(msg.carrierImageBytes);
// Actionable guard: the file <input accept="image/jpeg"> is only a hint,
// and a broken ArrayBuffer transport would arrive empty. Both surface as
// the opaque WASM error "no SOF marker found in JPEG", so classify here
// before handing bytes to the steganography embed.
if (carrierBytes.length === 0) {
return { ok: false, error: 'carrier_image_empty' };
}
if (carrierBytes[0] !== 0xff || carrierBytes[1] !== 0xd8) {
return { ok: false, error: 'carrier_image_not_jpeg' };
}
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));

View File

@@ -5,11 +5,16 @@ import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText
import { GLYPH_NEXT } from '../shared/glyphs';
import type { VaultConfig } from '../shared/types';
import type { Request, Response } from '../shared/messages';
import { lookupErrorCopy } from '../shared/error-copy';
import { encodeBinary, decodeBinary } from '../shared/message-binary';
// --- SW messaging ---
export function swSend(msg: Request): Promise<Response> {
return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r)));
// Binary fields (carrier/reference images) are base64-enveloped for the
// channel and decoded out of the response — see message-binary.ts.
return new Promise((resolve) =>
chrome.runtime.sendMessage(encodeBinary(msg), (r: Response) => resolve(decodeBinary(r))));
}
// --- Step registry types ---
@@ -621,7 +626,7 @@ const deviceStep: SetupStep = {
});
state.attaching = false;
if (resp.ok) ctx.goto('done');
else { state.error = resp.error; ctx.rerender(); }
else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
} else {
state.creating = true;
ctx.rerender();
@@ -637,7 +642,7 @@ const deviceStep: SetupStep = {
const data = resp.data as { referenceImageBytes: Uint8Array };
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
ctx.goto('done');
} else { state.error = resp.error; ctx.rerender(); }
} else { state.error = lookupErrorCopy(resp.error).body; ctx.rerender(); }
}
});
return () => {};

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { encodeBinary, decodeBinary } from '../message-binary';
function roundTrip<T>(v: T): T {
// Simulate the JSON-ish channel: encode → JSON stringify/parse → decode.
return decodeBinary(JSON.parse(JSON.stringify(encodeBinary(v))));
}
describe('message-binary transport', () => {
it('round-trips a Uint8Array field through a JSON channel', () => {
const bytes = new Uint8Array([0xff, 0xd8, 0x00, 0x10, 0xab]);
const msg = { type: 'create_vault', carrierImageBytes: bytes.buffer, n: 3 };
const out = roundTrip(msg) as typeof msg;
expect(out.type).toBe('create_vault');
expect(out.n).toBe(3);
expect(new Uint8Array(out.carrierImageBytes)).toEqual(bytes);
});
it('preserves a raw ArrayBuffer', () => {
const buf = new Uint8Array([1, 2, 3, 4]).buffer;
const out = roundTrip({ bytes: buf }) as { bytes: ArrayBuffer };
expect(new Uint8Array(out.bytes)).toEqual(new Uint8Array([1, 2, 3, 4]));
});
it('round-trips binary nested in response.data', () => {
const ref = new Uint8Array([9, 8, 7]);
const resp = { ok: true, data: { referenceImageBytes: ref, deviceName: 'X' } };
const out = roundTrip(resp) as typeof resp;
expect(out.data.deviceName).toBe('X');
expect(new Uint8Array(out.data.referenceImageBytes as unknown as ArrayBuffer)).toEqual(ref);
});
it('leaves plain number[] arrays (e.g. TOTP secrets) untouched', () => {
const msg = { core: { type: 'totp', secret: [0x48, 0x65, 0x6c] } };
const out = roundTrip(msg) as typeof msg;
expect(out.core.secret).toEqual([0x48, 0x65, 0x6c]);
expect(Array.isArray(out.core.secret)).toBe(true);
});
it('handles an empty Uint8Array without corruption', () => {
const out = roundTrip({ bytes: new Uint8Array(0).buffer }) as { bytes: ArrayBuffer };
expect(new Uint8Array(out.bytes).length).toBe(0);
});
it('round-trips a larger buffer (chunked base64, no stack overflow)', () => {
const big = new Uint8Array(100_000);
for (let i = 0; i < big.length; i++) big[i] = i % 256;
const out = roundTrip({ bytes: big.buffer }) as { bytes: ArrayBuffer };
expect(new Uint8Array(out.bytes)).toEqual(big);
});
it('is a structural no-op when there is no binary', () => {
const msg = { type: 'is_unlocked', nested: { a: 1, b: ['x', 'y'], c: null } };
expect(roundTrip(msg)).toEqual(msg);
});
});

View File

@@ -58,6 +58,14 @@ export const ERROR_COPY: Record<string, ErrorCopy> = {
title: 'Attachment missing',
body: 'The attachment is referenced in the item but is not present in the vault.',
},
carrier_image_not_jpeg: {
title: 'Not a JPEG',
body: 'The carrier image must be a JPEG (.jpg/.jpeg). The file you picked is a different format — PNG, WebP, and HEIC are not supported. Re-export or convert it to JPEG and try again.',
},
carrier_image_empty: {
title: 'Image did not load',
body: 'The carrier image came through empty — re-select the JPEG file and try again.',
},
upload_failed: {
title: 'Upload failed',
body: 'Could not upload the attachment — check your connection and try again.',

View File

@@ -0,0 +1,85 @@
/// Binary-safe transport for chrome.runtime.sendMessage.
///
/// chrome.runtime.sendMessage serializes messages with a JSON-like algorithm
/// that does NOT preserve ArrayBuffer / TypedArray payloads — they arrive at
/// the other end as empty objects (`{}`), so `new Uint8Array(buf)` yields a
/// zero-length array. This bit vault creation: a valid carrier JPEG reached
/// the service worker as 0 bytes ("no SOF marker found in JPEG").
///
/// `encodeBinary` walks a message/response tree and replaces every
/// ArrayBuffer/TypedArray with a `{ [MARKER]: <base64> }` envelope (a plain
/// JSON value that survives the channel). `decodeBinary` reverses it, turning
/// each envelope back into an ArrayBuffer. Plain `number[]` arrays (e.g. TOTP
/// secrets) are left untouched — only real binary buffers are transcoded.
///
/// Apply `encodeBinary` to every outbound request/response and `decodeBinary`
/// to every inbound one. Both are deep but cheap: the structural part of a
/// message is tiny, and each binary buffer is transcoded exactly once.
const MARKER = '__relicario_b64__';
/// Chunked base64 encode — avoids the call-stack blow-up of
/// `btoa(String.fromCharCode(...bytes))` on multi-MB buffers.
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const CHUNK = 0x8000; // 32 KiB per fromCharCode call
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function isBinary(v: unknown): v is ArrayBuffer | ArrayBufferView {
return v instanceof ArrayBuffer || ArrayBuffer.isView(v);
}
function toBytes(v: ArrayBuffer | ArrayBufferView): Uint8Array {
if (v instanceof ArrayBuffer) return new Uint8Array(v);
return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
}
/// Replace every binary buffer in `value` with a base64 envelope. Returns a
/// new structure; the input is not mutated.
export function encodeBinary<T>(value: T): T {
if (isBinary(value)) {
return { [MARKER]: bytesToBase64(toBytes(value)) } as unknown as T;
}
if (Array.isArray(value)) {
return value.map((v) => encodeBinary(v)) as unknown as T;
}
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = encodeBinary(v);
}
return out as T;
}
return value;
}
/// Reverse `encodeBinary`: each base64 envelope becomes an ArrayBuffer.
export function decodeBinary<T>(value: T): T {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const keys = Object.keys(value as Record<string, unknown>);
if (keys.length === 1 && keys[0] === MARKER) {
const b64 = (value as Record<string, string>)[MARKER];
return base64ToBytes(b64).buffer as unknown as T;
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = decodeBinary(v);
}
return out as T;
}
if (Array.isArray(value)) {
return value.map((v) => decodeBinary(v)) as unknown as T;
}
return value;
}

View File

@@ -6,6 +6,7 @@
import type { Request, Response } from '../shared/messages';
import { registerHost, sendMessage } from '../shared/state';
import { encodeBinary, decodeBinary } from '../shared/message-binary';
import { type ErrorCta } from '../shared/error-copy';
import {
type VaultController, type VaultState, type VaultView,
@@ -32,7 +33,9 @@ import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vaul
// → lock-screen intercept on top of this for every UI RPC.
function postToServiceWorker(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => resolve(response));
// Binary payloads (backups, imports, attachments) ride as base64 envelopes
// over the channel and are decoded back here. See message-binary.ts.
chrome.runtime.sendMessage(encodeBinary(request), (raw: Response) => resolve(decodeBinary(raw)));
});
}