restore.ts

11.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  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  base64UrlToBytes,34  sha256Hex,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    if (!(await verifyChunkIntegrity(envelope))) {109      return { accepted: false, reason: "bad_integrity" };110    }111112    const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;113    const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;114115    // PCH-01-011: `S` is attacker-controlled. Reject an out-of-range stripe116    // count before it can pin this.stripeCount and drive O(S) allocation in117    // stripeReadiness()/toStripeChunks() on the popup's render path.118    if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) {119      return { accepted: false, reason: "parse_error" };120    }121122    if (this.verifyCode === null) {123      // First valid chunk establishes verifyCode and stripeCount. Reject124      // subsequent envelopes whose `c` or `S` disagrees so two overlapping125      // Codex prints cannot poison each other.126      this.verifyCode = envelope.c;127      this.stripeCount = stripeCount;128    } else if (envelope.c !== this.verifyCode) {129      return { accepted: false, reason: "foreign_codex" };130    } else if (this.stripeCount !== stripeCount) {131      return { accepted: false, reason: "foreign_codex" };132    }133134    if (stripe < 0 || stripe >= this.stripeCount!) {135      return { accepted: false, reason: "parse_error" };136    }137138    let stripeState = this.stripes.get(stripe);139    if (!stripeState) {140      // First envelope from a new stripe fixes that stripe's total + data141      // shard count. `deriveShardCounts` inverts `computeParityShards` over142      // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE in143      // deriveShardCounts so the inversion has a unique, sane solution (PCH-01-010).144      let dataShards: number;145      try {146        dataShards = deriveShardCounts(envelope.t).dataShards;147      } catch {148        return { accepted: false, reason: "parse_error" };149      }150      stripeState = { total: envelope.t, dataShards, chunks: new Map() };151      this.stripes.set(stripe, stripeState);152    } else if (stripeState.total !== envelope.t) {153      // Two envelopes from the same stripe disagreeing on `t` means one of154      // them is corrupt or from a different print run — reject.155      return { accepted: false, reason: "parse_error" };156    }157158    if (stripeState.chunks.has(envelope.i)) {159      return { accepted: false, reason: "duplicate" };160    }161    if (envelope.i < 0 || envelope.i >= envelope.t) {162      return { accepted: false, reason: "parse_error" };163    }164165    stripeState.chunks.set(envelope.i, {166      envelope,167      bytes: base64UrlToBytes(envelope.d),168    });169    return { accepted: true };170  }171172  size(): number {173    let count = 0;174    for (const s of this.stripes.values()) count += s.chunks.size;175    return count;176  }177178  getContext(): ChunkBufferContext | null {179    if (this.verifyCode === null || this.stripeCount === null) return null;180    let total = 0;181    let dataShards = 0;182    let scanned = 0;183    for (const s of this.stripes.values()) {184      total += s.total;185      dataShards += s.dataShards;186      scanned += s.chunks.size;187    }188    return {189      verifyCode: this.verifyCode,190      stripeCount: this.stripeCount,191      total,192      dataShards,193      scanned,194      allStripesObserved: this.stripes.size === this.stripeCount,195    };196  }197198  isReady(): boolean {199    if (this.verifyCode === null || this.stripeCount === null) return false;200    if (this.stripes.size < this.stripeCount) return false;201    for (let i = 0; i < this.stripeCount; i++) {202      const s = this.stripes.get(i);203      if (!s || s.chunks.size < s.dataShards) return false;204    }205    return true;206  }207208  stripeReadiness(): StripeReadiness[] {209    if (this.stripeCount === null) return [];210    const out: StripeReadiness[] = [];211    for (let i = 0; i < this.stripeCount; i++) {212      const s = this.stripes.get(i);213      const found = s?.chunks.size ?? 0;214      const required = s?.dataShards ?? 0;215      out.push({216        stripe: i,217        found,218        required,219        isReady: !!s && found >= required,220      });221    }222    return out;223  }224225  entries(): ScannedChunk[] {226    const out: ScannedChunk[] = [];227    for (const s of this.stripes.values()) {228      for (const c of s.chunks.values()) out.push(c);229    }230    return out;231  }232233  /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */234  toStripeChunks(): StripeChunks[] {235    if (this.stripeCount === null) return [];236    const out: StripeChunks[] = [];237    for (let i = 0; i < this.stripeCount; i++) {238      const s = this.stripes.get(i);239      if (!s) {240        throw new CodexError(241          "INSUFFICIENT_CHUNKS",242          `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,243        );244      }245      const chunks = new Map<number, Uint8Array>();246      for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);247      out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });248    }249    return out;250  }251}252253export type ParseRawScanResult =254  | ChunkEnvelope255  | { error: "parse_error" | "wrong_version" };256257/**258 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner259 * fires on every successful decode — most decodes are noise (other QR codes,260 * stickers, old Codex prints) — so the caller must not assume any structure.261 *262 * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2263 * envelopes are returned as-is — downstream stripe handling treats missing264 * `s` / `S` as `0` / `1` respectively.265 */266export function parseRawScan(raw: string): ParseRawScanResult {267  let parsed: unknown;268  try {269    parsed = JSON.parse(raw);270  } catch {271    return { error: "parse_error" };272  }273274  if (typeof parsed !== "object" || parsed === null) {275    return { error: "parse_error" };276  }277278  const obj = parsed as Record<string, unknown>;279280  if (obj.v !== 2 && obj.v !== 3) {281    if ("v" in obj) return { error: "wrong_version" };282    return { error: "parse_error" };283  }284285  const looksLikeEnvelope =286    typeof obj.c === "string" &&287    typeof obj.i === "number" &&288    typeof obj.t === "number" &&289    typeof obj.p === "boolean" &&290    typeof obj.d === "string" &&291    typeof obj.h === "string";292293  if (!looksLikeEnvelope) {294    return { error: "parse_error" };295  }296297  // v3-only fields must be shaped correctly when present.298  if (obj.v === 3) {299    if (typeof obj.s !== "number" || typeof obj.S !== "number") {300      return { error: "parse_error" };301    }302  }303304  return obj as unknown as ChunkEnvelope;305}306307/**308 * Reconstruct the payload from a fully-loaded buffer and verify the vault309 * fingerprint round-trips. Throws `CodexError` on any inconsistency —310 * callers translate that into a visible "scan again" message.311 */312export async function reconstructAndVerify(313  buffer: ChunkBuffer,314): Promise<CodexPayload> {315  const ctx = buffer.getContext();316  if (ctx === null) {317    throw new CodexError(318      "INSUFFICIENT_CHUNKS",319      "No chunks have been scanned yet.",320    );321  }322323  const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());324325  // Re-derive the fingerprint exactly as the generator did. Mismatch here326  // means the reconstructed `vaultBlob` is corrupt even though every chunk327  // passed its hash — most likely a Reed-Solomon decode pulled the wrong328  // bytes into a parity-reconstructed slot. `sha256Hex` already returns329  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-330  // suspenders against a future helpers.ts change.331  //332  // AUD-004: legacy Codexes printed before v2.50.2 carry a 16-char (64-bit)333  // fingerprint; current Codexes carry 32 chars (128 bits). Slice by the334  // stored length so both verify without an envelope-version bump.335  const fingerprintLen = payload.vaultFingerprint.length;336  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))337    .slice(0, fingerprintLen)338    .toUpperCase();339  if (fingerprint !== payload.vaultFingerprint) {340    throw new CodexError(341      "RECONSTRUCTION_FINGERPRINT_MISMATCH",342      "Reconstructed vault fingerprint does not match the printed Codex. " +343        "Try scanning the Codex again from the beginning.",344    );345  }346347  return payload;348}