c7f4ca8b97d2

bound adoption snapshot with aggregate budget and sync fallback (PB-26-206, v5.0.62)

time
2026-09-03 10:56 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-206 adoption snapshot budget
parent
a7d691b51e23
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-bg.test.ts+59 −0src/shared/vault-adopt-snapshot.test.ts+155 −0src/shared/vault-adopt.test.ts+27 −24src/shared/vault-adopt.ts+151 −21
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.61",4+  "version": "5.0.62",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": "5.0.61",5+  "version": "5.0.62",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": "5.0.61",3+  "version": "5.0.62",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-bg.test.ts+59 −0
@@ -365,6 +365,65 @@365365     expect(mocks.triggerSync).not.toHaveBeenCalled();366366   });367367 368+  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {369+    // PB-26-206: an over-budget snapshot is never partially sent or silently370+    // truncated — the source completes adoption and the sync pipeline carries371+    // the entries instead.372+    const socket = socketWithFrames([373+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },374+      {375+        type: "ADOPT_REQUEST",376+        device_id: "request-only-label",377+        device_name: "June's phone",378+        device_type: "android",379+        snapshot_capable: true,380+      },381+      { type: "ADOPT_CONFIRM", confirmed: true },382+      {383+        type: "ADOPT_IDENTITY",384+        device_id: "android-receiver",385+        device_name: "June's phone",386+        device_type: "android",387+        long_term_public_key_hex: "d".repeat(64),388+      },389+      { type: "ADOPT_APPLIED" },390+      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is391+      // sent); the source must not hang on it — withTimeout resolves the wait392+      // via the sync fallback path below.393+      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },394+    ]);395+    mocks.openSocket.mockResolvedValue(socket);396+    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.397+    const envelope = "c".repeat(690_000);398+    const entries: Record<string, string> = {};399+    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;400+    mocks.createSyncAdapter.mockReturnValue({401+      buildEntryManifest: vi.fn(async () => Object.fromEntries(402+        Object.keys(entries).map((id) => [id, 1]),403+      )),404+      getEncryptedEntries: vi.fn(async () => entries),405+      buildTombstoneManifest: vi.fn(async () => ({})),406+      applyEntries: vi.fn(async () => undefined),407+      applyTombstones: vi.fn(async () => undefined),408+      buildOutgoingDeviceManifest: vi.fn(async () => null),409+      applyIncomingDeviceManifest: vi.fn(async () => undefined),410+    });411+412+    await startRelayAdoptionSource();413+    await waitForPush("ADOPTION_SOURCE_RESULT");414+415+    expect(mocks.pushes).toContainEqual({416+      type: "ADOPTION_SOURCE_RESULT",417+      payload: expect.objectContaining({ status: "complete" }),418+    });419+    const sentTypes = socket.send.mock.calls.map(([bytes]) =>420+      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,421+    );422+    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");423+    // Sync fallback ran so the entries still reach the receiver.424+    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });425+  });426+368427   it("fails closed when the popup rejects the SAS", async () => {369428     const socket = socketWithFrames([370429       {

view file

src/shared/vault-adopt-snapshot.test.ts+155 −0
@@ -1,6 +1,14 @@11 import { describe, expect, it } from "vitest";22 import {3+  MAX_ADOPT_SNAPSHOT_BYTES,4+  MAX_ADOPT_SNAPSHOT_ENTRIES,5+  MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES,6+  MAX_ADOPT_SNAPSHOT_ID_CHARS,7+  MAX_ADOPT_SNAPSHOT_TOMBSTONES,38   MAX_SNAPSHOT_CHUNKS,9+  SNAPSHOT_FRAME_WIRE_CEILING,10+  applyAdoptSnapshotFrame,11+  createAdoptSnapshotBudget,412   isAdoptSnapshot,513   receiveFrame,614   receiveVaultSnapshot,@@ -287,5 +295,152 @@287295         duress_slot: { version: 1 },288296       }),289297     ).toBe(false);298+  });299+});300+301+describe("adoption snapshot aggregate budget (PB-26-206)", () => {302+  // Cross-platform boundary vectors V1..V11. The same wire shapes and verdicts303+  // are asserted in Android's VaultAdoptSnapshotTest — both clients must agree304+  // on accept/reject for every vector. Vectors use the shared helper directly305+  // where a full 10 MiB wire transfer would only prove the same comparison.306+307+  it("V1: frame bytes at the aggregate limit pass, limit+1 rejects before merge", () => {308+    const atLimit = createAdoptSnapshotBudget();309+    atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;310+    applyAdoptSnapshotFrame(atLimit, { a: "x" }, {}, null, 100);311+    expect(atLimit.bytes).toBe(MAX_ADOPT_SNAPSHOT_BYTES);312+313+    const over = createAdoptSnapshotBudget();314+    over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;315+    expect(() => applyAdoptSnapshotFrame(over, { a: "x" }, {}, null, 101))316+      .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");317+    // Rejected before merge: no partial adopted state escapes.318+    expect(over.entries).toEqual({});319+  });320+321+  it("V2: 25,000 entries pass, the 25,001st rejects", () => {322+    const entries: Record<string, string> = {};323+    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_ENTRIES; i++) entries[`e${i}`] = "v";324+    const acc = createAdoptSnapshotBudget();325+    applyAdoptSnapshotFrame(acc, entries, {}, null, 100);326+    expect(acc.entryCount).toBe(MAX_ADOPT_SNAPSHOT_ENTRIES);327+    expect(() => applyAdoptSnapshotFrame(acc, { oneMore: "v" }, {}, null, 1))328+      .toThrow("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");329+    expect(acc.entries.oneMore).toBeUndefined();330+  });331+332+  it("V3: 25,000 tombstones pass, the 25,001st rejects", () => {333+    const tombstones: Record<string, number> = {};334+    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_TOMBSTONES; i++) tombstones[`dead-${i}`] = i + 1;335+    const acc = createAdoptSnapshotBudget();336+    applyAdoptSnapshotFrame(acc, {}, tombstones, null, 100);337+    expect(acc.tombstoneCount).toBe(MAX_ADOPT_SNAPSHOT_TOMBSTONES);338+    expect(() => applyAdoptSnapshotFrame(acc, {}, { oneMore: 1 }, null, 1))339+      .toThrow("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");340+    expect(acc.tombstones.oneMore).toBeUndefined();341+  });342+343+  it("V4: entry/tombstone IDs of 512 chars pass, 513 reject", () => {344+    const ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS);345+    const acc = createAdoptSnapshotBudget();346+    applyAdoptSnapshotFrame(acc, { [ok]: "v" }, { [ok]: 1 }, null, 10);347+    expect(acc.entries[ok]).toBe("v");348+349+    const tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1);350+    expect(() => applyAdoptSnapshotFrame(351+      createAdoptSnapshotBudget(), { [tooLong]: "v" }, {}, null, 10,352+    )).toThrow("ADOPT_SNAPSHOT_ID_TOO_LONG");353+    expect(() => applyAdoptSnapshotFrame(354+      createAdoptSnapshotBudget(), {}, { [tooLong]: 1 }, null, 10,355+    )).toThrow("ADOPT_SNAPSHOT_ID_TOO_LONG");356+  });357+358+  it("V5: envelope at the base64-overhead ceiling passes, +1 rejects", () => {359+    const atLimit = "c".repeat(Math.floor(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES));360+    const acc = createAdoptSnapshotBudget();361+    applyAdoptSnapshotFrame(acc, { e1: atLimit }, {}, null, 10);362+    expect(acc.entries.e1).toBe(atLimit);363+364+    const over = "c".repeat(Math.floor(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) + 1);365+    expect(() => applyAdoptSnapshotFrame(366+      createAdoptSnapshotBudget(), { e1: over }, {}, null, 10,367+    )).toThrow("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");368+  });369+370+  it("V6: duress slot at the wire-frame ceiling passes, +1 rejects", () => {371+    const acc = createAdoptSnapshotBudget();372+    applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10);373+    expect(acc.duressSlot).toHaveLength(SNAPSHOT_FRAME_WIRE_CEILING);374+375+    expect(() => applyAdoptSnapshotFrame(376+      createAdoptSnapshotBudget(), {}, {}, "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10,377+    )).toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");378+  });379+380+  it("V7: non-finite tombstone values reject", () => {381+    expect(() => applyAdoptSnapshotFrame(382+      createAdoptSnapshotBudget(), {}, { dead: Number.NaN }, null, 10,383+    )).toThrow("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");384+    expect(() => applyAdoptSnapshotFrame(385+      createAdoptSnapshotBudget(), {}, { dead: Number.POSITIVE_INFINITY }, null, 10,386+    )).toThrow("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");387+  });388+389+  it("V8: high-count/low-byte snapshot round-trips (25,000 tiny entries)", async () => {390+    const [source, receiver] = memoryTransportPair();391+    const entries: Record<string, string> = {};392+    for (let i = 0; i < MAX_ADOPT_SNAPSHOT_ENTRIES; i++) entries[`e${i}`] = "v";393+    const [result] = await Promise.all([394+      receiveVaultSnapshot(receiver),395+      sendVaultSnapshot(source, entries, {}),396+    ]);397+    expect(Object.keys(result.entries)).toHaveLength(MAX_ADOPT_SNAPSHOT_ENTRIES);398+  });399+400+  it("V9: low-count/high-byte stream over the aggregate rejects with no partial result", async () => {401+    // 16 envelopes of ~674 KiB each: every frame fits the 768 KiB wire402+    // ceiling, but the ~10.8 MiB total exceeds the 10 MiB aggregate.403+    const [source, receiver] = memoryTransportPair();404+    const envelope = "c".repeat(690_000);405+    const chunks = 16;406+    const send = (async () => {407+      for (let i = 0; i < chunks; i++) {408+        await sendFrame(source, {409+          type: "ADOPT_SNAPSHOT",410+          chunk_index: i,411+          chunk_count: chunks,412+          entries: { [`big-${i}`]: envelope },413+          tombstones: {},414+        });415+      }416+    })();417+    await expect(receiveVaultSnapshot(receiver)).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");418+    await send;419+  });420+421+  it("V10: over-budget sender preflights with zero frames sent (sync fallback, never truncation)", async () => {422+    const [source] = memoryTransportPair();423+    let frames = 0;424+    const countingSource: PairingTransport = {425+      send: (msg) => {426+        frames++;427+        return source.send(msg);428+      },429+      receive: () => source.receive(),430+    };431+    const envelope = "c".repeat(690_000);432+    const entries: Record<string, string> = {};433+    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;434+    await expect(sendVaultSnapshot(countingSource, entries, {}))435+      .rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");436+    expect(frames).toBe(0);437+  });438+439+  it("V11: duress bytes count against the aggregate budget", () => {440+    const acc = createAdoptSnapshotBudget();441+    acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100;442+    // A 200-byte duress slot does not fit the remaining 100-byte budget.443+    expect(() => applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(200), 200))444+      .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");290445   });291446 });

view file

src/shared/vault-adopt.test.ts+27 −24
@@ -738,7 +738,8 @@738738   // sender must fit everything within MAX_SNAPSHOT_CHUNKS = 200 (199 entries739739   // + 1 duress = 200 frames). Duress MUST arrive intact — sync does not740740   // carry duress, so the adopted device would permanently lack its duress741-  // vault otherwise.741+  // vault otherwise. Entries are 40 KiB so the ~8 MiB total also fits the742+  // PB-26-206 10 MiB aggregate budget.742743   it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => {743744     const [source, receiver] = memoryPair();744745     const duressJson = JSON.stringify({@@ -748,12 +749,12 @@748749       ciphertext: "cc".repeat(32),749750       tag: "dd".repeat(16),750751     });751-    const big = "x".repeat(450 * 1024);752+    const big = "x".repeat(40 * 1024);752753     const entries: Record<string, string> = {};753754     for (let i = 0; i < 199; i++) entries[`id-${i}`] = big;754-    // First sanity: 199 entries needs multiple chunks (each ~450 KiB entry755-    // takes its own chunk). Verify the chunking math is sane before asserting756-    // the duress-reservation behavior.755+    // First sanity: 199 entries needs multiple chunks (each ~40 KiB entry756+    // packs ~12 per 512 KiB chunk). Verify the chunking math is sane before757+    // asserting the duress-reservation behavior.757758     const [result] = await Promise.all([758759       receiveVaultSnapshot(receiver),759760       sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),@@ -763,13 +764,21 @@763764     expect(result.duressSlot).toBe(duressJson);764765   });765766 766-  // T-A37: cap boundary exceeded. With 200 entries + duress, the duress frame767-  // is reserved (entry cap = MAX_SNAPSHOT_CHUNKS - 1 = 199), so duress still768-  // arrives. Excess entries (one beyond 199) are deferred to sync. Critically:769-  // duress MUST NOT be silently dropped, otherwise the adopted device lacks770-  // its duress vault forever (sync cannot restore duress).771-  it("T-A37: 200 entries + duress does not silently drop duress at the chunk cap", async () => {772-    const [source, receiver] = memoryPair();767+  // T-A37: PB-26-206 supersedes the old chunk-cap truncation. An over-budget768+  // snapshot is never partially sent: the sender preflights with the same769+  // aggregate helper the receiver enforces and throws before the first frame,770+  // so the caller falls through to the regular sync pipeline instead of771+  // sending a truncated snapshot.772+  it("T-A37: over-budget snapshot preflights with zero frames sent (sync fallback, never truncation)", async () => {773+    const [source] = memoryPair();774+    let frames = 0;775+    const countingSource: PairingTransport = {776+      async send(msg) {777+        frames++;778+        return source.send(msg);779+      },780+      receive() { return source.receive(); },781+    };773782     const duressJson = JSON.stringify({774783       version: 1,775784       salt: "aa".repeat(16),@@ -777,20 +786,14 @@777786       ciphertext: "cc".repeat(32),778787       tag: "dd".repeat(16),779788     });780-    const big = "x".repeat(450 * 1024);789+    // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.790+    const big = "x".repeat(60 * 1024);781791     const entries: Record<string, string> = {};782792     for (let i = 0; i < 200; i++) entries[`id-${i}`] = big;783-    const [result] = await Promise.all([784-      receiveVaultSnapshot(receiver),785-      sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),786-    ]);787-    // Duress MUST arrive intact; capping drops the LAST entry from the inline788-    // transfer (sync picks it up later — sync carries entries/tombstones).789-    expect(result.duressSlot).toBe(duressJson);790-    // The inline cap = MAX_SNAPSHOT_CHUNKS - 1 = 199 entries, so 199 of 200791-    // entries are inline; the rest are deferred to sync.792-    expect(Object.keys(result.entries).length).toBe(199);793-    expect(result.tombstones).toEqual({ "dead-0": 1 });793+    await expect(794+      sendVaultSnapshot(countingSource, entries, { "dead-0": 1 }, duressJson),795+    ).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");796+    expect(frames).toBe(0);794797   });795798 796799   // T-A38: the single test that proves the *root-cause fix* for the frame

view file

src/shared/vault-adopt.ts+151 −21
@@ -1,3 +1,4 @@1+import { SYNC_LIMITS } from "./sync-limits";12 import { computeVaultId } from "./sync-identity";23 import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";34 import type { LocalIdentity, PairingTransport } from "./pairing-handshake";@@ -134,8 +135,100 @@134135 export const MAX_SNAPSHOT_CHUNKS = 200;135136 // Headroom added to the per-chunk entries budget when validating an encoded136137 // frame on the wire: JSON envelope plus the final chunk's tombstone map.137-const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;138+export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;138139 140+/**141+ * PB-26-206 aggregate budget shared by sender and receiver. The per-frame142+ * guard above bounds one frame; this bounds the whole transfer — encoded143+ * snapshot bytes (entries + tombstones + JSON framing + duress slot),144+ * entry/tombstone counts, ID lengths, and per-envelope size — independently145+ * of the 200-frame count, so many individually valid frames cannot OOM the146+ * receiver. Aligned with the 12 MiB pairing-inbox transport backstop147+ * (PB-26-205): an honest snapshot never approaches either ceiling.148+ */149+export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; // 10 MiB150+export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;151+export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;152+/** Matches the cross-client anti-abuse ID ceiling (Android SyncLimits.MAX_ID_LENGTH). */153+export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;154+/**155+ * Maximum encrypted entry envelope: SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES156+ * plus the existing base64 overhead allowance (mirrors sync-vault-adapter).157+ */158+export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =159+  (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;160+161+/**162+ * Running aggregate for one snapshot transfer. Counts are tracked alongside163+ * the maps (not derived via Object.keys per frame) so the bound stays O(n).164+ */165+export interface AdoptSnapshotBudget {166+  entries: Record<string, string>;167+  tombstones: Record<string, number>;168+  duressSlot: string | null;169+  /** Sum of encoded wire-frame bytes consumed so far. */170+  bytes: number;171+  entryCount: number;172+  tombstoneCount: number;173+}174+175+export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {176+  return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };177+}178+179+/**180+ * PB-26-206 shared preflight/merge helper used by BOTH the sender181+ * (preflight before the first frame goes out) and the receiver (per frame,182+ * after the byte-budget check, before JSON.parse results are merged).183+ *184+ * One bounded pass per attacker-controlled map: each key/value is validated185+ * and projected counts are enforced inside the same loop that merges, so the186+ * maps are never iterated count→size→merge repeatedly. Throws before merging187+ * the offending frame, leaving no partial adopted state.188+ */189+export function applyAdoptSnapshotFrame(190+  acc: AdoptSnapshotBudget,191+  entries: Record<string, string>,192+  tombstones: Record<string, number>,193+  duressSlot: string | null | undefined,194+  frameBytes: number,195+): void {196+  if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {197+    throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");198+  }199+  for (const [id, envelope] of Object.entries(entries)) {200+    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");201+    if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {202+      throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");203+    }204+    if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {205+      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");206+    }207+    acc.entryCount++;208+    acc.entries[id] = envelope;209+  }210+  for (const [id, ts] of Object.entries(tombstones)) {211+    if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");212+    if (typeof ts !== "number" || !Number.isFinite(ts)) {213+      throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");214+    }215+    if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {216+      throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");217+    }218+    acc.tombstoneCount++;219+    acc.tombstones[id] = ts;220+  }221+  if (typeof duressSlot === "string" && duressSlot.length > 0) {222+    // The duress slot rides inside wire frames already bounded by the frame223+    // ceiling, and its raw bytes count against the aggregate above.224+    if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {225+      throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");226+    }227+    acc.duressSlot = duressSlot;228+  }229+  acc.bytes += frameBytes;230+}231+139232 export interface AdoptionPairState {140233   rendezvousSecretHex: string;141234   relayUrl?: string;@@ -327,6 +420,39 @@327420   tombstones: Record<string, number>,328421   duressSlot: string | null = null,329422 ): Promise<void> {423+  const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);424+  // PB-26-206 preflight with the SAME helper the receiver enforces: nothing425+  // is sent until every frame fits the aggregate budget, so an over-budget426+  // snapshot is never partially sent or silently truncated. The caller falls427+  // through to the regular sync pipeline (deny-only: no wire-field change,428+  // invitation prefix unchanged).429+  const acc = createAdoptSnapshotBudget();430+  const encoded: Uint8Array[] = frames.map((frame) =>431+    new TextEncoder().encode(JSON.stringify(frame))432+  );433+  for (let index = 0; index < frames.length; index++) {434+    const frame = frames[index];435+    applyAdoptSnapshotFrame(436+      acc,437+      frame.entries,438+      frame.tombstones,439+      frame.duress_slot,440+      encoded[index].length,441+    );442+  }443+  for (const raw of encoded) await channel.send(raw);444+}445+446+/**447+ * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame448+ * reservation, and the oversized-duress hard stop. No I/O happens here, so449+ * the preflight above can reject before the first byte goes out.450+ */451+function buildAdoptSnapshotFrames(452+  entries: Record<string, string>,453+  tombstones: Record<string, number>,454+  duressSlot: string | null,455+): AdoptSnapshot[] {330456   const includeDuress = !!duressSlot;331457   // Reserve one frame for duress when configured so duress is never silently332458   // dropped at the cap; ordinary sync does NOT carry duress, so dropping a@@ -335,19 +461,19 @@335461 336462   const chunks: Array<Record<string, string>> = [];337463   let current: Record<string, string> = {};464+  let currentCount = 0;338465   let currentBytes = 2; // "{}"339466   for (const [id, envelope] of Object.entries(entries)) {340467     // +1 for the separating comma; an overestimate only shrinks chunks.341468     const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;342-    if (343-      Object.keys(current).length > 0 &&344-      currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES345-    ) {469+    if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {346470       chunks.push(current);347471       current = {};472+      currentCount = 0;348473       currentBytes = 2;349474     }350475     current[id] = envelope;476+    currentCount++;351477     currentBytes += entryBytes;352478   }353479   chunks.push(current);@@ -374,9 +500,10 @@374500     throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");375501   }376502 503+  const frames: AdoptSnapshot[] = [];377504   for (let index = 0; index < entriesChunkCount; index++) {378505     const isFinalEntries = index === entriesChunkCount - 1;379-    const frame: AdoptSnapshot = {506+    frames.push({380507       type: "ADOPT_SNAPSHOT",381508       chunk_index: index,382509       chunk_count: totalChunks,@@ -384,30 +511,35 @@384511       // Tombstones ride the LAST wire frame: when duress follows the entries,385512       // it goes on the duress frame; otherwise on the last entries chunk.386513       tombstones: isFinalEntries && !sendDuress ? tombstones : {},387-    };388-    await sendFrame(channel, frame);514+    });389515   }390516 391-  if (duressFrame) await sendFrame(channel, duressFrame);517+  if (duressFrame) frames.push(duressFrame);518+  return frames;392519 }393520 394521 /**395- * Receiver side: receive and reassemble every snapshot chunk. Validates the396- * encoded frame size before parsing, then enforces sequential 0-based397- * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and398- * structural validity of each frame. Throws on any validation failure so the399- * caller can fall back to the sync pipeline.522+ * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame523+ * bytes to the aggregate counter before decoding (rejecting before524+ * JSON.parse when the next frame exceeds the remaining budget), then enforces525+ * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a526+ * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of527+ * each frame. Merging goes through the shared aggregate helper: every528+ * key/value and projected count is validated in one bounded pass before529+ * anything lands in the accumulator, so a rejection leaves no partial530+ * adopted state and the caller can fall back to the sync pipeline.400531  */401532 export async function receiveVaultSnapshot(402533   channel: PairingTransport,403534 ): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {404-  const entries: Record<string, string> = {};405-  const tombstones: Record<string, number> = {};406-  let duressSlot: string | null = null;535+  const acc = createAdoptSnapshotBudget();407536   let chunkCount = -1;408537   let received = 0;409538   while (chunkCount === -1 || received < chunkCount) {410539     const raw = await channel.receive();540+    if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {541+      throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");542+    }411543     if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {412544       throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");413545     }@@ -424,12 +556,10 @@424556     if (frame.chunk_index !== received) {425557       throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");426558     }427-    Object.assign(entries, frame.entries);428-    Object.assign(tombstones, frame.tombstones);429-    if (frame.duress_slot) duressSlot = frame.duress_slot;559+    applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);430560     received++;431561   }432-  return { entries, tombstones, duressSlot };562+  return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };433563 }434564 435565 export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {

view file