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:
56
extension/src/shared/__tests__/message-binary.test.ts
Normal file
56
extension/src/shared/__tests__/message-binary.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
|
||||
85
extension/src/shared/message-binary.ts
Normal file
85
extension/src/shared/message-binary.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user