restore.ts

10.6 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 class ChunkBuffer {82  private verifyCode: string | null = null;83  private stripeCount: number | null = null;84  private readonly stripes = new Map<number, StripeState>();8586  /**87   * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or88   * `{ accepted: false, reason }` for any rejection. The buffer never throws —89   * the scanner UI needs to keep running across bad frames.90   *91   * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2 is92   * treated as a v3 envelope with `s=0, S=1` so the rest of the pipeline can93   * be stripe-aware without branching.94   */95  async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {96    if (envelope.v !== 2 && envelope.v !== 3) {97      return { accepted: false, reason: "parse_error" };98    }99100    if (!(await verifyChunkIntegrity(envelope))) {101      return { accepted: false, reason: "bad_integrity" };102    }103104    const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;105    const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;106107    if (this.verifyCode === null) {108      // First valid chunk establishes verifyCode and stripeCount. Reject109      // subsequent envelopes whose `c` or `S` disagrees so two overlapping110      // Codex prints cannot poison each other.111      this.verifyCode = envelope.c;112      this.stripeCount = stripeCount;113    } else if (envelope.c !== this.verifyCode) {114      return { accepted: false, reason: "foreign_codex" };115    } else if (this.stripeCount !== stripeCount) {116      return { accepted: false, reason: "foreign_codex" };117    }118119    if (stripe < 0 || stripe >= this.stripeCount!) {120      return { accepted: false, reason: "parse_error" };121    }122123    let stripeState = this.stripes.get(stripe);124    if (!stripeState) {125      // First envelope from a new stripe fixes that stripe's total + data126      // shard count. `deriveShardCounts` inverts `computeParityShards` over127      // the within-stripe total — bounded by MAX_DATA_SHARDS_PER_STRIPE so128      // the inversion always has a unique solution.129      let dataShards: number;130      try {131        dataShards = deriveShardCounts(envelope.t).dataShards;132      } catch {133        return { accepted: false, reason: "parse_error" };134      }135      stripeState = { total: envelope.t, dataShards, chunks: new Map() };136      this.stripes.set(stripe, stripeState);137    } else if (stripeState.total !== envelope.t) {138      // Two envelopes from the same stripe disagreeing on `t` means one of139      // them is corrupt or from a different print run — reject.140      return { accepted: false, reason: "parse_error" };141    }142143    if (stripeState.chunks.has(envelope.i)) {144      return { accepted: false, reason: "duplicate" };145    }146    if (envelope.i < 0 || envelope.i >= envelope.t) {147      return { accepted: false, reason: "parse_error" };148    }149150    stripeState.chunks.set(envelope.i, {151      envelope,152      bytes: base64UrlToBytes(envelope.d),153    });154    return { accepted: true };155  }156157  size(): number {158    let count = 0;159    for (const s of this.stripes.values()) count += s.chunks.size;160    return count;161  }162163  getContext(): ChunkBufferContext | null {164    if (this.verifyCode === null || this.stripeCount === null) return null;165    let total = 0;166    let dataShards = 0;167    let scanned = 0;168    for (const s of this.stripes.values()) {169      total += s.total;170      dataShards += s.dataShards;171      scanned += s.chunks.size;172    }173    return {174      verifyCode: this.verifyCode,175      stripeCount: this.stripeCount,176      total,177      dataShards,178      scanned,179      allStripesObserved: this.stripes.size === this.stripeCount,180    };181  }182183  isReady(): boolean {184    if (this.verifyCode === null || this.stripeCount === null) return false;185    if (this.stripes.size < this.stripeCount) return false;186    for (let i = 0; i < this.stripeCount; i++) {187      const s = this.stripes.get(i);188      if (!s || s.chunks.size < s.dataShards) return false;189    }190    return true;191  }192193  entries(): ScannedChunk[] {194    const out: ScannedChunk[] = [];195    for (const s of this.stripes.values()) {196      for (const c of s.chunks.values()) out.push(c);197    }198    return out;199  }200201  /** Per-stripe chunk maps in stripe order, ready for `reconstructPayloadFromStripes`. */202  toStripeChunks(): StripeChunks[] {203    if (this.stripeCount === null) return [];204    const out: StripeChunks[] = [];205    for (let i = 0; i < this.stripeCount; i++) {206      const s = this.stripes.get(i);207      if (!s) {208        throw new CodexError(209          "INSUFFICIENT_CHUNKS",210          `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,211        );212      }213      const chunks = new Map<number, Uint8Array>();214      for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);215      out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });216    }217    return out;218  }219}220221export type ParseRawScanResult =222  | ChunkEnvelope223  | { error: "parse_error" | "wrong_version" };224225/**226 * Defensively turn a raw scanned string into a `ChunkEnvelope`. The scanner227 * fires on every successful decode — most decodes are noise (other QR codes,228 * stickers, old Codex prints) — so the caller must not assume any structure.229 *230 * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2231 * envelopes are returned as-is — downstream stripe handling treats missing232 * `s` / `S` as `0` / `1` respectively.233 */234export function parseRawScan(raw: string): ParseRawScanResult {235  let parsed: unknown;236  try {237    parsed = JSON.parse(raw);238  } catch {239    return { error: "parse_error" };240  }241242  if (typeof parsed !== "object" || parsed === null) {243    return { error: "parse_error" };244  }245246  const obj = parsed as Record<string, unknown>;247248  if (obj.v !== 2 && obj.v !== 3) {249    if ("v" in obj) return { error: "wrong_version" };250    return { error: "parse_error" };251  }252253  const looksLikeEnvelope =254    typeof obj.c === "string" &&255    typeof obj.i === "number" &&256    typeof obj.t === "number" &&257    typeof obj.p === "boolean" &&258    typeof obj.d === "string" &&259    typeof obj.h === "string";260261  if (!looksLikeEnvelope) {262    return { error: "parse_error" };263  }264265  // v3-only fields must be shaped correctly when present.266  if (obj.v === 3) {267    if (typeof obj.s !== "number" || typeof obj.S !== "number") {268      return { error: "parse_error" };269    }270  }271272  return obj as unknown as ChunkEnvelope;273}274275/**276 * Reconstruct the payload from a fully-loaded buffer and verify the vault277 * fingerprint round-trips. Throws `CodexError` on any inconsistency —278 * callers translate that into a visible "scan again" message.279 */280export async function reconstructAndVerify(281  buffer: ChunkBuffer,282): Promise<CodexPayload> {283  const ctx = buffer.getContext();284  if (ctx === null) {285    throw new CodexError(286      "INSUFFICIENT_CHUNKS",287      "No chunks have been scanned yet.",288    );289  }290291  const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());292293  // Re-derive the fingerprint exactly as the generator did. Mismatch here294  // means the reconstructed `vaultBlob` is corrupt even though every chunk295  // passed its hash — most likely a Reed-Solomon decode pulled the wrong296  // bytes into a parity-reconstructed slot. `sha256Hex` already returns297  // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-298  // suspenders against a future helpers.ts change.299  //300  // AUD-004: legacy Codexes printed before v2.50.2 carry a 16-char (64-bit)301  // fingerprint; current Codexes carry 32 chars (128 bits). Slice by the302  // stored length so both verify without an envelope-version bump.303  const fingerprintLen = payload.vaultFingerprint.length;304  const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))305    .slice(0, fingerprintLen)306    .toUpperCase();307  if (fingerprint !== payload.vaultFingerprint) {308    throw new CodexError(309      "RECONSTRUCTION_FINGERPRINT_MISMATCH",310      "Reconstructed vault fingerprint does not match the printed Codex. " +311        "Try scanning the Codex again from the beginning.",312    );313  }314315  return payload;316}