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
↓ peach-browser-5b7714bd.tar.gzatlas at this save
files changed5 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 −1manifest.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": {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",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",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 });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