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