/** * 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 `reconstructPayloadFromStripes`, 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, deriveShardCounts, MAX_STRIPE_COUNT, type ChunkEnvelope, type CodexPayload, } from "../../shared/codex-types"; import { reconstructPayloadFromStripes, type StripeChunks } from "./generate"; import { sha256Hex, tryBase64UrlToBytes, 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; } interface StripeState { total: number; dataShards: number; chunks: Map; } /** * Aggregate snapshot of the buffer state. Counts (`total`, `dataShards`, * `scanned`) are sums across whichever stripes the buffer has *observed at * least one envelope from*; until every stripe has been seen, they are * lower bounds. `allStripesObserved` flips to `true` once the buffer has at * least one envelope from each of the `stripeCount` stripes — that is when * `total` and `dataShards` become final. */ export interface ChunkBufferContext { verifyCode: string; /** Total stripes in this Codex. */ stripeCount: number; /** Running total chunks (data + parity) across observed stripes. */ total: number; /** Running total data shards across observed stripes. */ dataShards: number; /** Chunks accepted into the buffer so far. */ scanned: number; /** True once at least one envelope from every stripe has been observed. */ allStripesObserved: boolean; } export interface StripeReadiness { stripe: number; found: number; required: number; isReady: boolean; } export class ChunkBuffer { private verifyCode: string | null = null; private stripeCount: number | null = null; private readonly stripes = new Map(); /** * 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. * * Accepts current v3 envelopes and rejects older paper formats. */ async add(envelope: ChunkEnvelope): Promise { if (envelope.v !== 3) { return { accepted: false, reason: "parse_error" }; } // PCH-01-026 #2: decode `d` once, up front, so an out-of-alphabet base64 // payload becomes a parse_error rejection instead of a thrown exception out // of add() (honouring the documented "never throws" contract). // verifyChunkIntegrity also decodes `d`, so guarding here covers both that // decode and the accept-path decode below, which reuses `decoded`. const decoded = tryBase64UrlToBytes(envelope.d); if (decoded === null) { return { accepted: false, reason: "parse_error" }; } if (!(await verifyChunkIntegrity(envelope))) { return { accepted: false, reason: "bad_integrity" }; } const stripe = envelope.s; const stripeCount = envelope.S; // PCH-01-011: `S` is attacker-controlled. Reject an out-of-range stripe // count before it can pin this.stripeCount and drive O(S) allocation in // stripeReadiness()/toStripeChunks() on the popup's render path. if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) { return { accepted: false, reason: "parse_error" }; } if (this.verifyCode === null) { // First valid chunk establishes verifyCode and stripeCount. Reject // subsequent envelopes whose `c` or `S` disagrees so two overlapping // Codex prints cannot poison each other. this.verifyCode = envelope.c; this.stripeCount = stripeCount; } else if (envelope.c !== this.verifyCode) { return { accepted: false, reason: "foreign_codex" }; } else if (this.stripeCount !== stripeCount) { return { accepted: false, reason: "foreign_codex" }; } if (stripe < 0 || stripe >= this.stripeCount!) { return { accepted: false, reason: "parse_error" }; } let stripeState = this.stripes.get(stripe); if (!stripeState) { // First envelope from a new stripe fixes that stripe's total + data // shard count. `deriveShardCounts` inverts `computeParityShards` over // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE in // deriveShardCounts so the inversion has a unique, sane solution (PCH-01-010). let dataShards: number; try { dataShards = deriveShardCounts(envelope.t).dataShards; } catch { return { accepted: false, reason: "parse_error" }; } stripeState = { total: envelope.t, dataShards, chunks: new Map() }; this.stripes.set(stripe, stripeState); } else if (stripeState.total !== envelope.t) { // Two envelopes from the same stripe disagreeing on `t` means one of // them is corrupt or from a different print run — reject. return { accepted: false, reason: "parse_error" }; } // PCH-01-026 #7: validate the index range BEFORE the duplicate lookup. An // out-of-range index is malformed, not a duplicate — classifying it by map // membership would mis-report it as "duplicate". if (envelope.i < 0 || envelope.i >= envelope.t) { return { accepted: false, reason: "parse_error" }; } if (stripeState.chunks.has(envelope.i)) { return { accepted: false, reason: "duplicate" }; } stripeState.chunks.set(envelope.i, { envelope, bytes: decoded, // PCH-01-026 #2: reuse the single successful decode }); return { accepted: true }; } size(): number { let count = 0; for (const s of this.stripes.values()) count += s.chunks.size; return count; } getContext(): ChunkBufferContext | null { if (this.verifyCode === null || this.stripeCount === null) return null; let total = 0; let dataShards = 0; let scanned = 0; for (const s of this.stripes.values()) { total += s.total; dataShards += s.dataShards; scanned += s.chunks.size; } return { verifyCode: this.verifyCode, stripeCount: this.stripeCount, total, dataShards, scanned, allStripesObserved: this.stripes.size === this.stripeCount, }; } isReady(): boolean { if (this.verifyCode === null || this.stripeCount === null) return false; if (this.stripes.size < this.stripeCount) return false; for (let i = 0; i < this.stripeCount; i++) { const s = this.stripes.get(i); if (!s || s.chunks.size < s.dataShards) return false; } return true; } stripeReadiness(): StripeReadiness[] { if (this.stripeCount === null) return []; const out: StripeReadiness[] = []; for (let i = 0; i < this.stripeCount; i++) { const s = this.stripes.get(i); const found = s?.chunks.size ?? 0; const required = s?.dataShards ?? 0; out.push({ stripe: i, found, required, isReady: !!s && found >= required, }); } return out; } entries(): ScannedChunk[] { const out: ScannedChunk[] = []; for (const s of this.stripes.values()) { for (const c of s.chunks.values()) out.push(c); } return out; } /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */ toStripeChunks(): StripeChunks[] { if (this.stripeCount === null) return []; const out: StripeChunks[] = []; for (let i = 0; i < this.stripeCount; i++) { const s = this.stripes.get(i); if (!s) { throw new CodexError( "INSUFFICIENT_CHUNKS", `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`, ); } const chunks = new Map(); for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes); out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total }); } 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. * * Accepts current v3 envelopes. Older paper formats are rejected. */ 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; if (obj.v !== 3) { if ("v" in obj) return { error: "wrong_version" }; return { error: "parse_error" }; } const looksLikeEnvelope = typeof obj.c === "string" && Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices Number.isInteger(obj.t) && typeof obj.p === "boolean" && typeof obj.d === "string" && typeof obj.h === "string"; if (!looksLikeEnvelope) { return { error: "parse_error" }; } if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) { 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 reconstructPayloadFromStripes(buffer.toStripeChunks()); // 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. // if (payload.vaultFingerprint.length !== 32) { throw new CodexError( "RECONSTRUCTION_FINGERPRINT_MISMATCH", "Reconstructed vault fingerprint has an invalid length. " + "Try scanning the Codex again from the beginning.", ); } const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob))) .slice(0, 32) .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; }