/** * restore.ts — Peach Codex restoration library (popup-side). * * Responsibilities: * 1. `parseRawScan` — defensively turn a raw scanned string into a * `ChunkEnvelope`. Foreign QR/Aztec content, malformed JSON, and * wrong-version envelopes are rejected without throwing. * 2. `ChunkBuffer` — accumulate per-chunk scans, enforce one-Codex-per-scan * context, deduplicate, and verify per-chunk integrity hashes. * 3. `reconstructAndVerify` — once the buffer has enough chunks, hand them * to Subagent 1's `reconstructPayloadFromChunks`, then re-derive the * vault fingerprint and fail loudly on mismatch. * * This module never touches the camera, the SW, or storage. Camera capture * lives in `./scanner.ts`; the actual vault write happens behind the * `RESTORE_FROM_CODEX` message in the service worker. * * Why a class instead of bare functions: the buffer holds three pieces of * state that must stay in sync (chunks, context, accept history). A class * makes the invariants — first valid chunk establishes context, later * chunks must match — explicit at the call site. */ import { CodexError, PARITY_SHARDS, type ChunkEnvelope, type CodexPayload, } from "../../shared/codex-types"; import { reconstructPayloadFromChunks } from "./generate"; import { base64UrlToBytes, sha256Hex, verifyChunkIntegrity, } from "./helpers"; export interface ScannedChunk { envelope: ChunkEnvelope; bytes: Uint8Array; } export type ChunkAddReason = | "duplicate" | "foreign_codex" | "bad_integrity" | "parse_error"; export interface ChunkAddResult { accepted: boolean; reason?: ChunkAddReason; } export interface ChunkBufferContext { verifyCode: string; total: number; dataShards: number; } export class ChunkBuffer { private readonly chunks = new Map(); private ctx: ChunkBufferContext | null = null; /** * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or * `{ accepted: false, reason }` for any rejection. The buffer never throws — * the scanner UI needs to keep running across bad frames. * * Async because `verifyChunkIntegrity` is async (SubtleCrypto digest). * Callers in a scan loop should await but the UI need not block — a few * milliseconds per frame is well below 30fps decoding cadence. */ async add(envelope: ChunkEnvelope): Promise { // Defensive: even though parseRawScan filters most of these, the buffer // is a public API and callers may hand-build envelopes (tests, future // import flows). if (envelope.v !== 2) { return { accepted: false, reason: "parse_error" }; } if (!(await verifyChunkIntegrity(envelope))) { return { accepted: false, reason: "bad_integrity" }; } if (this.ctx === null) { // First valid chunk establishes the Codex context. Later chunks must // agree on verifyCode and total — that is how we keep two overlapping // Codex prints from poisoning each other. this.ctx = { verifyCode: envelope.c, total: envelope.t, dataShards: envelope.t - PARITY_SHARDS, }; } else if (envelope.c !== this.ctx.verifyCode) { return { accepted: false, reason: "foreign_codex" }; } if (this.chunks.has(envelope.i)) { return { accepted: false, reason: "duplicate" }; } this.chunks.set(envelope.i, { envelope, bytes: base64UrlToBytes(envelope.d), }); return { accepted: true }; } size(): number { return this.chunks.size; } getContext(): ChunkBufferContext | null { return this.ctx; } isReady(): boolean { return this.ctx !== null && this.chunks.size >= this.ctx.dataShards; } entries(): ScannedChunk[] { return Array.from(this.chunks.values()); } /** Index → raw chunk bytes, ready for `reconstructPayloadFromChunks`. */ toChunkMap(): Map { const out = new Map(); for (const [i, scanned] of this.chunks) { out.set(i, scanned.bytes); } return out; } } export type ParseRawScanResult = | ChunkEnvelope | { error: "parse_error" | "wrong_version" }; /** * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner * fires on every successful decode — most decodes are noise (other QR codes, * stickers, old Codex prints) — so the caller must not assume any structure. */ export function parseRawScan(raw: string): ParseRawScanResult { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { error: "parse_error" }; } if (typeof parsed !== "object" || parsed === null) { return { error: "parse_error" }; } const obj = parsed as Record; // Version check happens before shape validation so a v1 (or future-v3) // envelope can be reported distinctly from random JSON. if (obj.v !== 2) { // Any explicit `v` that isn't 2 is a wrong-version signal. Missing `v` // is plain noise → parse_error. if ("v" in obj) return { error: "wrong_version" }; return { error: "parse_error" }; } const looksLikeEnvelope = typeof obj.c === "string" && typeof obj.i === "number" && typeof obj.t === "number" && typeof obj.p === "boolean" && typeof obj.d === "string" && typeof obj.h === "string"; if (!looksLikeEnvelope) { return { error: "parse_error" }; } return obj as unknown as ChunkEnvelope; } /** * Reconstruct the payload from a fully-loaded buffer and verify the vault * fingerprint round-trips. Throws `CodexError` on any inconsistency — * callers translate that into a visible "scan again" message. */ export async function reconstructAndVerify( buffer: ChunkBuffer, ): Promise { const ctx = buffer.getContext(); if (ctx === null) { throw new CodexError( "INSUFFICIENT_CHUNKS", "No chunks have been scanned yet.", ); } const payload = await reconstructPayloadFromChunks( buffer.toChunkMap(), ctx.dataShards, ctx.total, ); // Re-derive the fingerprint exactly as the generator did. Mismatch here // means the reconstructed `vaultBlob` is corrupt even though every chunk // passed its hash — most likely a Reed-Solomon decode pulled the wrong // bytes into a parity-reconstructed slot. `sha256Hex` already returns // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and- // suspenders against a future helpers.ts change. const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob))) .slice(0, 16) .toUpperCase(); if (fingerprint !== payload.vaultFingerprint) { throw new CodexError( "RECONSTRUCTION_FINGERPRINT_MISMATCH", "Reconstructed vault fingerprint does not match the printed Codex. " + "Try scanning the Codex again from the beginning.", ); } return payload; }