8e348373e1b9

fix: pad adoption snapshot frames to the sync bucket ladder (audit PB-26-604, v5.1.14)

time
2026-09-06 15:16 UTC · 1 month ago
machine
koh2
agent
openrouter/tencent/hy4-preview
session
PB-26-601..611 audit closeout
parent
22d6a1a0bb03
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-bg.test.ts+8 −1src/shared/vault-adopt-snapshot.test.ts+44 −4src/shared/vault-adopt.test.ts+33 −7src/shared/vault-adopt.ts+20 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.1.13",4+  "version": "5.1.14",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.1.13",5+  "version": "5.1.14",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.1.13",3+  "version": "5.1.14",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-bg.test.ts+8 −1
@@ -1,4 +1,5 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";2+import { padSyncFrame } from "../shared/sync-padding";23 34 const mocks = vi.hoisted(() => ({45   openSocket: vi.fn(),@@ -128,7 +129,13 @@128129   frames: unknown[],129130   options: { closeOnEmpty?: boolean } = {},130131 ) {131-  const queue = frames.map(encode);132+  const queue = frames.map((frame) => {133+    const encoded = encode(frame);134+    // PB-26-604: snapshot frames are bucket-padded before Noise.135+    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"136+      ? padSyncFrame(encoded)137+      : encoded;138+  });132139   return {133140     send: vi.fn(),134141     receive: vi.fn(async () => {

view file

src/shared/vault-adopt-snapshot.test.ts+44 −4
@@ -13,12 +13,17 @@1313   receiveFrame,1414   receiveVaultSnapshot,1515   requestAdoption,16-  sendFrame,1716   sendVaultSnapshot,1817   type AdoptionReceiverDeps,1918 } from "./vault-adopt";2019 import type { PairingTransport } from "./pairing-handshake";20+import { padSyncFrame } from "./sync-padding";2121 22+/** Snapshot frames are bucket-padded on the wire (PB-26-604). */23+function sendSnapshotFrame(channel: PairingTransport, frame: unknown): Promise<void> {24+  return channel.send(padSyncFrame(new TextEncoder().encode(JSON.stringify(frame))));25+}26+2227 function memoryTransportPair(): [PairingTransport, PairingTransport] {2328   const a: Uint8Array[] = [];2429   const b: Uint8Array[] = [];@@ -117,7 +122,7 @@117122 118123   it("rejects out-of-order chunks", async () => {119124     const [source, receiver] = memoryTransportPair();120-    const send = sendFrame(source, {125+    const send = sendSnapshotFrame(source, {121126       type: "ADOPT_SNAPSHOT",122127       chunk_index: 1,123128       chunk_count: 2,@@ -130,7 +135,7 @@130135 131136   it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {132137     const [source, receiver] = memoryTransportPair();133-    const send = sendFrame(source, {138+    const send = sendSnapshotFrame(source, {134139       type: "ADOPT_SNAPSHOT",135140       chunk_index: 0,136141       chunk_count: MAX_SNAPSHOT_CHUNKS + 1,@@ -405,7 +410,7 @@405410     const chunks = 16;406411     const send = (async () => {407412       for (let i = 0; i < chunks; i++) {408-        await sendFrame(source, {413+        await sendSnapshotFrame(source, {409414           type: "ADOPT_SNAPSHOT",410415           chunk_index: i,411416           chunk_count: chunks,@@ -442,5 +447,40 @@442447     // A 200-byte duress slot does not fit the remaining 100-byte budget.443448     expect(() => applyAdoptSnapshotFrame(acc, {}, {}, "d".repeat(200), 200))444449       .toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");450+  });451+});452+453+/**454+ * PB-26-604 — adoption snapshot frames were sent as raw JSON, so a relay455+ * observing Noise ciphertext saw plaintext_size + 16 per message and could456+ * single out the distinctive duress frame. They now ride the sync transport's457+ * bucket ladder.458+ */459+describe("PB-26-604 adoption snapshot padding", () => {460+  it("pads every frame to a bucket so similar logical frames look alike", async () => {461+    const [source] = memoryTransportPair();462+    let wireSizes: number[] = [];463+    const probe: PairingTransport = {464+      send: (msg) => {465+        wireSizes.push(msg.length);466+        return source.send(msg);467+      },468+      receive: () => source.receive(),469+    };470+471+    await sendVaultSnapshot(probe, { "id-0": "x".repeat(1000) }, {}, null);472+    const withoutDuress = wireSizes;473+474+    wireSizes = [];475+    await sendVaultSnapshot(probe, { "id-0": "x".repeat(1000) }, {}, "d".repeat(1000));476+    const withDuress = wireSizes;477+478+    // Every frame is bucket-padded: no wire size is the raw JSON length.479+    for (const size of [...withoutDuress, ...withDuress]) {480+      expect(size % 256).toBe(0);481+    }482+    // The duress frame lands in the same bucket as an entries frame of similar483+    // logical size, so the relay cannot tell which transfer carried duress.484+    expect(withDuress.at(-1)).toBe(withoutDuress.at(-1));445485   });446486 });

view file

src/shared/vault-adopt.test.ts+33 −7
@@ -1,4 +1,5 @@11 import { describe, expect, it, vi } from "vitest";2+import { padSyncFrame, syncFrameBucket, unpadSyncFrame } from "./sync-padding";23 import { computeVaultId } from "./sync-identity";34 import {45   grantAdoption,@@ -50,6 +51,12 @@5051   };5152 }5253 54+/** Snapshot frames are padded on the wire (PB-26-604), so hand-built ones55+ *  must go through padSyncFrame exactly as sendVaultSnapshot does. */56+function sendSnapshotFrame(channel: PairingTransport, frame: unknown): Promise<void> {57+  return channel.send(padSyncFrame(new TextEncoder().encode(JSON.stringify(frame))));58+}59+5360 describe("vault adoption sub-protocol", () => {5461   it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => {5562     const [receiverChannel, sourceChannel] = memoryPair();@@ -673,12 +680,19 @@673680   // 768 KiB ceiling, even when the combined payload would exceed it.674681   it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => {675682     const [source] = memoryPair();676-    const observed: Array<{ size: number; hasDuress: boolean; entriesKeys: number }> = [];683+    const observed: Array<{684+      size: number;685+      wireSize: number;686+      hasDuress: boolean;687+      entriesKeys: number;688+    }> = [];677689     const probe: PairingTransport = {678690       async send(msg) {679-        const f = JSON.parse(new TextDecoder().decode(msg)) as Record<string, unknown>;691+        const raw = unpadSyncFrame(msg);692+        const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;680693         observed.push({681-          size: msg.length,694+          size: raw.length,695+          wireSize: msg.length,682696           hasDuress: typeof f.duress_slot === "string",683697           entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,684698         });@@ -703,6 +717,11 @@703717     expect(observed[1].size).toBeLessThan(CEILING);704718     // Combined raw exceeds the ceiling, which is the point of the split.705719     expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);720+    // PB-26-604: both frames left the wire bucket-padded, so the relay sees721+    // neither exact plaintext length.722+    for (const frame of observed) {723+      expect(frame.wireSize).toBe(syncFrameBucket(frame.size + 4));724+    }706725   });707726 708727   // T-A35: backward compat — a pre-v4.6.4 sender (duress on the entries@@ -720,7 +739,7 @@720739     });721740     // Manually construct the legacy wire shape: a single chunk with both722741     // the entries, the tombstones, AND duress_slot on it.723-    await sendFrame(source, {742+    await sendSnapshotFrame(source, {724743       type: "ADOPT_SNAPSHOT",725744       chunk_index: 0,726745       chunk_count: 1,@@ -821,12 +840,19 @@821840 822841     // Probe the wire so we can assert the structural shape alongside the823842     // end-to-end success.824-    const observed: Array<{ size: number; hasDuress: boolean; entriesKeys: number }> = [];843+    const observed: Array<{844+      size: number;845+      wireSize: number;846+      hasDuress: boolean;847+      entriesKeys: number;848+    }> = [];825849     const probe: PairingTransport = {826850       async send(msg) {827-        const f = JSON.parse(new TextDecoder().decode(msg)) as Record<string, unknown>;851+        const raw = unpadSyncFrame(msg);852+        const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;828853         observed.push({829-          size: msg.length,854+          size: raw.length,855+          wireSize: msg.length,830856           hasDuress: typeof f.duress_slot === "string",831857           entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,832858         });

view file

src/shared/vault-adopt.ts+20 −2
@@ -1,3 +1,4 @@1+import { padSyncFrame, syncFrameBucket, unpadSyncFrame } from "./sync-padding";12 import { capDeviceName, SYNC_LIMITS } from "./sync-limits";23 import { computeVaultId } from "./sync-identity";34 import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";@@ -136,6 +137,14 @@136137 // Headroom added to the per-chunk entries budget when validating an encoded137138 // frame on the wire: JSON envelope plus the final chunk's tombstone map.138139 export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;140+/**141+ * PB-26-604: snapshot frames are padded to the sync bucket ladder before142+ * Noise, so the wire bytes are larger than the JSON they carry. `+4` is143+ * padSyncFrame's length header; the bucket ladder supplies the rest.144+ */145+export const MAX_PADDED_SNAPSHOT_FRAME_BYTES = syncFrameBucket(146+  SNAPSHOT_FRAME_WIRE_CEILING + 4,147+);139148 140149 /**141150  * PB-26-206 aggregate budget shared by sender and receiver. The per-frame@@ -440,7 +449,10 @@440449       encoded[index].length,441450     );442451   }443-  for (const raw of encoded) await channel.send(raw);452+  // PB-26-604: pad to the sync bucket ladder so the relay sees bucket-sized453+  // ciphertext and cannot read exact plaintext lengths (or the duress frame's454+  // distinctive size) off the wire.455+  for (const raw of encoded) await channel.send(padSyncFrame(raw));444456 }445457 446458 /**@@ -536,7 +548,13 @@536548   let chunkCount = -1;537549   let received = 0;538550   while (chunkCount === -1 || received < chunkCount) {539-    const raw = await channel.receive();551+    const wire = await channel.receive();552+    if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {553+      throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");554+    }555+    // PB-26-604: every budget below applies to the unpadded JSON, exactly as556+    // the sender's preflight accounted for it.557+    const raw = unpadSyncFrame(wire);540558     if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {541559       throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");542560     }

view file