8411b9a63ec4
fix: Move duress off the entries frame (v4.6.4 frame-ceiling root cause)
- time
- 2026-07-30 20:37 UTC · 2 months ago
- machine
- koh2
- parent
- d1f501a07784
↓ peach-browser-8411b9a6.tar.gzatlas at this save
files changed2 files
src/shared/vault-adopt.test.ts+66 −0src/shared/vault-adopt.ts+44 −12src/shared/vault-adopt.test.ts+66 −0
@@ -664,4 +664,70 @@664664 expect(result.tombstones).toEqual(tombstones);665665 expect(result.duressSlot).toBe(duressJson);666666 });667+668+ // T-A34: structural proof of the duress-on-separate-frame wire change.669+ // Duress must leave the entries chunk so the entries frame stays under the670+ // 768 KiB ceiling, even when the combined payload would exceed it.671+ it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => {672+ const [source] = memoryPair();673+ const observed: Array<{ size: number; hasDuress: boolean; entriesKeys: number }> = [];674+ const probe: PairingTransport = {675+ async send(msg) {676+ const f = JSON.parse(new TextDecoder().decode(msg)) as Record<string, unknown>;677+ observed.push({678+ size: msg.length,679+ hasDuress: typeof f.duress_slot === "string",680+ entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,681+ });682+ return source.send(msg);683+ },684+ receive() { return source.receive(); },685+ };686+ const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };687+ const duressJson = "y".repeat(320 * 1024);688+ const CEILING = 768 * 1024;689+690+ await sendVaultSnapshot(probe, entries, { "dead-0": 1 }, duressJson);691+692+ expect(observed.length).toBe(2);693+ // Entries frame: no duress, one entries key, well under 768 KiB.694+ expect(observed[0].hasDuress).toBe(false);695+ expect(observed[0].entriesKeys).toBe(1);696+ expect(observed[0].size).toBeLessThan(CEILING);697+ // Duress frame: duress present, no entries, well under 768 KiB.698+ expect(observed[1].hasDuress).toBe(true);699+ expect(observed[1].entriesKeys).toBe(0);700+ expect(observed[1].size).toBeLessThan(CEILING);701+ // Combined raw exceeds the ceiling, which is the point of the split.702+ expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);703+ });704+705+ // T-A35: backward compat — a pre-v4.6.4 sender (duress on the entries706+ // final chunk) still decodes correctly. The receiver's per-chunk707+ // Object.assign (entries / tombstones) plus duress-slot capture tolerates708+ // both shapes.709+ it("T-A35: receiver accepts duress_slot on the entries final chunk (pre-v4.6.4 sender shape)", async () => {710+ const [source, receiver] = memoryPair();711+ const duressJson = JSON.stringify({712+ version: 1,713+ salt: "aa".repeat(16),714+ nonce: "bb".repeat(12),715+ ciphertext: "cc".repeat(32),716+ tag: "dd".repeat(16),717+ });718+ // Manually construct the legacy wire shape: a single chunk with both719+ // the entries, the tombstones, AND duress_slot on it.720+ await sendFrame(source, {721+ type: "ADOPT_SNAPSHOT",722+ chunk_index: 0,723+ chunk_count: 1,724+ entries: { "id-0": "ciphertext" },725+ tombstones: { "dead-0": 42 },726+ duress_slot: duressJson,727+ });728+ const result = await receiveVaultSnapshot(receiver);729+ expect(result.entries).toEqual({ "id-0": "ciphertext" });730+ expect(result.tombstones).toEqual({ "dead-0": 42 });731+ expect(result.duressSlot).toBe(duressJson);732+ });667733 });src/shared/vault-adopt.ts+44 −12
@@ -296,10 +296,25 @@296296 297297 /**298298 * Source side: chunk the vault snapshot and send it over the pairing channel.299- * Entries are split so each chunk's serialized entries map stays under300- * MAX_SNAPSHOT_CHUNK_BYTES; tombstones ride the final chunk. If the vault301- * needs more than MAX_SNAPSHOT_CHUNKS chunks, only the first that many are302- * sent and the fallback sync pipeline fills the remainder.299+ *300+ * Wire shape (v4.6.4+, duress on its own frame):301+ * - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries.302+ * Tombstones ride the last entries frame when no duress follows.303+ * - 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).308+ *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).313+ *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).303318 */304319 export async function sendVaultSnapshot(305320 channel: PairingTransport,@@ -326,20 +341,37 @@326341 }327342 chunks.push(current);328343 329- const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);330- for (let index = 0; index < chunkCount; index++) {331- const isFinal = index === chunkCount - 1;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;350+ const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);351+352+ for (let index = 0; index < entriesChunkCount; index++) {353+ const isFinalEntries = index === entriesChunkCount - 1;332354 const frame: AdoptSnapshot = {333355 type: "ADOPT_SNAPSHOT",334356 chunk_index: index,335- chunk_count: chunkCount,357+ chunk_count: totalChunks,336358 entries: chunks[index],337- tombstones: isFinal ? tombstones : {},359+ // Tombstones ride the LAST wire frame: when duress follows the entries,360+ // it goes on the duress frame; otherwise on the last entries chunk.361+ tombstones: isFinalEntries && !sendDuress ? tombstones : {},338362 };339- if (isFinal && duressSlot) {340- frame.duress_slot = duressSlot;341- }342363 await sendFrame(channel, frame);364+ }365+366+ if (sendDuress) {367+ await sendFrame(channel, {368+ type: "ADOPT_SNAPSHOT",369+ chunk_index: entriesChunkCount,370+ chunk_count: totalChunks,371+ entries: {},372+ tombstones,373+ duress_slot: duressSlot as string,374+ });343375 }344376 }345377