c067510e1a14

fix: enforce MAX_FRAME_BYTES on the final chunk of sync frame reassembly (audit PB-26-602, v5.1.12)

time
2026-09-06 14:55 UTC · 1 month ago
machine
koh2
agent
openrouter/tencent/hy4-preview
session
PB-26-601..611 audit closeout
parent
cf2649c42e87
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-kk-transport.test.ts+35 −0src/shared/sync-transport.ts+8 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.1.11",4+  "version": "5.1.12",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.11",5+  "version": "5.1.12",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.11",3+  "version": "5.1.12",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/sync-kk-transport.test.ts+35 −0
@@ -1,6 +1,7 @@11 import { afterEach, describe, expect, it, vi } from "vitest";22 import { generateKeyPair } from "@stablelib/x25519";33 import { noiseKKResponder } from "./noise-js";4+import { SYNC_LIMITS } from "./sync-limits";45 import { padSyncFrame, unpadSyncFrame } from "./sync-padding";56 import {67   buildEncryptedSyncSession,@@ -429,5 +430,39 @@429430       })(),430431     ).rejects.toBeInstanceOf(SyncFrameTooLargeError);431432     expect(received).toBeLessThan(400);433+  });434+});435+436+/**437+ * PB-26-602 — the continuation loop bounds only what is already buffered, so438+ * the FINAL chunk could push the reassembled frame up to one Noise message439+ * (~64 KiB) past MAX_FRAME_BYTES. The bound has to cover the concatenation.440+ */441+describe("PB-26-602 final-chunk bound", () => {442+  it("rejects a final chunk that overshoots MAX_FRAME_BYTES", async () => {443+    const max = SYNC_LIMITS.MAX_FRAME_BYTES;444+    let calls = 0;445+    const noopSession = {446+      encrypt: (pt: Uint8Array) => pt,447+      decrypt: () => {448+        calls += 1;449+        if (calls === 1) {450+          // Continuation chunk filling the buffer to exactly the cap.451+          const pt = new Uint8Array(max + 1);452+          pt[0] = 0x01; // FRAME_CONTINUATION_MARKER453+          return pt;454+        }455+        return new Uint8Array(1);456+      },457+      close: () => undefined,458+    };459+    const session = buildEncryptedSyncSession(460+      {} as WebSocket,461+      noopSession as unknown as Parameters<typeof buildEncryptedSyncSession>[1],462+      "00".repeat(32),463+      async () => new Uint8Array(0),464+    );465+466+    await expect(session.receive()).rejects.toBeInstanceOf(SyncFrameTooLargeError);432467   });433468 });

view file

src/shared/sync-transport.ts+8 −2
@@ -505,8 +505,14 @@505505             }506506             continue;507507           }508-          // Final chunk. Concatenate without a continuation byte, then fall509-          // through to the unpadder.508+          // Final chunk. PB-26-602: the continuation loop only bounds what is509+          // buffered, so the last chunk could push the frame up to 64 KiB past510+          // the cap. Bound the concatenation, not just the buffer.511+          if (buffered.length + pt.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {512+            throw new SyncFrameTooLargeError(513+              `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,514+            );515+          }510516           const reassembled = new Uint8Array(buffered.length + pt.byteLength);511517           reassembled.set(buffered, 0);512518           reassembled.set(pt, buffered.length);

view file