80ab8c3ad841

feat: inline vault snapshot over pairing channel (v4.5.0)

After ADOPT_APPLIED, transfer the full vault snapshot (entries + tombstones + optional signed device manifest) over the same authenticated Noise NK pairing channel instead of closing it and waiting on a second sync round. - vault-adopt.ts: ADOPT_SNAPSHOT / ADOPT_SNAPSHOT_APPLIED / ADOPT_DEVICE_MANIFEST / ADOPT_DEVICE_MANIFEST_APPLIED frames + guards; snapshot_capable negotiation in ADOPT_REQUEST; sendVaultSnapshot/receiveVaultSnapshot chunking helpers with MAX_SNAPSHOT_CHUNK_BYTES (512 KiB) and MAX_SNAPSHOT_CHUNKS (200) guardrails. - relay-adoption-source-bg.ts: receiver applies the inline snapshot and marks lastSyncedAt so the popup transitions immediately; source sends it when the receiver advertises snapshot_capable. Any failure (older peer, timeout, corrupt chunk) falls back to the untouched sync pipeline. - Gated on snapshot_capable so older peers degrade cleanly; peach-android and the sync pipeline are unchanged. - Tests: vault-adopt-snapshot.test.ts (chunking, reassembly, validation, fallback negotiation) plus a faithful closeOnEmpty mock so the receiver fallback path is exercised. tsc clean; 3645 tests pass; Chrome/Firefox/Mobile builds pass.

time
2026-07-29 23:48 UTC · 2 months ago
machine
koh2
parent
7416b66af497
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+13 −4src/background/relay-adoption-source-bg.ts+113 −3src/shared/vault-adopt-snapshot.test.ts+148 −0src/shared/vault-adopt.ts+197 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.4.7",4+  "version": "4.5.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.4.7",5+  "version": "4.5.0",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.4.7",3+  "version": "4.5.0",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-source-bg.test.ts+13 −4
@@ -101,13 +101,22 @@101101 const encode = (value: unknown) =>102102   new TextEncoder().encode(JSON.stringify(value));103103 104-function socketWithFrames(frames: unknown[]) {104+function socketWithFrames(105+  frames: unknown[],106+  options: { closeOnEmpty?: boolean } = {},107+) {105108   const queue = frames.map(encode);106109   return {107110     send: vi.fn(),108111     receive: vi.fn(async () => {109112       const next = queue.shift();110-      if (!next) return new Promise<Uint8Array>(() => undefined);113+      if (!next) {114+        // A real peer closes the channel once it runs out of frames; model115+        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast116+        // and falls back to the sync pipeline instead of hanging on a timeout.117+        if (options.closeOnEmpty) throw new Error("socket_closed");118+        return new Promise<Uint8Array>(() => undefined);119+      }111120       return next;112121     }),113122     close: vi.fn(),@@ -505,7 +514,7 @@505514         },506515       },507516       { type: "ADOPT_COMPLETE" },508-    ]);517+    ], { closeOnEmpty: true });509518     mocks.openSocket.mockResolvedValue(socket);510519     mocks.createVault.mockResolvedValue({ vaultId });511520 @@ -571,7 +580,7 @@571580       { type: "ADOPT_COMPLETE" },572581     ];573582     mocks.createVault.mockResolvedValue({ vaultId });574-    mocks.openSocket.mockResolvedValue(socketWithFrames(frames));583+    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));575584 576585     // Replace the chrome.runtime mock for this one test. The receiver577586     // auto-accepts the SAS, so no decision needs to be simulated.

view file

src/background/relay-adoption-source-bg.ts+113 −3
@@ -14,9 +14,14 @@1414 } from "../shared/noise-js";1515 import {1616   grantAdoption,17+  isAdoptDeviceManifestFrame,1718   isAdoptRequest,19+  isAdoptSnapshotApplied,1820   receiveFrame,21+  receiveVaultSnapshot,1922   requestAdoption,23+  sendFrame,24+  sendVaultSnapshot,2025   type AdoptRequest,2126 } from "../shared/vault-adopt";2227 import {@@ -34,8 +39,9 @@3439 import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";3540 import { computeVaultId } from "../shared/sync-identity";3641 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";37-import { storageSet } from "../shared/storage";38-import { upsertPairedDevice } from "./paired-devices";42+import { storageRemove, storageSet } from "../shared/storage";43+import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";44+import { createSyncVaultAdapter } from "./sync-vault-adapter";3945 import { triggerSyncIfReachable } from "./sync-trigger";4046 4147 const SOURCE_WINDOW_MS = 5 * 60 * 1000;@@ -51,6 +57,23 @@5157   }5258 }5359 60+/** Race a promise against a wall-clock deadline; rejects with "timeout". */61+function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {62+  return new Promise<T>((resolve, reject) => {63+    const timer = setTimeout(() => reject(new Error("timeout")), ms);64+    promise.then(65+      (value) => {66+        clearTimeout(timer);67+        resolve(value);68+      },69+      (error) => {70+        clearTimeout(timer);71+        reject(error);72+      },73+    );74+  });75+}76+5477 let inFlight = false;5578 let activeSocket: PairingSocket | null = null;5679 // Outstanding source-side SAS confirmations, keyed by id. A map (vs the@@ -249,6 +272,40 @@249272       });250273       return;251274     }275+    // If the receiver advertised inline-snapshot support, send the vault now276+    // over the same authenticated channel. Best-effort: any failure leaves the277+    // receiver to fall back to the sync pipeline triggered below.278+    if (request.snapshot_capable === true) {279+      try {280+        const adapter = createSyncVaultAdapter();281+        const entryManifest = await adapter.buildEntryManifest();282+        const entryIds = Object.keys(entryManifest);283+        const entries =284+          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};285+        const tombstones = await adapter.buildTombstoneManifest();286+        await sendVaultSnapshot(channel, entries, tombstones);287+288+        const applied = await withTimeout(receiveFrame(channel), 15_000);289+        if (isAdoptSnapshotApplied(applied)) {290+          console.info(291+            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,292+          );293+        }294+295+        if (adapter.buildOutgoingDeviceManifest) {296+          const manifest = await adapter.buildOutgoingDeviceManifest();297+          if (manifest) {298+            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });299+            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);300+          }301+        }302+      } catch (snapshotError) {303+        console.info(304+          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",305+          snapshotError,306+        );307+      }308+    }252309     push("ADOPTION_SOURCE_RESULT", { status: "complete", device });253310     // The Android receiver starts its relay listener as soon as its source254311     // record is durable. Try once now and once after service startup settles so@@ -386,6 +443,51 @@386443       });387444       return;388445     }446+    // The pairing channel is still open and authenticated — try to pull the447+    // vault snapshot inline so the popup transitions immediately instead of448+    // waiting on a second sync round. Any failure (older source, timeout,449+    // corrupt chunk) falls through to the existing sync pipeline below.450+    let snapshotApplied = false;451+    try {452+      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 15_000);453+      const adapter = createSyncVaultAdapter();454+      await adapter.applyEntries(snapshot.entries);455+      await adapter.applyTombstones(snapshot.tombstones);456+      await sendFrame(channel, {457+        type: "ADOPT_SNAPSHOT_APPLIED",458+        entries_received: Object.keys(snapshot.entries).length,459+        tombstones_received: Object.keys(snapshot.tombstones).length,460+      });461+462+      // Optional device graph. Non-fatal and isolated so a manifest timeout or463+      // unsupported source cannot demote an otherwise-successful entry snapshot.464+      try {465+        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);466+        if (467+          isAdoptDeviceManifestFrame(manifestFrame) &&468+          adapter.applyIncomingDeviceManifest &&469+          typeof device.longTermPublicKeyHex === "string" &&470+          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)471+        ) {472+          await adapter.applyIncomingDeviceManifest(473+            manifestFrame.manifest,474+            hexToBytes(device.longTermPublicKeyHex),475+          );476+          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });477+        }478+      } catch {479+        // The graph arrives via incremental sync instead.480+      }481+482+      await markLastSyncedAt(device.deviceId, Date.now());483+      snapshotApplied = true;484+    } catch (snapshotError) {485+      console.info(486+        "[Peach] inline snapshot failed, falling back to sync pipeline:",487+        snapshotError,488+      );489+    }490+389491     // Adoption has committed identity and vault-key material, but entries have390492     // not necessarily crossed yet. Persist the exact source row before telling391493     // the popup pairing is complete so a popup close/reopen cannot reveal the@@ -396,7 +498,15 @@396498     );397499     push("ADOPTION_SOURCE_RESULT", { status: "complete", device });398500     createdVault = false;399-    void triggerSyncIfReachable({ reason: "manual", force: true });501+502+    if (snapshotApplied) {503+      // The snapshot populated the vault and marked the source synced, so the504+      // pending marker's job is done — clear it instead of making the popup wait.505+      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);506+    } else {507+      // Fallback: the existing sync pipeline fills the vault.508+      void triggerSyncIfReachable({ reason: "manual", force: true });509+    }400510   } catch (error) {401511     if (createdVault) {402512       try {

view file

src/shared/vault-adopt-snapshot.test.ts+148 −0
@@ -0,0 +1,148 @@1+import { describe, expect, it } from "vitest";2+import {3+  MAX_SNAPSHOT_CHUNKS,4+  isAdoptSnapshot,5+  receiveFrame,6+  receiveVaultSnapshot,7+  requestAdoption,8+  sendFrame,9+  sendVaultSnapshot,10+  type AdoptionReceiverDeps,11+} from "./vault-adopt";12+import { memoryTransportPair, type PairingTransport } from "./pairing-handshake";13+14+describe("inline vault snapshot", () => {15+  it("round-trips an empty vault (0 entries)", async () => {16+    const [source, receiver] = memoryTransportPair();17+    const [result] = await Promise.all([18+      receiveVaultSnapshot(receiver),19+      sendVaultSnapshot(source, {}, {}),20+    ]);21+    expect(result.entries).toEqual({});22+    expect(result.tombstones).toEqual({});23+  });24+25+  it("round-trips 50 entries + 5 tombstones in a single chunk", async () => {26+    const [source, receiver] = memoryTransportPair();27+    const entries: Record<string, string> = {};28+    for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`;29+    const tombstones: Record<string, number> = {};30+    for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i;31+    const [result] = await Promise.all([32+      receiveVaultSnapshot(receiver),33+      sendVaultSnapshot(source, entries, tombstones),34+    ]);35+    expect(result.entries).toEqual(entries);36+    expect(result.tombstones).toEqual(tombstones);37+  });38+39+  it("splits a large vault into 3+ chunks and reassembles it", async () => {40+    const [source, receiver] = memoryTransportPair();41+    let frames = 0;42+    const countingSource: PairingTransport = {43+      send: (msg) => {44+        frames++;45+        return source.send(msg);46+      },47+      receive: () => source.receive(),48+    };49+    const big = "x".repeat(200 * 1024); // 200 KiB per entry forces chunking50+    const entries: Record<string, string> = {};51+    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;52+    const tombstones: Record<string, number> = { "dead-0": 42 };53+    const [result] = await Promise.all([54+      receiveVaultSnapshot(receiver),55+      sendVaultSnapshot(countingSource, entries, tombstones),56+    ]);57+    expect(frames).toBeGreaterThanOrEqual(3);58+    expect(result.entries).toEqual(entries);59+    expect(result.tombstones).toEqual(tombstones);60+  });61+62+  it("rejects out-of-order chunks", async () => {63+    const [source, receiver] = memoryTransportPair();64+    const send = sendFrame(source, {65+      type: "ADOPT_SNAPSHOT",66+      chunk_index: 1,67+      chunk_count: 2,68+      entries: { a: "x" },69+      tombstones: {},70+    });71+    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);72+    await send;73+  });74+75+  it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {76+    const [source, receiver] = memoryTransportPair();77+    const send = sendFrame(source, {78+      type: "ADOPT_SNAPSHOT",79+      chunk_index: 0,80+      chunk_count: MAX_SNAPSHOT_CHUNKS + 1,81+      entries: {},82+      tombstones: {},83+    });84+    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);85+    await send;86+  });87+88+  it("validates ADOPT_SNAPSHOT frames", () => {89+    expect(90+      isAdoptSnapshot({91+        type: "ADOPT_SNAPSHOT",92+        chunk_index: 0,93+        chunk_count: 1,94+        entries: { a: "x" },95+        tombstones: {},96+      }),97+    ).toBe(true);98+    // Missing tombstones / chunk_index.99+    expect(100+      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),101+    ).toBe(false);102+    expect(103+      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),104+    ).toBe(false);105+    // Wrong type tag.106+    expect(107+      isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),108+    ).toBe(false);109+    // Invalid indices and value types.110+    expect(111+      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),112+    ).toBe(false);113+    expect(114+      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),115+    ).toBe(false);116+    expect(117+      isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),118+    ).toBe(false);119+    expect(isAdoptSnapshot(null)).toBe(false);120+  });121+122+  it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {123+    const [receiverChannel, sourceChannel] = memoryTransportPair();124+    const deps: AdoptionReceiverDeps = {125+      deviceName: "recv",126+      generateDeviceId: () => "recv-id",127+      async confirmSas() {128+        return true;129+      },130+      async createVaultFromRootKey() {131+        return { vaultId: "v" };132+      },133+      async deriveLocalIdentity() {134+        return {135+          deviceId: "recv-id",136+          deviceName: "recv",137+          deviceType: "extension",138+          longTermPublicKeyHex: "ab".repeat(32),139+        };140+      },141+      async upsertPairedDevice() {},142+      onError() {},143+    };144+    void requestAdoption(receiverChannel, deps);145+    const first = await receiveFrame(sourceChannel);146+    expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });147+  });148+});

view file

src/shared/vault-adopt.ts+197 −2
@@ -1,5 +1,5 @@11 import { computeVaultId } from "./sync-identity";2-import type { DeviceType, PairedDevice } from "./sync-types";2+import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";33 import type { LocalIdentity, PairingTransport } from "./pairing-handshake";44 55 export interface AdoptRequest {@@ -9,6 +9,12 @@99   device_type: DeviceType;1010   /** Signed Pro entitlement offered by the receiver, if it has one. */1111   license?: string;12+  /**13+   * Receiver advertises inline-snapshot support. A source that understands it14+   * sends the vault snapshot over this same channel after ADOPT_APPLIED;15+   * older sources ignore it and the receiver falls back to the sync pipeline.16+   */17+  snapshot_capable?: boolean;1218 }1319 1420 export interface AdoptGrant {@@ -61,6 +67,38 @@6167   reason: "ADOPT_PRO_REQUIRED";6268 }6369 70+/**71+ * One chunk of the inline vault snapshot sent by the source after72+ * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are73+ * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.74+ */75+export interface AdoptSnapshot {76+  type: "ADOPT_SNAPSHOT";77+  chunk_index: number;78+  chunk_count: number;79+  entries: Record<string, string>;80+  /** entryId → deletion timestamp; populated on the final chunk only. */81+  tombstones: Record<string, number>;82+}83+84+/** Receiver acknowledgement sent after all snapshot chunks are applied. */85+export interface AdoptSnapshotApplied {86+  type: "ADOPT_SNAPSHOT_APPLIED";87+  entries_received: number;88+  tombstones_received: number;89+}90+91+/** Optional signed device graph appended after the snapshot (manifest v3+). */92+export interface AdoptDeviceManifestFrame {93+  type: "ADOPT_DEVICE_MANIFEST";94+  manifest: SignedDeviceManifest;95+}96+97+/** Receiver acknowledgement for an applied device manifest. */98+export interface AdoptDeviceManifestApplied {99+  type: "ADOPT_DEVICE_MANIFEST_APPLIED";100+}101+64102 export type AdoptionFrame =65103   | AdoptRequest66104   | AdoptConfirm@@ -68,8 +106,24 @@68106   | AdoptIdentity69107   | AdoptComplete70108   | AdoptApplied71-  | AdoptReject;109+  | AdoptReject110+  | AdoptSnapshot111+  | AdoptSnapshotApplied112+  | AdoptDeviceManifestFrame113+  | AdoptDeviceManifestApplied;72114 115+/**116+ * Wire guardrails for the inline snapshot. A chunk's serialized entries map117+ * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at118+ * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver119+ * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).120+ */121+export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB122+export const MAX_SNAPSHOT_CHUNKS = 200;123+// Headroom added to the per-chunk entries budget when validating an encoded124+// frame on the wire: JSON envelope plus the final chunk's tombstone map.125+const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;126+73127 export interface AdoptionPairState {74128   rendezvousSecretHex: string;75129   relayUrl?: string;@@ -168,6 +222,146 @@168222     value.reason === "ADOPT_PRO_REQUIRED";169223 }170224 225+export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {226+  const value = frame as Partial<AdoptSnapshot> | null;227+  if (value?.type !== "ADOPT_SNAPSHOT") return false;228+  if (229+    typeof value.chunk_index !== "number" ||230+    !Number.isInteger(value.chunk_index) ||231+    value.chunk_index < 0232+  ) return false;233+  if (234+    typeof value.chunk_count !== "number" ||235+    !Number.isInteger(value.chunk_count) ||236+    value.chunk_count < 1237+  ) return false;238+  if (!isStringRecord(value.entries)) return false;239+  if (!isNumberRecord(value.tombstones)) return false;240+  return true;241+}242+243+export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {244+  const value = frame as Partial<AdoptSnapshotApplied> | null;245+  return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&246+    typeof value.entries_received === "number" &&247+    typeof value.tombstones_received === "number";248+}249+250+export function isAdoptDeviceManifestFrame(251+  frame: unknown,252+): frame is AdoptDeviceManifestFrame {253+  const value = frame as Partial<AdoptDeviceManifestFrame> | null;254+  const manifest = value?.manifest as255+    | { body?: unknown; signatureHex?: unknown }256+    | null;257+  return value?.type === "ADOPT_DEVICE_MANIFEST" &&258+    typeof manifest === "object" &&259+    manifest !== null &&260+    typeof manifest.body === "object" &&261+    manifest.body !== null &&262+    typeof manifest.signatureHex === "string";263+}264+265+export function isAdoptDeviceManifestApplied(266+  frame: unknown,267+): frame is AdoptDeviceManifestApplied {268+  return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===269+    "ADOPT_DEVICE_MANIFEST_APPLIED";270+}271+272+function isStringRecord(value: unknown): value is Record<string, string> {273+  if (typeof value !== "object" || value === null) return false;274+  return Object.values(value).every((v) => typeof v === "string");275+}276+277+function isNumberRecord(value: unknown): value is Record<string, number> {278+  if (typeof value !== "object" || value === null) return false;279+  return Object.values(value).every((v) => typeof v === "number");280+}281+282+/**283+ * Source side: chunk the vault snapshot and send it over the pairing channel.284+ * Entries are split so each chunk's serialized entries map stays under285+ * MAX_SNAPSHOT_CHUNK_BYTES; tombstones ride the final chunk. If the vault286+ * needs more than MAX_SNAPSHOT_CHUNKS chunks, only the first that many are287+ * sent and the fallback sync pipeline fills the remainder.288+ */289+export async function sendVaultSnapshot(290+  channel: PairingTransport,291+  entries: Record<string, string>,292+  tombstones: Record<string, number>,293+): Promise<void> {294+  const chunks: Array<Record<string, string>> = [];295+  let current: Record<string, string> = {};296+  let currentBytes = 2; // "{}"297+  for (const [id, envelope] of Object.entries(entries)) {298+    // +1 for the separating comma; an overestimate only shrinks chunks.299+    const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;300+    if (301+      Object.keys(current).length > 0 &&302+      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES303+    ) {304+      chunks.push(current);305+      current = {};306+      currentBytes = 2;307+    }308+    current[id] = envelope;309+    currentBytes += entryBytes;310+  }311+  chunks.push(current);312+313+  const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);314+  for (let index = 0; index < chunkCount; index++) {315+    const isFinal = index === chunkCount - 1;316+    await sendFrame(channel, {317+      type: "ADOPT_SNAPSHOT",318+      chunk_index: index,319+      chunk_count: chunkCount,320+      entries: chunks[index],321+      tombstones: isFinal ? tombstones : {},322+    });323+  }324+}325+326+/**327+ * Receiver side: receive and reassemble every snapshot chunk. Validates the328+ * encoded frame size before parsing, then enforces sequential 0-based329+ * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and330+ * structural validity of each frame. Throws on any validation failure so the331+ * caller can fall back to the sync pipeline.332+ */333+export async function receiveVaultSnapshot(334+  channel: PairingTransport,335+): Promise<{ entries: Record<string, string>; tombstones: Record<string, number> }> {336+  const entries: Record<string, string> = {};337+  const tombstones: Record<string, number> = {};338+  let chunkCount = -1;339+  let received = 0;340+  while (chunkCount === -1 || received < chunkCount) {341+    const raw = await channel.receive();342+    if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {343+      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");344+    }345+    const frame: unknown = JSON.parse(new TextDecoder().decode(raw));346+    if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");347+    if (chunkCount === -1) {348+      chunkCount = frame.chunk_count;349+      if (chunkCount > MAX_SNAPSHOT_CHUNKS) {350+        throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");351+      }352+    } else if (frame.chunk_count !== chunkCount) {353+      throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");354+    }355+    if (frame.chunk_index !== received) {356+      throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");357+    }358+    Object.assign(entries, frame.entries);359+    Object.assign(tombstones, frame.tombstones);360+    received++;361+  }362+  return { entries, tombstones };363+}364+171365 export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {172366   return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);173367 }@@ -183,6 +377,7 @@183377     device_name: deps.deviceName,184378     device_type: "extension",185379     license: receiverLicense,380+    snapshot_capable: true,186381   });187382 188383   const confirmed = await deps.confirmSas();

view file