5b7714bd29dd

fix: enforce MAX_FRAME_BYTES during sync-frame chunk reassembly (PB-26-002) — receive fails with SyncFrameTooLargeError as soon as the buffered continuation stream exceeds the bound, regression test drives 400 continuation chunks; bump 5.0.1→5.0.2

time
2026-08-29 23:55 UTC · 1 month ago
machine
koh2
agent
glm-5.3-flash
session
pb-26 audit fixes
parent
aa05b62ef5a3
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-kk-transport.test.ts+44 −0src/shared/sync-transport.ts+12 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.1",4+  "version": "5.0.2",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.0.1",5+  "version": "5.0.2",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.0.1",3+  "version": "5.0.2",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/sync-kk-transport.test.ts+44 −0
@@ -3,9 +3,11 @@33 import { noiseKKResponder } from "./noise-js";44 import { padSyncFrame, unpadSyncFrame } from "./sync-padding";55 import {6+  buildEncryptedSyncSession,67   openRelaySyncResponderSession,78   openRelaySyncSession,89   openSyncSessionAtUrl,10+  SyncFrameTooLargeError,911   type SyncPeerIdentity,1012 } from "./sync-transport";1113 @@ -385,5 +387,47 @@385387     }386388     expect(joined.byteLength).toBe(padded.byteLength);387389     expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));390+  });391+});392+393+/**394+ * PB-26-002 — the MAX_FRAME_BYTES bound must be enforced during chunk395+ * reassembly, not only after receive() returns. A peer that completes the396+ * Noise KK handshake (a paired device, or a revoked device still holding its397+ * static key) must not be able to grow the reassembly buffer without bound398+ * by streaming continuation chunks.399+ */400+describe("PB-26-002 reassembly bound", () => {401+  it("fails the receive as soon as continuation chunks exceed MAX_FRAME_BYTES", async () => {402+    const noopSession = {403+      encrypt: (pt: Uint8Array) => pt,404+      decrypt: (_ct: Uint8Array) => {405+        // Every "decrypted" message is a continuation chunk of ~64 KiB.406+        const pt = new Uint8Array(65535);407+        pt[0] = 0x01; // FRAME_CONTINUATION_MARKER408+        return pt;409+      },410+      close: () => undefined,411+    };412+    const session = buildEncryptedSyncSession(413+      {} as WebSocket,414+      noopSession as Parameters<typeof buildEncryptedSyncSession>[1],415+      "00".repeat(32),416+      async () => new Uint8Array(0),417+    );418+419+    // ~64 KiB per chunk → the 24 MiB bound trips within 400 chunks, well420+    // before the ciphertext queue is exhausted.421+    let received = 0;422+    await expect(423+      (async () => {424+        for (;;) {425+          await session.receive();426+          received++;427+          if (received > 1000) throw new Error("bound never enforced");428+        }429+      })(),430+    ).rejects.toBeInstanceOf(SyncFrameTooLargeError);431+    expect(received).toBeLessThan(400);388432   });389433 });

view file

src/shared/sync-transport.ts+12 −1
@@ -425,7 +425,8 @@425425   );426426 }427427 428-function buildEncryptedSyncSession(428+// Exported for the PB-26-002 reassembly-bound regression test.429+export function buildEncryptedSyncSession(429430   ws: WebSocket,430431   session: NoiseNKSession,431432   expectedRemotePubkeyHex: string,@@ -492,6 +493,16 @@492493             merged.set(buffered, 0);493494             merged.set(pt.subarray(1), buffered.length);494495             buffered = merged;496+            // PB-26-002: the frame bound must hold DURING reassembly. The497+            // post-receive check in the coordinator runs only after the full498+            // frame is in memory, so a peer that completes the Noise KK499+            // handshake could otherwise stream continuation chunks without500+            // bound inside the session window.501+            if (buffered.length > SYNC_LIMITS.MAX_FRAME_BYTES) {502+              throw new SyncFrameTooLargeError(503+                `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,504+              );505+            }495506             continue;496507           }497508           // Final chunk. Concatenate without a continuation byte, then fall

view file