restore.ts

12.9 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 (always 1 for legacy v=2 envelopes). */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 v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2 is100   * treated as a v3 envelope with `s=0, S=1` so the rest of the pipeline can101   * be stripe-aware without branching.102   */103  async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {104    if (envelope.v !== 2 && envelope.v !== 3) {105      return { accepted: false, reason: "parse_error" };106    }107108    // PCH-01-026 #2: decode `d` once, up front, so an out-of-alphabet base64109    // payload becomes a parse_error rejection instead of a thrown exception out110    // of add() (honouring the documented "never throws" contract).111    // verifyChunkIntegrity also decodes `d`, so guarding here covers both that112    // decode and the accept-path decode below, which reuses `decoded`.113    const decoded = tryBase64UrlToBytes(envelope.d);114    if (decoded === null) {115      return { accepted: false, reason: "parse_error" };116    }117118    if (!(await verifyChunkIntegrity(envelope))) {119      return { accepted: false, reason: "bad_integrity" };120    }121122    const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;123    const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;124125    // PCH-01-011: `S` is attacker-controlled. Reject an out-of-range stripe126    // count before it can pin this.stripeCount and drive O(S) allocation in127    // stripeReadiness()/toStripeChunks() on the popup's render path.128    if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) {129      return { accepted: false, reason: "parse_error" };130    }131132    if (this.verifyCode === null) {133      // First valid chunk establishes verifyCode and stripeCount. Reject134      // subsequent envelopes whose `c` or `S` disagrees so two overlapping135      // Codex prints cannot poison each other.136      this.verifyCode = envelope.c;137      this.stripeCount = stripeCount;138    } else if (envelope.c !== this.verifyCode) {139      return { accepted: false, reason: "foreign_codex" };140    } else if (this.stripeCount !== stripeCount) {141      return { accepted: false, reason: "foreign_codex" };142    }143144    if (stripe < 0 || stripe >= this.stripeCount!) {145      return { accepted: false, reason: "parse_error" };146    }147148    let stripeState = this.stripes.get(stripe);149    if (!stripeState) {150      // First envelope from a new stripe fixes that stripe's total + data151      // shard count. `deriveShardCounts` inverts `computeParityShards` over152      // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE in153      // deriveShardCounts so the inversion has a unique, sane solution (PCH-01-010).154      let dataShards: number;155      try {156        dataShards = deriveShardCounts(envelope.t).dataShards;157      } catch {158        return { accepted: false, reason: "parse_error" };159      }160      stripeState = { total: envelope.t, dataShards, chunks: new Map() };161      this.stripes.set(stripe, stripeState);162    } else if (stripeState.total !== envelope.t) {163      // Two envelopes from the same stripe disagreeing on `t` means one of164      // them is corrupt or from a different print run — reject.165      return { accepted: false, reason: "parse_error" };166    }167168    // PCH-01-026 #7: validate the index range BEFORE the duplicate lookup. An169    // out-of-range index is malformed, not a duplicate — classifying it by map170    // membership would mis-report it as "duplicate".171    if (envelope.i < 0 || envelope.i >= envelope.t) {172      return { accepted: false, reason: "parse_error" };173    }174    if (stripeState.chunks.has(envelope.i)) {175      return { accepted: false, reason: "duplicate" };176    }177178    stripeState.chunks.set(envelope.i, {179      envelope,180      bytes: decoded, // PCH-01-026 #2: reuse the single successful decode181    });182    return { accepted: true };183  }184185  size(): number {186    let count = 0;187    for (const s of this.stripes.values()) count += s.chunks.size;188    return count;189  }190191  getContext(): ChunkBufferContext | null {192    if (this.verifyCode === null || this.stripeCount === null) return null;193    let total = 0;194    let dataShards = 0;195    let scanned = 0;196    for (const s of this.stripes.values()) {197      total += s.total;198      dataShards += s.dataShards;199      scanned += s.chunks.size;200    }201    return {202      verifyCode: this.verifyCode,203      stripeCount: this.stripeCount,204      total,205      dataShards,206      scanned,207      allStripesObserved: this.stripes.size === this.stripeCount,208    };209  }210211  isReady(): boolean {212    if (this.verifyCode === null || this.stripeCount === null) return false;213    if (this.stripes.size < this.stripeCount) return false;214    for (let i = 0; i < this.stripeCount; i++) {215      const s = this.stripes.get(i);216      if (!s || s.chunks.size < s.dataShards) return false;217    }218    return true;219  }220221  stripeReadiness(): StripeReadiness[] {222    if (this.stripeCount === null) return [];223    const out: StripeReadiness[] = [];224    for (let i = 0; i < this.stripeCount; i++) {225      const s = this.stripes.get(i);226      const found = s?.chunks.size ?? 0;227      const required = s?.dataShards ?? 0;228      out.push({229        stripe: i,230        found,231        required,232        isReady: !!s && found >= required,233      });234    }235    return out;236  }237238  entries(): ScannedChunk[] {239    const out: ScannedChunk[] = [];240    for (const s of this.stripes.values()) {241      for (const c of s.chunks.values()) out.push(c);242    }243    return out;244  }245246  /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */247  toStripeChunks(): StripeChunks[] {248    if (this.stripeCount === null) return [];249    const out: StripeChunks[] = [];250    for (let i = 0; i < this.stripeCount; i++) {251      const s = this.stripes.get(i);252      if (!s) {253        throw new CodexError(254          "INSUFFICIENT_CHUNKS",255          `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,256        );257      }258      const chunks = new Map<number, Uint8Array>();259      for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);260      out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });261    }262    return out;263  }264}265266export type ParseRawScanResult =267  | ChunkEnvelope268  | { error: "parse_error" | "wrong_version" };269270/**271 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner272 * fires on every successful decode — most decodes are noise (other QR codes,273 * stickers, old Codex prints) — so the caller must not assume any structure.274 *275 * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2276 * envelopes are returned as-is — downstream stripe handling treats missing277 * `s` / `S` as `0` / `1` respectively.278 */279export function parseRawScan(raw: string): ParseRawScanResult {280  let parsed: unknown;281  try {282    parsed = JSON.parse(raw);283  } catch {284    return { error: "parse_error" };285  }286287  if (typeof parsed !== "object" || parsed === null) {288    return { error: "parse_error" };289  }290291  const obj = parsed as Record<string, unknown>;292293  if (obj.v !== 2 && obj.v !== 3) {294    if ("v" in obj) return { error: "wrong_version" };295    return { error: "parse_error" };296  }297298  const looksLikeEnvelope =299    typeof obj.c === "string" &&300    Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices301    Number.isInteger(obj.t) &&302    typeof obj.p === "boolean" &&303    typeof obj.d === "string" &&304    typeof obj.h === "string";305306  if (!looksLikeEnvelope) {307    return { error: "parse_error" };308  }309310  // v3-only fields must be shaped correctly when present.311  if (obj.v === 3) {312    if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {313      return { error: "parse_error" };314    }315  }316317  return obj as unknown as ChunkEnvelope;318}319320/**321 * Reconstruct the payload from a fully-loaded buffer and verify the vault322 * fingerprint round-trips. Throws `CodexError` on any inconsistency —323 * callers translate that into a visible "scan again" message.324 */325export async function reconstructAndVerify(326  buffer: ChunkBuffer,327): Promise<CodexPayload> {328  const ctx = buffer.getContext();329  if (ctx === null) {330    throw new CodexError(331      "INSUFFICIENT_CHUNKS",332      "No chunks have been scanned yet.",333    );334  }335336  const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());337338  // Re-derive the fingerprint exactly as the generator did. Mismatch here339  // means the reconstructed `vaultBlob` is corrupt even though every chunk340  // passed its hash — most likely a Reed-Solomon decode pulled the wrong341  // bytes into a parity-reconstructed slot. `sha256Hex` already returns342  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-343  // suspenders against a future helpers.ts change.344  //345  // AUD-004: legacy Codexes printed before v2.50.2 carry a 16-char (64-bit)346  // fingerprint; current Codexes carry 32 chars (128 bits). Slice by the347  // stored length so both verify without an envelope-version bump.348  const fingerprintLen = payload.vaultFingerprint.length;349  // AUD-004 / PCH-01-013: the declared fingerprint length is attacker-350  // controlled. Without this gate, an empty declared fingerprint passes351  // trivially (slice(0,0) === "") and a 1-char one passes with p=1/16. Accept352  // only the two documented widths: 16 (legacy 64-bit) and 32 (current 128-bit).353  // Mirrors verifyChunkIntegrity in helpers.ts.354  if (fingerprintLen !== 16 && fingerprintLen !== 32) {355    throw new CodexError(356      "RECONSTRUCTION_FINGERPRINT_MISMATCH",357      "Reconstructed vault fingerprint has an invalid length. " +358        "Try scanning the Codex again from the beginning.",359    );360  }361  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))362    .slice(0, fingerprintLen)363    .toUpperCase();364  if (fingerprint !== payload.vaultFingerprint) {365    throw new CodexError(366      "RECONSTRUCTION_FINGERPRINT_MISMATCH",367      "Reconstructed vault fingerprint does not match the printed Codex. " +368        "Try scanning the Codex again from the beginning.",369    );370  }371372  return payload;373}