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
↓ peach-browser-c7f4ca8b.tar.gzatlas at this save
files changed7 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 −21manifest.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": {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",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",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 {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 });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 framesrc/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 {