diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index c154735..a2ad8a2 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -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): void { export function sendMessage(request: Request): Promise { 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) }; diff --git a/extension/src/service-worker/__tests__/vault.test.ts b/extension/src/service-worker/__tests__/vault.test.ts index f926151..7de9a8c 100644 --- a/extension/src/service-worker/__tests__/vault.test.ts +++ b/extension/src/service-worker/__tests__/vault.test.ts @@ -116,7 +116,9 @@ function makeState(wasm: ReturnType): 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 --- diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 2c68a3e..295b920 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -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 diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 0c7dbbe..6775a01 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -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( diff --git a/extension/src/service-worker/router/index.ts b/extension/src/service-worker/router/index.ts index 3f61589..6a96f12 100644 --- a/extension/src/service-worker/router/index.ts +++ b/extension/src/service-worker/router/index.ts @@ -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 = new Set([ 'save_setup', 'rate_passphrase', 'is_unlocked', + 'create_vault', + 'attach_vault', + 'generate_recovery_qr', ]); export async function route( diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index ad91ad3..b5a1a9e 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -55,6 +55,16 @@ export async function handleCreateVault( let handle: SessionHandle | null = null; try { const carrierBytes = new Uint8Array(msg.carrierImageBytes); + // Actionable guard: the file 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)); diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts index ed0e1f2..8382072 100644 --- a/extension/src/setup/setup-steps.ts +++ b/extension/src/setup/setup-steps.ts @@ -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 { - 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 () => {}; diff --git a/extension/src/shared/__tests__/message-binary.test.ts b/extension/src/shared/__tests__/message-binary.test.ts new file mode 100644 index 0000000..405ca91 --- /dev/null +++ b/extension/src/shared/__tests__/message-binary.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { encodeBinary, decodeBinary } from '../message-binary'; + +function roundTrip(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); + }); +}); diff --git a/extension/src/shared/error-copy.ts b/extension/src/shared/error-copy.ts index 71998c8..256a794 100644 --- a/extension/src/shared/error-copy.ts +++ b/extension/src/shared/error-copy.ts @@ -58,6 +58,14 @@ export const ERROR_COPY: Record = { 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.', diff --git a/extension/src/shared/message-binary.ts b/extension/src/shared/message-binary.ts new file mode 100644 index 0000000..d4c6541 --- /dev/null +++ b/extension/src/shared/message-binary.ts @@ -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]: }` 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(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 = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = encodeBinary(v); + } + return out as T; + } + return value; +} + +/// Reverse `encodeBinary`: each base64 envelope becomes an ArrayBuffer. +export function decodeBinary(value: T): T { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + const keys = Object.keys(value as Record); + if (keys.length === 1 && keys[0] === MARKER) { + const b64 = (value as Record)[MARKER]; + return base64ToBytes(b64).buffer as unknown as T; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = decodeBinary(v); + } + return out as T; + } + if (Array.isArray(value)) { + return value.map((v) => decodeBinary(v)) as unknown as T; + } + return value; +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index 82b11e1..32e3cb3 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -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 { 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))); }); }