1077ccaa33eb
fix: Reserve frame for duress at chunk cap (v4.6.4 GPT-5.6-Sol HIGH re-review fix)
- time
- 2026-07-30 22:09 UTC · 2 months ago
- machine
- koh2
- parent
- 8411b9a63ec4
↓ peach-browser-1077ccaa.tar.gzatlas at this save
files changed2 files
src/shared/vault-adopt.test.ts+71 −9src/shared/vault-adopt.ts+48 −18src/shared/vault-adopt.test.ts+71 −9
@@ -634,19 +634,22 @@634634 expect(result!.snapshotCapable).toBe(false);635635 });636636 637- // T-A33: regression for the SNAPSHOT_FRAME_WIRE_CEILING (768 KiB) bug.638- // Today duress rides the final entries chunk, so an entry + duress whose639- // combined raw exceeds 768 KiB throws ADOPT_SNAPSHOT_CHUNK_TOO_LARGE on640- // receive. The v4.6.4 frame-ceiling fix separates duress from the entries641- // frame; this test proves that root-cause change rather than just a new642- // frame type. Currently fails until the fix lands.637+ // T-A33: post-fix regression for the SNAPSHOT_FRAME_WIRE_CEILING (768 KiB)638+ // bug. Before v4.6.4, duress rode the entries final chunk, so an entry +639+ // duress whose combined raw exceeded 768 KiB threw640+ // ADOPT_SNAPSHOT_CHUNK_TOO_LARGE on receive. The frame-ceiling fix moves641+ // duress onto its own frame after the entries chunks; this case asserts the642+ // root-cause fix rather than just the new wire path.643643 it("T-A33: >768 KiB snapshot (entry + duress) succeeds because duress no longer counts against the entries frame", async () => {644644 const [source, receiver] = memoryPair();645645 const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };646646 const tombstones: Record<string, number> = { "dead-0": 1 };647- // ~320 KiB sealed duress: 450 KiB entry + ~320 KiB duress + JSON envelope648- // and tombstone map crosses the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING when649- // duress rides the entries chunk.647+ // 320 KiB of ciphertext JSON ("cc".repeat(320 * 1024) is ~640 KiB of JSON648+ // text — the receiver treats duress_slot as opaque and concatenates it649+ // raw; ~640 KiB JSON pushes combined raw over 768 KiB once the entry and650+ // envelope are added). Pre-v4.6.4 this duress rode the entries final651+ // chunk and pushed that frame over the ceiling; post-fix the duress652+ // frame is sent on its own and stays under the ceiling.650653 const duressJson = JSON.stringify({651654 version: 1,652655 salt: "aa".repeat(16),@@ -729,5 +732,64 @@729732 expect(result.entries).toEqual({ "id-0": "ciphertext" });730733 expect(result.tombstones).toEqual({ "dead-0": 42 });731734 expect(result.duressSlot).toBe(duressJson);735+ });736+737+ // T-A36: cap boundary inclusive. With 199 entries and a duress slot, the738+ // sender must fit everything within MAX_SNAPSHOT_CHUNKS = 200 (199 entries739+ // + 1 duress = 200 frames). Duress MUST arrive intact — sync does not740+ // carry duress, so the adopted device would permanently lack its duress741+ // vault otherwise.742+ it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => {743+ const [source, receiver] = memoryPair();744+ const duressJson = JSON.stringify({745+ version: 1,746+ salt: "aa".repeat(16),747+ nonce: "bb".repeat(12),748+ ciphertext: "cc".repeat(32),749+ tag: "dd".repeat(16),750+ });751+ const big = "x".repeat(450 * 1024);752+ const entries: Record<string, string> = {};753+ 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.757+ const [result] = await Promise.all([758+ receiveVaultSnapshot(receiver),759+ sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),760+ ]);761+ expect(result.entries).toEqual(entries);762+ expect(result.tombstones).toEqual({ "dead-0": 1 });763+ expect(result.duressSlot).toBe(duressJson);764+ });765+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();773+ const duressJson = JSON.stringify({774+ version: 1,775+ salt: "aa".repeat(16),776+ nonce: "bb".repeat(12),777+ ciphertext: "cc".repeat(32),778+ tag: "dd".repeat(16),779+ });780+ const big = "x".repeat(450 * 1024);781+ const entries: Record<string, string> = {};782+ 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).toBeLessThan(200);793+ expect(result.tombstones).toEqual({ "dead-0": 1 });732794 });733795 });src/shared/vault-adopt.ts+48 −18
@@ -301,20 +301,32 @@301301 * - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.302302 * Tombstones ride the last entries frame when no duress follows.303303 * - If duressSlot is set, one extra frame after the entries carrying only304- * the duress slot (entries: {}, tombstones: tombstone map, duress_slot).305- * This moves duress off the entries frame so a sizeable sealed duress306- * vault can no longer push the entries frame raw bytes over307- * SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).304+ * the duress slot (entries: empty, tombstones: tombstone map,305+ * duress_slot: duressSlot). This moves duress off the entries frame so a306+ * sizeable sealed duress vault can no longer push the entries frame raw307+ * bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB).308308 *309- * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries final310- * chunk. Receivers tolerate both shapes because `receiveVaultSnapshot` reads311- * `duress_slot` off any chunk (a duress-only frame has empty312- * entries/tombstones which merge as a no-op).309+ * Frame budget (MAX_SNAPSHOT_CHUNKS = 200):310+ * - When duress is configured, ONE of the 200 frames is reserved for the311+ * duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries312+ * chunks; excess entries are deferred to the sync fallback pipeline (sync313+ * carries entries and tombstones, so this is a graceful truncation).314+ * This reservation is REQUIRED: ordinary sync does NOT carry duress315+ * (vault.test.ts:158-179 test-locks duress as non-syncing), so silently316+ * dropping a duress frame at the cap would leave the adopted device317+ * permanently without its duress vault. We prioritize duress delivery318+ * over completeness of entries (the latter is recoverable via sync).313319 *314- * If the vault needs more than MAX_SNAPSHOT_CHUNKS - 1 entries chunks (i.e.315- * the duress frame would push chunk_count over the 200-chunk ceiling), the316- * duress frame is dropped and the fallback sync pipeline fills the duress317- * slot later (graceful degradation, never blocks adoption).320+ * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING321+ * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the322+ * first duress frame is sent. This is a hard stop — the caller must split323+ * the duress payload out of band. We do not silently truncate duress; sync324+ * cannot restore it.325+ *326+ * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries327+ * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot`328+ * reads `duress_slot` off any chunk (a duress-only frame has empty329+ * entries/tombstones which merge as a no-op).318330 */319331 export async function sendVaultSnapshot(320332 channel: PairingTransport,@@ -322,6 +334,12 @@322334 tombstones: Record<string, number>,323335 duressSlot: string | null = null,324336 ): Promise<void> {337+ const includeDuress = !!duressSlot;338+ // Reserve one frame for duress when configured so duress is never silently339+ // dropped at the cap; ordinary sync does NOT carry duress, so dropping a340+ // duress frame would orphan the adopted device's duress vault.341+ const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;342+325343 const chunks: Array<Record<string, string>> = [];326344 let current: Record<string, string> = {};327345 let currentBytes = 2; // "{}"@@ -341,12 +359,8 @@341359 }342360 chunks.push(current);343361 344- const entriesChunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);345- const includeDuress = !!duressSlot;346- // ponytail: cap total chunks to MAX_SNAPSHOT_CHUNKS; if duress would push347- // us over, drop the duress frame and let sync fill the duress slot later.348- const duressFits = entriesChunkCount < MAX_SNAPSHOT_CHUNKS;349- const sendDuress = includeDuress && duressFits;362+ const entriesChunkCount = Math.min(chunks.length, entryCap);363+ const sendDuress = includeDuress;350364 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);351365 352366 for (let index = 0; index < entriesChunkCount; index++) {@@ -364,6 +378,22 @@364378 }365379 366380 if (sendDuress) {381+ // Pre-check: a single duress frame that exceeds the wire ceiling cannot be382+ // sent and would fail the receiver's size check. Throw loudly so the383+ // caller can split duress out of band instead of triggering a silent384+ // "adoption succeeded but duress vanished" path. Sync cannot recover385+ // duress — the spec is a hard stop.386+ const duressEnvelope = JSON.stringify({387+ type: "ADOPT_SNAPSHOT",388+ chunk_index: entriesChunkCount,389+ chunk_count: totalChunks,390+ entries: {},391+ tombstones,392+ duress_slot: duressSlot,393+ });394+ if (new TextEncoder().encode(duressEnvelope).length > SNAPSHOT_FRAME_WIRE_CEILING) {395+ throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");396+ }367397 await sendFrame(channel, {368398 type: "ADOPT_SNAPSHOT",369399 chunk_index: entriesChunkCount,