restore.ts

11.1 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  type ChunkEnvelope,28  type CodexPayload,29} from "../../shared/codex-types";30import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";31import {32  base64UrlToBytes,33  sha256Hex,34  verifyChunkIntegrity,35} from "./helpers";3637export interface ScannedChunk {38  envelope: ChunkEnvelope;39  bytes: Uint8Array;40}4142export type ChunkAddReason =43  | "duplicate"44  | "foreign_codex"45  | "bad_integrity"46  | "parse_error";4748export interface ChunkAddResult {49  accepted: boolean;50  reason?: ChunkAddReason;51}5253interface StripeState {54  total: number;55  dataShards: number;56  chunks: Map<number, ScannedChunk>;57}5859/**60 * Aggregate snapshot of the buffer state. Counts (`total`, `dataShards`,61 * `scanned`) are sums across whichever stripes the buffer has *observed at62 * least one envelope from*; until every stripe has been seen, they are63 * lower bounds. `allStripesObserved` flips to `true` once the buffer has at64 * least one envelope from each of the `stripeCount` stripes — that is when65 * `total` and `dataShards` become final.66 */67export interface ChunkBufferContext {68  verifyCode: string;69  /** Total stripes in this Codex (always 1 for legacy v=2 envelopes). */70  stripeCount: number;71  /** Running total chunks (data + parity) across observed stripes. */72  total: number;73  /** Running total data shards across observed stripes. */74  dataShards: number;75  /** Chunks accepted into the buffer so far. */76  scanned: number;77  /** True once at least one envelope from every stripe has been observed. */78  allStripesObserved: boolean;79}8081export interface StripeReadiness {82  stripe: number;83  found: number;84  required: number;85  isReady: boolean;86}8788export class ChunkBuffer {89  private verifyCode: string | null = null;90  private stripeCount: number | null = null;91  private readonly stripes = new Map<number, StripeState>();9293  /**94   * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or95   * `{ accepted: false, reason }` for any rejection. The buffer never throws —96   * the scanner UI needs to keep running across bad frames.97   *98   * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2 is99   * treated as a v3 envelope with `s=0, S=1` so the rest of the pipeline can100   * be stripe-aware without branching.101   */102  async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {103    if (envelope.v !== 2 && envelope.v !== 3) {104      return { accepted: false, reason: "parse_error" };105    }106107    if (!(await verifyChunkIntegrity(envelope))) {108      return { accepted: false, reason: "bad_integrity" };109    }110111    const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;112    const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;113114    if (this.verifyCode === null) {115      // First valid chunk establishes verifyCode and stripeCount. Reject116      // subsequent envelopes whose `c` or `S` disagrees so two overlapping117      // Codex prints cannot poison each other.118      this.verifyCode = envelope.c;119      this.stripeCount = stripeCount;120    } else if (envelope.c !== this.verifyCode) {121      return { accepted: false, reason: "foreign_codex" };122    } else if (this.stripeCount !== stripeCount) {123      return { accepted: false, reason: "foreign_codex" };124    }125126    if (stripe < 0 || stripe >= this.stripeCount!) {127      return { accepted: false, reason: "parse_error" };128    }129130    let stripeState = this.stripes.get(stripe);131    if (!stripeState) {132      // First envelope from a new stripe fixes that stripe's total + data133      // shard count. `deriveShardCounts` inverts `computeParityShards` over134      // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE so135      // the inversion always has a unique solution.136      let dataShards: number;137      try {138        dataShards = deriveShardCounts(envelope.t).dataShards;139      } catch {140        return { accepted: false, reason: "parse_error" };141      }142      stripeState = { total: envelope.t, dataShards, chunks: new Map() };143      this.stripes.set(stripe, stripeState);144    } else if (stripeState.total !== envelope.t) {145      // Two envelopes from the same stripe disagreeing on `t` means one of146      // them is corrupt or from a different print run — reject.147      return { accepted: false, reason: "parse_error" };148    }149150    if (stripeState.chunks.has(envelope.i)) {151      return { accepted: false, reason: "duplicate" };152    }153    if (envelope.i < 0 || envelope.i >= envelope.t) {154      return { accepted: false, reason: "parse_error" };155    }156157    stripeState.chunks.set(envelope.i, {158      envelope,159      bytes: base64UrlToBytes(envelope.d),160    });161    return { accepted: true };162  }163164  size(): number {165    let count = 0;166    for (const s of this.stripes.values()) count += s.chunks.size;167    return count;168  }169170  getContext(): ChunkBufferContext | null {171    if (this.verifyCode === null || this.stripeCount === null) return null;172    let total = 0;173    let dataShards = 0;174    let scanned = 0;175    for (const s of this.stripes.values()) {176      total += s.total;177      dataShards += s.dataShards;178      scanned += s.chunks.size;179    }180    return {181      verifyCode: this.verifyCode,182      stripeCount: this.stripeCount,183      total,184      dataShards,185      scanned,186      allStripesObserved: this.stripes.size === this.stripeCount,187    };188  }189190  isReady(): boolean {191    if (this.verifyCode === null || this.stripeCount === null) return false;192    if (this.stripes.size < this.stripeCount) return false;193    for (let i = 0; i < this.stripeCount; i++) {194      const s = this.stripes.get(i);195      if (!s || s.chunks.size < s.dataShards) return false;196    }197    return true;198  }199200  stripeReadiness(): StripeReadiness[] {201    if (this.stripeCount === null) return [];202    const out: StripeReadiness[] = [];203    for (let i = 0; i < this.stripeCount; i++) {204      const s = this.stripes.get(i);205      const found = s?.chunks.size ?? 0;206      const required = s?.dataShards ?? 0;207      out.push({208        stripe: i,209        found,210        required,211        isReady: !!s && found >= required,212      });213    }214    return out;215  }216217  entries(): ScannedChunk[] {218    const out: ScannedChunk[] = [];219    for (const s of this.stripes.values()) {220      for (const c of s.chunks.values()) out.push(c);221    }222    return out;223  }224225  /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */226  toStripeChunks(): StripeChunks[] {227    if (this.stripeCount === null) return [];228    const out: StripeChunks[] = [];229    for (let i = 0; i < this.stripeCount; i++) {230      const s = this.stripes.get(i);231      if (!s) {232        throw new CodexError(233          "INSUFFICIENT_CHUNKS",234          `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,235        );236      }237      const chunks = new Map<number, Uint8Array>();238      for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);239      out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });240    }241    return out;242  }243}244245export type ParseRawScanResult =246  | ChunkEnvelope247  | { error: "parse_error" | "wrong_version" };248249/**250 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner251 * fires on every successful decode — most decodes are noise (other QR codes,252 * stickers, old Codex prints) — so the caller must not assume any structure.253 *254 * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2255 * envelopes are returned as-is — downstream stripe handling treats missing256 * `s` / `S` as `0` / `1` respectively.257 */258export function parseRawScan(raw: string): ParseRawScanResult {259  let parsed: unknown;260  try {261    parsed = JSON.parse(raw);262  } catch {263    return { error: "parse_error" };264  }265266  if (typeof parsed !== "object" || parsed === null) {267    return { error: "parse_error" };268  }269270  const obj = parsed as Record<string, unknown>;271272  if (obj.v !== 2 && obj.v !== 3) {273    if ("v" in obj) return { error: "wrong_version" };274    return { error: "parse_error" };275  }276277  const looksLikeEnvelope =278    typeof obj.c === "string" &&279    typeof obj.i === "number" &&280    typeof obj.t === "number" &&281    typeof obj.p === "boolean" &&282    typeof obj.d === "string" &&283    typeof obj.h === "string";284285  if (!looksLikeEnvelope) {286    return { error: "parse_error" };287  }288289  // v3-only fields must be shaped correctly when present.290  if (obj.v === 3) {291    if (typeof obj.s !== "number" || typeof obj.S !== "number") {292      return { error: "parse_error" };293    }294  }295296  return obj as unknown as ChunkEnvelope;297}298299/**300 * Reconstruct the payload from a fully-loaded buffer and verify the vault301 * fingerprint round-trips. Throws `CodexError` on any inconsistency —302 * callers translate that into a visible "scan again" message.303 */304export async function reconstructAndVerify(305  buffer: ChunkBuffer,306): Promise<CodexPayload> {307  const ctx = buffer.getContext();308  if (ctx === null) {309    throw new CodexError(310      "INSUFFICIENT_CHUNKS",311      "No chunks have been scanned yet.",312    );313  }314315  const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());316317  // Re-derive the fingerprint exactly as the generator did. Mismatch here318  // means the reconstructed `vaultBlob` is corrupt even though every chunk319  // passed its hash — most likely a Reed-Solomon decode pulled the wrong320  // bytes into a parity-reconstructed slot. `sha256Hex` already returns321  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-322  // suspenders against a future helpers.ts change.323  //324  // AUD-004: legacy Codexes printed before v2.50.2 carry a 16-char (64-bit)325  // fingerprint; current Codexes carry 32 chars (128 bits). Slice by the326  // stored length so both verify without an envelope-version bump.327  const fingerprintLen = payload.vaultFingerprint.length;328  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))329    .slice(0, fingerprintLen)330    .toUpperCase();331  if (fingerprint !== payload.vaultFingerprint) {332    throw new CodexError(333      "RECONSTRUCTION_FINGERPRINT_MISMATCH",334      "Reconstructed vault fingerprint does not match the printed Codex. " +335        "Try scanning the Codex again from the beginning.",336    );337  }338339  return payload;340}