/** * 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 { CHUNK_SIZE, CodexError, CODEX_MAX_PARITY_SHARDS, deriveShardCounts, MAX_DATA_SHARDS_PER_STRIPE, MAX_STRIPE_COUNT, type ChunkEnvelope, type CodexPayload, } from "../../shared/codex-types"; import { reconstructPayloadFromStripes, type StripeChunks } from "./generate"; import { sha256Hex, tryBase64UrlToBytes, verifyChunkIntegrity, } from "./helpers"; // ── PB-26-207 scan input bounds ───────────────────────────────────────────── // Every ceiling derives from the Codex format, not from available memory: // one shard is CHUNK_SIZE bytes, one envelope carries one shard, and the // buffer can never retain more than every stripe's every shard. /** Raw decoded scanner text accepted by parseRawScan (honest envelopes are ~0.7 KiB). */ export const MAX_RAW_SCAN_CHARS = 4096; /** `d` may carry at most one shard's base64url expansion (350 B → 467 chars + padding tolerance). */ export const MAX_CHUNK_B64_CHARS = Math.ceil(CHUNK_SIZE / 3) * 4; /** Explicit format headroom above the shard size for the decoded-chunk check. */ export const SCAN_CHUNK_HEADROOM_BYTES = 64; /** Decoded `d` bytes accepted per chunk (honest chunks decode to exactly CHUNK_SIZE). */ export const MAX_DECODED_CHUNK_BYTES = CHUNK_SIZE + SCAN_CHUNK_HEADROOM_BYTES; /** `c` is always a `PX-XXXX-XXXX` verify code; `h` is always a 128-bit hex prefix. */ export const VERIFY_CODE_LENGTH = 12; export const CHUNK_HASH_LENGTH = 32; /** Most shards one stripe may declare (data ceiling + parity ceiling). */ export const MAX_SHARDS_PER_STRIPE = MAX_DATA_SHARDS_PER_STRIPE + CODEX_MAX_PARITY_SHARDS; /** Most chunks the buffer may retain: every stripe's every shard. */ export const MAX_BUFFER_CHUNKS = MAX_STRIPE_COUNT * MAX_SHARDS_PER_STRIPE; /** Most decoded bytes the buffer may retain: every chunk at full shard size. */ export const MAX_BUFFER_BYTES = MAX_BUFFER_CHUNKS * CHUNK_SIZE; 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(); private retainedBytes = 0; private readonly maxBytes: number; private readonly maxChunks: number; constructor(limits?: { maxBytes?: number; maxChunks?: number }) { this.maxBytes = limits?.maxBytes ?? MAX_BUFFER_BYTES; this.maxChunks = limits?.maxChunks ?? MAX_BUFFER_CHUNKS; } /** * 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. * * PB-26-207: every attacker-controlled length and index is validated before * any base64 conversion, allocation, or hashing, and accepted bytes count * against the aggregate buffer ceiling. */ async add(envelope: ChunkEnvelope): Promise { if (envelope.v !== 3) { return { accepted: false, reason: "parse_error" }; } // Envelope shape and bounds before any decode/hash work. parseRawScan // enforces the same limits on the camera path; this covers direct callers // (PDF import, tests) and any envelope built outside the scanner. if ( typeof envelope.c !== "string" || envelope.c.length !== VERIFY_CODE_LENGTH || typeof envelope.h !== "string" || envelope.h.length !== CHUNK_HASH_LENGTH || typeof envelope.d !== "string" || envelope.d.length === 0 || envelope.d.length > MAX_CHUNK_B64_CHARS || typeof envelope.p !== "boolean" || !Number.isInteger(envelope.s) || !Number.isInteger(envelope.S) || envelope.S < 1 || envelope.S > MAX_STRIPE_COUNT || envelope.s < 0 || envelope.s >= envelope.S || !Number.isInteger(envelope.i) || !Number.isInteger(envelope.t) || envelope.t < 1 || envelope.t > MAX_SHARDS_PER_STRIPE || envelope.i < 0 || envelope.i >= envelope.t ) { 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 || decoded.length > MAX_DECODED_CHUNK_BYTES) { return { accepted: false, reason: "parse_error" }; } if (!(await verifyChunkIntegrity(envelope))) { return { accepted: false, reason: "bad_integrity" }; } const stripe = envelope.s; const stripeCount = envelope.S; 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" }; } 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" }; } // Duplicates are normal as the user pans across the same code, so they // get their own reason rather than counting as malformed. if (stripeState.chunks.has(envelope.i)) { return { accepted: false, reason: "duplicate" }; } // Aggregate ceiling: every accepted chunk's actual decoded bytes count // toward the stripes × shards × shard-size budget. Rejections above // (duplicate, foreign, corrupt, malformed) return before this point, so // they never inflate the counters. if ( this.retainedBytes + decoded.length > this.maxBytes || this.size() + 1 > this.maxChunks ) { return { accepted: false, reason: "parse_error" }; } stripeState.chunks.set(envelope.i, { envelope, bytes: decoded, // PCH-01-026 #2: reuse the single successful decode }); this.retainedBytes += decoded.length; return { accepted: true }; } size(): number { let count = 0; for (const s of this.stripes.values()) count += s.chunks.size; return count; } /** Actual decoded bytes retained across all accepted chunks. */ bufferedBytes(): number { return this.retainedBytes; } 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. * * PB-26-207: the raw text length is bounded before JSON.parse, and every * envelope field length/range is validated before the caller performs any * base64 conversion or hashing. */ export function parseRawScan(raw: string): ParseRawScanResult { if (typeof raw !== "string" || raw.length > MAX_RAW_SCAN_CHARS) { return { error: "parse_error" }; } 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" && obj.c.length === VERIFY_CODE_LENGTH && Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices Number.isInteger(obj.t) && typeof obj.p === "boolean" && typeof obj.d === "string" && (obj.d as string).length > 0 && (obj.d as string).length <= MAX_CHUNK_B64_CHARS && typeof obj.h === "string" && (obj.h as string).length === CHUNK_HASH_LENGTH; if (!looksLikeEnvelope) { return { error: "parse_error" }; } if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) { return { error: "parse_error" }; } const s = obj.s as number; const S = obj.S as number; const i = obj.i as number; const t = obj.t as number; if ( S < 1 || S > MAX_STRIPE_COUNT || s < 0 || s >= S || t < 1 || t > MAX_SHARDS_PER_STRIPE || i < 0 || i >= t ) { return { error: "parse_error" }; } return obj as unknown as ChunkEnvelope; } /** * PB-26-207: the camera decoder fires on every frame, so repeat callbacks * arrive while the previous parse/add (including two SHA-256 hashes) is still * in flight. The gate allows exactly one scan in flight and drops callbacks * that arrive while busy — the same code is re-decoded on later frames, so * dropped callbacks lose nothing while queueing unbounded work would. */ export function createScanGate( process: (raw: string) => Promise, ): { onScan: (raw: string) => void } { let busy = false; return { onScan(raw: string) { if (busy) return; busy = true; void process(raw).then( () => { busy = false; }, () => { busy = false; }, ); }, }; } /** * 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; }