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
↓ peach-browser-8e348373.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+8 −1src/shared/vault-adopt-snapshot.test.ts+44 −4src/shared/vault-adopt.test.ts+33 −7src/shared/vault-adopt.ts+20 −2manifest.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": {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",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",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 () => {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 });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 });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 }