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

@@ -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;
}