8411b9a63ec4

fix: Move duress off the entries frame (v4.6.4 frame-ceiling root cause)

src/shared/vault-adopt.ts:sendVaultSnapshot now sends duress on its own ADOPT_SNAPSHOT frame (chunk_index = entriesChunkCount) after the entries chunks, instead of appending duress_slot to the entries final chunk. Tombstones migrate to the last wire frame: duress frame when duress follows, otherwise the entries final chunk. If the duress frame would push chunk_count past MAX_SNAPSHOT_CHUNKS the duress frame is dropped and sync fills duress later (graceful, never blocks adoption). Receiver unchanged. receiveVaultSnapshot reads duress_slot off any chunk and merges entries/tombstones per chunk, so both shapes decode: - new sender -> old receiver: duress-only frame on iteration N - old sender -> new receiver: duress on the entries final chunk - new sender -> new receiver: duress on its own frame src/shared/vault-adopt.test.ts: T-A33 (regression from d1f501a07784) now passes. Added T-A34 (structural: observed intermediate frames each stay under the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING; combined raw exceeds it) and T-A35 (manually constructed legacy-shape frame with duress on the entries final chunk decodes cleanly). docs/vault-adoption-design.md: three new subsections under Protocol: "Inline vault snapshot (ADOPT_SNAPSHOT)" wire shape + 768 KiB ceiling + 200-chunk cap; "Duress on its own frame (browser v4.6.4+ / Android v2.9.9+)" with the dual-path compat matrix; "Acknowledgement timing" ADOPT_SNAPSHOT_APPLIED waits for all chunks and the optional device manifest before the source reports inline-snapshot success. Gate: tsc --noEmit RC=0; vitest run 138 passed / 1 skipped (139 files) / 3668 tests passed / 40 todo / 0 failed (baseline 3665 + T-A33 + T-A34 + T-A35 = 3668, all green). pnpm run build RC=0 across Chrome Firefox and Mobile; dist manifests report 4.6.4. Coordinated wire change: peach-android v2.9.9 receiver work follows separately; the browser sender is backward-compat in both directions. No version bump (already 4.6.4 from the prior save; this completes the frame-ceiling item in the deferred brief).

time
2026-07-30 20:37 UTC · 2 months ago
machine
koh2
parent
d1f501a07784
2 files src/shared/vault-adopt.test.ts+66 −0src/shared/vault-adopt.ts+44 −12
src/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 });

view file

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 

view file