restore.ts

16.5 KB · raw

1/**2 * restore.ts — Peach Codex restoration library (popup-side).3 *4 * Responsibilities:5 *  1. `parseRawScan` — defensively turn a raw scanned string into a6 *     `ChunkEnvelope`. Foreign QR/Aztec content, malformed JSON, and7 *     wrong-version envelopes are rejected without throwing.8 *  2. `ChunkBuffer` — accumulate per-chunk scans, enforce one-Codex-per-scan9 *     context, deduplicate, and verify per-chunk integrity hashes.10 *  3. `reconstructAndVerify` — once the buffer has enough chunks, hand them11 *     to `reconstructPayloadFromStripes`, then re-derive the12 *     vault fingerprint and fail loudly on mismatch.13 *14 * This module never touches the camera, the SW, or storage. Camera capture15 * lives in `./scanner.ts`; the actual vault write happens behind the16 * `RESTORE_FROM_CODEX` message in the service worker.17 *18 * Why a class instead of bare functions: the buffer holds three pieces of19 * state that must stay in sync (chunks, context, accept history). A class20 * makes the invariants — first valid chunk establishes context, later21 * chunks must match — explicit at the call site.22 */2324import {25  CHUNK_SIZE,26  CodexError,27  CODEX_MAX_PARITY_SHARDS,28  deriveShardCounts,29  MAX_DATA_SHARDS_PER_STRIPE,30  MAX_STRIPE_COUNT,31  type ChunkEnvelope,32  type CodexPayload,33} from "../../shared/codex-types";34import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";35import {36  sha256Hex,37  tryBase64UrlToBytes,38  verifyChunkIntegrity,39} from "./helpers";4041// ── PB-26-207 scan input bounds ─────────────────────────────────────────────42// Every ceiling derives from the Codex format, not from available memory:43// one shard is CHUNK_SIZE bytes, one envelope carries one shard, and the44// buffer can never retain more than every stripe's every shard.4546/** Raw decoded scanner text accepted by parseRawScan (honest envelopes are ~0.7 KiB). */47export const MAX_RAW_SCAN_CHARS = 4096;4849/** `d` may carry at most one shard's base64url expansion (350 B → 467 chars + padding tolerance). */50export const MAX_CHUNK_B64_CHARS = Math.ceil(CHUNK_SIZE / 3) * 4;5152/** Explicit format headroom above the shard size for the decoded-chunk check. */53export const SCAN_CHUNK_HEADROOM_BYTES = 64;5455/** Decoded `d` bytes accepted per chunk (honest chunks decode to exactly CHUNK_SIZE). */56export const MAX_DECODED_CHUNK_BYTES = CHUNK_SIZE + SCAN_CHUNK_HEADROOM_BYTES;5758/** `c` is always a `PX-XXXX-XXXX` verify code; `h` is always a 128-bit hex prefix. */59export const VERIFY_CODE_LENGTH = 12;60export const CHUNK_HASH_LENGTH = 32;6162/** Most shards one stripe may declare (data ceiling + parity ceiling). */63export const MAX_SHARDS_PER_STRIPE =64  MAX_DATA_SHARDS_PER_STRIPE + CODEX_MAX_PARITY_SHARDS;6566/** Most chunks the buffer may retain: every stripe's every shard. */67export const MAX_BUFFER_CHUNKS = MAX_STRIPE_COUNT * MAX_SHARDS_PER_STRIPE;6869/** Most decoded bytes the buffer may retain: every chunk at full shard size. */70export const MAX_BUFFER_BYTES = MAX_BUFFER_CHUNKS * CHUNK_SIZE;7172export interface ScannedChunk {73  envelope: ChunkEnvelope;74  bytes: Uint8Array;75}7677export type ChunkAddReason =78  | "duplicate"79  | "foreign_codex"80  | "bad_integrity"81  | "parse_error";8283export interface ChunkAddResult {84  accepted: boolean;85  reason?: ChunkAddReason;86}8788interface StripeState {89  total: number;90  dataShards: number;91  chunks: Map<number, ScannedChunk>;92}9394/**95 * Aggregate snapshot of the buffer state. Counts (`total`, `dataShards`,96 * `scanned`) are sums across whichever stripes the buffer has *observed at97 * least one envelope from*; until every stripe has been seen, they are98 * lower bounds. `allStripesObserved` flips to `true` once the buffer has at99 * least one envelope from each of the `stripeCount` stripes — that is when100 * `total` and `dataShards` become final.101 */102export interface ChunkBufferContext {103  verifyCode: string;104  /** Total stripes in this Codex. */105  stripeCount: number;106  /** Running total chunks (data + parity) across observed stripes. */107  total: number;108  /** Running total data shards across observed stripes. */109  dataShards: number;110  /** Chunks accepted into the buffer so far. */111  scanned: number;112  /** True once at least one envelope from every stripe has been observed. */113  allStripesObserved: boolean;114}115116export interface StripeReadiness {117  stripe: number;118  found: number;119  required: number;120  isReady: boolean;121}122123export class ChunkBuffer {124  private verifyCode: string | null = null;125  private stripeCount: number | null = null;126  private readonly stripes = new Map<number, StripeState>();127  private retainedBytes = 0;128  private readonly maxBytes: number;129  private readonly maxChunks: number;130131  constructor(limits?: { maxBytes?: number; maxChunks?: number }) {132    this.maxBytes = limits?.maxBytes ?? MAX_BUFFER_BYTES;133    this.maxChunks = limits?.maxChunks ?? MAX_BUFFER_CHUNKS;134  }135136  /**137   * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or138   * `{ accepted: false, reason }` for any rejection. The buffer never throws —139   * the scanner UI needs to keep running across bad frames.140   *141   * Accepts current v3 envelopes and rejects older paper formats.142   *143   * PB-26-207: every attacker-controlled length and index is validated before144   * any base64 conversion, allocation, or hashing, and accepted bytes count145   * against the aggregate buffer ceiling.146   */147  async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {148    if (envelope.v !== 3) {149      return { accepted: false, reason: "parse_error" };150    }151152    // Envelope shape and bounds before any decode/hash work. parseRawScan153    // enforces the same limits on the camera path; this covers direct callers154    // (PDF import, tests) and any envelope built outside the scanner.155    if (156      typeof envelope.c !== "string" ||157      envelope.c.length !== VERIFY_CODE_LENGTH ||158      typeof envelope.h !== "string" ||159      envelope.h.length !== CHUNK_HASH_LENGTH ||160      typeof envelope.d !== "string" ||161      envelope.d.length === 0 ||162      envelope.d.length > MAX_CHUNK_B64_CHARS ||163      typeof envelope.p !== "boolean" ||164      !Number.isInteger(envelope.s) ||165      !Number.isInteger(envelope.S) ||166      envelope.S < 1 ||167      envelope.S > MAX_STRIPE_COUNT ||168      envelope.s < 0 ||169      envelope.s >= envelope.S ||170      !Number.isInteger(envelope.i) ||171      !Number.isInteger(envelope.t) ||172      envelope.t < 1 ||173      envelope.t > MAX_SHARDS_PER_STRIPE ||174      envelope.i < 0 ||175      envelope.i >= envelope.t176    ) {177      return { accepted: false, reason: "parse_error" };178    }179180    // PCH-01-026 #2: decode `d` once, up front, so an out-of-alphabet base64181    // payload becomes a parse_error rejection instead of a thrown exception out182    // of add() (honouring the documented "never throws" contract).183    // verifyChunkIntegrity also decodes `d`, so guarding here covers both that184    // decode and the accept-path decode below, which reuses `decoded`.185    const decoded = tryBase64UrlToBytes(envelope.d);186    if (decoded === null || decoded.length > MAX_DECODED_CHUNK_BYTES) {187      return { accepted: false, reason: "parse_error" };188    }189190    if (!(await verifyChunkIntegrity(envelope))) {191      return { accepted: false, reason: "bad_integrity" };192    }193194    const stripe = envelope.s;195    const stripeCount = envelope.S;196197    if (this.verifyCode === null) {198      // First valid chunk establishes verifyCode and stripeCount. Reject199      // subsequent envelopes whose `c` or `S` disagrees so two overlapping200      // Codex prints cannot poison each other.201      this.verifyCode = envelope.c;202      this.stripeCount = stripeCount;203    } else if (envelope.c !== this.verifyCode) {204      return { accepted: false, reason: "foreign_codex" };205    } else if (this.stripeCount !== stripeCount) {206      return { accepted: false, reason: "foreign_codex" };207    }208209    let stripeState = this.stripes.get(stripe);210    if (!stripeState) {211      // First envelope from a new stripe fixes that stripe's total + data212      // shard count. `deriveShardCounts` inverts `computeParityShards` over213      // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE in214      // deriveShardCounts so the inversion has a unique, sane solution (PCH-01-010).215      let dataShards: number;216      try {217        dataShards = deriveShardCounts(envelope.t).dataShards;218      } catch {219        return { accepted: false, reason: "parse_error" };220      }221      stripeState = { total: envelope.t, dataShards, chunks: new Map() };222      this.stripes.set(stripe, stripeState);223    } else if (stripeState.total !== envelope.t) {224      // Two envelopes from the same stripe disagreeing on `t` means one of225      // them is corrupt or from a different print run — reject.226      return { accepted: false, reason: "parse_error" };227    }228229    // Duplicates are normal as the user pans across the same code, so they230    // get their own reason rather than counting as malformed.231    if (stripeState.chunks.has(envelope.i)) {232      return { accepted: false, reason: "duplicate" };233    }234235    // Aggregate ceiling: every accepted chunk's actual decoded bytes count236    // toward the stripes × shards × shard-size budget. Rejections above237    // (duplicate, foreign, corrupt, malformed) return before this point, so238    // they never inflate the counters.239    if (240      this.retainedBytes + decoded.length > this.maxBytes ||241      this.size() + 1 > this.maxChunks242    ) {243      return { accepted: false, reason: "parse_error" };244    }245246    stripeState.chunks.set(envelope.i, {247      envelope,248      bytes: decoded, // PCH-01-026 #2: reuse the single successful decode249    });250    this.retainedBytes += decoded.length;251    return { accepted: true };252  }253254  size(): number {255    let count = 0;256    for (const s of this.stripes.values()) count += s.chunks.size;257    return count;258  }259260  /** Actual decoded bytes retained across all accepted chunks. */261  bufferedBytes(): number {262    return this.retainedBytes;263  }264265  getContext(): ChunkBufferContext | null {266    if (this.verifyCode === null || this.stripeCount === null) return null;267    let total = 0;268    let dataShards = 0;269    let scanned = 0;270    for (const s of this.stripes.values()) {271      total += s.total;272      dataShards += s.dataShards;273      scanned += s.chunks.size;274    }275    return {276      verifyCode: this.verifyCode,277      stripeCount: this.stripeCount,278      total,279      dataShards,280      scanned,281      allStripesObserved: this.stripes.size === this.stripeCount,282    };283  }284285  isReady(): boolean {286    if (this.verifyCode === null || this.stripeCount === null) return false;287    if (this.stripes.size < this.stripeCount) return false;288    for (let i = 0; i < this.stripeCount; i++) {289      const s = this.stripes.get(i);290      if (!s || s.chunks.size < s.dataShards) return false;291    }292    return true;293  }294295  stripeReadiness(): StripeReadiness[] {296    if (this.stripeCount === null) return [];297    const out: StripeReadiness[] = [];298    for (let i = 0; i < this.stripeCount; i++) {299      const s = this.stripes.get(i);300      const found = s?.chunks.size ?? 0;301      const required = s?.dataShards ?? 0;302      out.push({303        stripe: i,304        found,305        required,306        isReady: !!s && found >= required,307      });308    }309    return out;310  }311312  entries(): ScannedChunk[] {313    const out: ScannedChunk[] = [];314    for (const s of this.stripes.values()) {315      for (const c of s.chunks.values()) out.push(c);316    }317    return out;318  }319320  /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */321  toStripeChunks(): StripeChunks[] {322    if (this.stripeCount === null) return [];323    const out: StripeChunks[] = [];324    for (let i = 0; i < this.stripeCount; i++) {325      const s = this.stripes.get(i);326      if (!s) {327        throw new CodexError(328          "INSUFFICIENT_CHUNKS",329          `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,330        );331      }332      const chunks = new Map<number, Uint8Array>();333      for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);334      out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });335    }336    return out;337  }338}339340export type ParseRawScanResult =341  | ChunkEnvelope342  | { error: "parse_error" | "wrong_version" };343344/**345 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner346 * fires on every successful decode — most decodes are noise (other QR codes,347 * stickers, old Codex prints) — so the caller must not assume any structure.348 *349 * Accepts current v3 envelopes. Older paper formats are rejected.350 *351 * PB-26-207: the raw text length is bounded before JSON.parse, and every352 * envelope field length/range is validated before the caller performs any353 * base64 conversion or hashing.354 */355export function parseRawScan(raw: string): ParseRawScanResult {356  if (typeof raw !== "string" || raw.length > MAX_RAW_SCAN_CHARS) {357    return { error: "parse_error" };358  }359360  let parsed: unknown;361  try {362    parsed = JSON.parse(raw);363  } catch {364    return { error: "parse_error" };365  }366367  if (typeof parsed !== "object" || parsed === null) {368    return { error: "parse_error" };369  }370371  const obj = parsed as Record<string, unknown>;372373  if (obj.v !== 3) {374    if ("v" in obj) return { error: "wrong_version" };375    return { error: "parse_error" };376  }377378  const looksLikeEnvelope =379    typeof obj.c === "string" &&380    obj.c.length === VERIFY_CODE_LENGTH &&381    Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices382    Number.isInteger(obj.t) &&383    typeof obj.p === "boolean" &&384    typeof obj.d === "string" &&385    (obj.d as string).length > 0 &&386    (obj.d as string).length <= MAX_CHUNK_B64_CHARS &&387    typeof obj.h === "string" &&388    (obj.h as string).length === CHUNK_HASH_LENGTH;389390  if (!looksLikeEnvelope) {391    return { error: "parse_error" };392  }393394  if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {395    return { error: "parse_error" };396  }397398  const s = obj.s as number;399  const S = obj.S as number;400  const i = obj.i as number;401  const t = obj.t as number;402  if (403    S < 1 ||404    S > MAX_STRIPE_COUNT ||405    s < 0 ||406    s >= S ||407    t < 1 ||408    t > MAX_SHARDS_PER_STRIPE ||409    i < 0 ||410    i >= t411  ) {412    return { error: "parse_error" };413  }414415  return obj as unknown as ChunkEnvelope;416}417418/**419 * PB-26-207: the camera decoder fires on every frame, so repeat callbacks420 * arrive while the previous parse/add (including two SHA-256 hashes) is still421 * in flight. The gate allows exactly one scan in flight and drops callbacks422 * that arrive while busy — the same code is re-decoded on later frames, so423 * dropped callbacks lose nothing while queueing unbounded work would.424 */425export function createScanGate(426  process: (raw: string) => Promise<void>,427): { onScan: (raw: string) => void } {428  let busy = false;429  return {430    onScan(raw: string) {431      if (busy) return;432      busy = true;433      void process(raw).then(434        () => {435          busy = false;436        },437        () => {438          busy = false;439        },440      );441    },442  };443}444445/**446 * Reconstruct the payload from a fully-loaded buffer and verify the vault447 * fingerprint round-trips. Throws `CodexError` on any inconsistency —448 * callers translate that into a visible "scan again" message.449 */450export async function reconstructAndVerify(451  buffer: ChunkBuffer,452): Promise<CodexPayload> {453  const ctx = buffer.getContext();454  if (ctx === null) {455    throw new CodexError(456      "INSUFFICIENT_CHUNKS",457      "No chunks have been scanned yet.",458    );459  }460461  const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());462463  // Re-derive the fingerprint exactly as the generator did. Mismatch here464  // means the reconstructed `vaultBlob` is corrupt even though every chunk465  // passed its hash — most likely a Reed-Solomon decode pulled the wrong466  // bytes into a parity-reconstructed slot. `sha256Hex` already returns467  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-468  // suspenders against a future helpers.ts change.469  //470  if (payload.vaultFingerprint.length !== 32) {471    throw new CodexError(472      "RECONSTRUCTION_FINGERPRINT_MISMATCH",473      "Reconstructed vault fingerprint has an invalid length. " +474        "Try scanning the Codex again from the beginning.",475    );476  }477  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))478    .slice(0, 32)479    .toUpperCase();480  if (fingerprint !== payload.vaultFingerprint) {481    throw new CodexError(482      "RECONSTRUCTION_FINGERPRINT_MISMATCH",483      "Reconstructed vault fingerprint does not match the printed Codex. " +484        "Try scanning the Codex again from the beginning.",485    );486  }487488  return payload;489}