restore.ts

11.8 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  CodexError,26  deriveShardCounts,27  MAX_STRIPE_COUNT,28  type ChunkEnvelope,29  type CodexPayload,30} from "../../shared/codex-types";31import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";32import {33  sha256Hex,34  tryBase64UrlToBytes,35  verifyChunkIntegrity,36} from "./helpers";3738export interface ScannedChunk {39  envelope: ChunkEnvelope;40  bytes: Uint8Array;41}4243export type ChunkAddReason =44  | "duplicate"45  | "foreign_codex"46  | "bad_integrity"47  | "parse_error";4849export interface ChunkAddResult {50  accepted: boolean;51  reason?: ChunkAddReason;52}5354interface StripeState {55  total: number;56  dataShards: number;57  chunks: Map<number, ScannedChunk>;58}5960/**61 * Aggregate snapshot of the buffer state. Counts (`total`, `dataShards`,62 * `scanned`) are sums across whichever stripes the buffer has *observed at63 * least one envelope from*; until every stripe has been seen, they are64 * lower bounds. `allStripesObserved` flips to `true` once the buffer has at65 * least one envelope from each of the `stripeCount` stripes — that is when66 * `total` and `dataShards` become final.67 */68export interface ChunkBufferContext {69  verifyCode: string;70  /** Total stripes in this Codex. */71  stripeCount: number;72  /** Running total chunks (data + parity) across observed stripes. */73  total: number;74  /** Running total data shards across observed stripes. */75  dataShards: number;76  /** Chunks accepted into the buffer so far. */77  scanned: number;78  /** True once at least one envelope from every stripe has been observed. */79  allStripesObserved: boolean;80}8182export interface StripeReadiness {83  stripe: number;84  found: number;85  required: number;86  isReady: boolean;87}8889export class ChunkBuffer {90  private verifyCode: string | null = null;91  private stripeCount: number | null = null;92  private readonly stripes = new Map<number, StripeState>();9394  /**95   * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or96   * `{ accepted: false, reason }` for any rejection. The buffer never throws —97   * the scanner UI needs to keep running across bad frames.98   *99   * Accepts current v3 envelopes and rejects older paper formats.100   */101  async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {102    if (envelope.v !== 3) {103      return { accepted: false, reason: "parse_error" };104    }105106    // PCH-01-026 #2: decode `d` once, up front, so an out-of-alphabet base64107    // payload becomes a parse_error rejection instead of a thrown exception out108    // of add() (honouring the documented "never throws" contract).109    // verifyChunkIntegrity also decodes `d`, so guarding here covers both that110    // decode and the accept-path decode below, which reuses `decoded`.111    const decoded = tryBase64UrlToBytes(envelope.d);112    if (decoded === null) {113      return { accepted: false, reason: "parse_error" };114    }115116    if (!(await verifyChunkIntegrity(envelope))) {117      return { accepted: false, reason: "bad_integrity" };118    }119120    const stripe = envelope.s;121    const stripeCount = envelope.S;122123    // PCH-01-011: `S` is attacker-controlled. Reject an out-of-range stripe124    // count before it can pin this.stripeCount and drive O(S) allocation in125    // stripeReadiness()/toStripeChunks() on the popup's render path.126    if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) {127      return { accepted: false, reason: "parse_error" };128    }129130    if (this.verifyCode === null) {131      // First valid chunk establishes verifyCode and stripeCount. Reject132      // subsequent envelopes whose `c` or `S` disagrees so two overlapping133      // Codex prints cannot poison each other.134      this.verifyCode = envelope.c;135      this.stripeCount = stripeCount;136    } else if (envelope.c !== this.verifyCode) {137      return { accepted: false, reason: "foreign_codex" };138    } else if (this.stripeCount !== stripeCount) {139      return { accepted: false, reason: "foreign_codex" };140    }141142    if (stripe < 0 || stripe >= this.stripeCount!) {143      return { accepted: false, reason: "parse_error" };144    }145146    let stripeState = this.stripes.get(stripe);147    if (!stripeState) {148      // First envelope from a new stripe fixes that stripe's total + data149      // shard count. `deriveShardCounts` inverts `computeParityShards` over150      // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE in151      // deriveShardCounts so the inversion has a unique, sane solution (PCH-01-010).152      let dataShards: number;153      try {154        dataShards = deriveShardCounts(envelope.t).dataShards;155      } catch {156        return { accepted: false, reason: "parse_error" };157      }158      stripeState = { total: envelope.t, dataShards, chunks: new Map() };159      this.stripes.set(stripe, stripeState);160    } else if (stripeState.total !== envelope.t) {161      // Two envelopes from the same stripe disagreeing on `t` means one of162      // them is corrupt or from a different print run — reject.163      return { accepted: false, reason: "parse_error" };164    }165166    // PCH-01-026 #7: validate the index range BEFORE the duplicate lookup. An167    // out-of-range index is malformed, not a duplicate — classifying it by map168    // membership would mis-report it as "duplicate".169    if (envelope.i < 0 || envelope.i >= envelope.t) {170      return { accepted: false, reason: "parse_error" };171    }172    if (stripeState.chunks.has(envelope.i)) {173      return { accepted: false, reason: "duplicate" };174    }175176    stripeState.chunks.set(envelope.i, {177      envelope,178      bytes: decoded, // PCH-01-026 #2: reuse the single successful decode179    });180    return { accepted: true };181  }182183  size(): number {184    let count = 0;185    for (const s of this.stripes.values()) count += s.chunks.size;186    return count;187  }188189  getContext(): ChunkBufferContext | null {190    if (this.verifyCode === null || this.stripeCount === null) return null;191    let total = 0;192    let dataShards = 0;193    let scanned = 0;194    for (const s of this.stripes.values()) {195      total += s.total;196      dataShards += s.dataShards;197      scanned += s.chunks.size;198    }199    return {200      verifyCode: this.verifyCode,201      stripeCount: this.stripeCount,202      total,203      dataShards,204      scanned,205      allStripesObserved: this.stripes.size === this.stripeCount,206    };207  }208209  isReady(): boolean {210    if (this.verifyCode === null || this.stripeCount === null) return false;211    if (this.stripes.size < this.stripeCount) return false;212    for (let i = 0; i < this.stripeCount; i++) {213      const s = this.stripes.get(i);214      if (!s || s.chunks.size < s.dataShards) return false;215    }216    return true;217  }218219  stripeReadiness(): StripeReadiness[] {220    if (this.stripeCount === null) return [];221    const out: StripeReadiness[] = [];222    for (let i = 0; i < this.stripeCount; i++) {223      const s = this.stripes.get(i);224      const found = s?.chunks.size ?? 0;225      const required = s?.dataShards ?? 0;226      out.push({227        stripe: i,228        found,229        required,230        isReady: !!s && found >= required,231      });232    }233    return out;234  }235236  entries(): ScannedChunk[] {237    const out: ScannedChunk[] = [];238    for (const s of this.stripes.values()) {239      for (const c of s.chunks.values()) out.push(c);240    }241    return out;242  }243244  /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */245  toStripeChunks(): StripeChunks[] {246    if (this.stripeCount === null) return [];247    const out: StripeChunks[] = [];248    for (let i = 0; i < this.stripeCount; i++) {249      const s = this.stripes.get(i);250      if (!s) {251        throw new CodexError(252          "INSUFFICIENT_CHUNKS",253          `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,254        );255      }256      const chunks = new Map<number, Uint8Array>();257      for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);258      out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });259    }260    return out;261  }262}263264export type ParseRawScanResult =265  | ChunkEnvelope266  | { error: "parse_error" | "wrong_version" };267268/**269 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner270 * fires on every successful decode — most decodes are noise (other QR codes,271 * stickers, old Codex prints) — so the caller must not assume any structure.272 *273 * Accepts current v3 envelopes. Older paper formats are rejected.274 */275export function parseRawScan(raw: string): ParseRawScanResult {276  let parsed: unknown;277  try {278    parsed = JSON.parse(raw);279  } catch {280    return { error: "parse_error" };281  }282283  if (typeof parsed !== "object" || parsed === null) {284    return { error: "parse_error" };285  }286287  const obj = parsed as Record<string, unknown>;288289  if (obj.v !== 3) {290    if ("v" in obj) return { error: "wrong_version" };291    return { error: "parse_error" };292  }293294  const looksLikeEnvelope =295    typeof obj.c === "string" &&296    Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices297    Number.isInteger(obj.t) &&298    typeof obj.p === "boolean" &&299    typeof obj.d === "string" &&300    typeof obj.h === "string";301302  if (!looksLikeEnvelope) {303    return { error: "parse_error" };304  }305306  if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {307    return { error: "parse_error" };308  }309310  return obj as unknown as ChunkEnvelope;311}312313/**314 * Reconstruct the payload from a fully-loaded buffer and verify the vault315 * fingerprint round-trips. Throws `CodexError` on any inconsistency —316 * callers translate that into a visible "scan again" message.317 */318export async function reconstructAndVerify(319  buffer: ChunkBuffer,320): Promise<CodexPayload> {321  const ctx = buffer.getContext();322  if (ctx === null) {323    throw new CodexError(324      "INSUFFICIENT_CHUNKS",325      "No chunks have been scanned yet.",326    );327  }328329  const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());330331  // Re-derive the fingerprint exactly as the generator did. Mismatch here332  // means the reconstructed `vaultBlob` is corrupt even though every chunk333  // passed its hash — most likely a Reed-Solomon decode pulled the wrong334  // bytes into a parity-reconstructed slot. `sha256Hex` already returns335  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-336  // suspenders against a future helpers.ts change.337  //338  if (payload.vaultFingerprint.length !== 32) {339    throw new CodexError(340      "RECONSTRUCTION_FINGERPRINT_MISMATCH",341      "Reconstructed vault fingerprint has an invalid length. " +342        "Try scanning the Codex again from the beginning.",343    );344  }345  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))346    .slice(0, 32)347    .toUpperCase();348  if (fingerprint !== payload.vaultFingerprint) {349    throw new CodexError(350      "RECONSTRUCTION_FINGERPRINT_MISMATCH",351      "Reconstructed vault fingerprint does not match the printed Codex. " +352        "Try scanning the Codex again from the beginning.",353    );354  }355356  return payload;357}