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:
@@ -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) };
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user