69197437712d

fix: expire incomplete sync frames (PB-26-705, v5.2.8)

time
2026-09-07 01:01 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
red team remediation
parent
ebd357713cd6
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-kk-transport.test.ts+31 −0src/shared/sync-transport.ts+26 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.7",4+  "version": "5.2.8",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.2.7",5+  "version": "5.2.8",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.2.7",3+  "version": "5.2.8",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/sync-kk-transport.test.ts+31 −0
@@ -466,3 +466,34 @@466466     await expect(session.receive()).rejects.toBeInstanceOf(SyncFrameTooLargeError);467467   });468468 });469+470+describe("PB-26-705 continuation deadline", () => {471+  it("aborts an incomplete continued frame at one fixed deadline", async () => {472+    vi.useFakeTimers();473+    let reads = 0;474+    const close = vi.fn();475+    const session = buildEncryptedSyncSession(476+      { close } as unknown as WebSocket,477+      {478+        encrypt: (bytes: Uint8Array) => bytes,479+        decrypt: () => new Uint8Array([1, 0]),480+        close: vi.fn(),481+      } as unknown as Parameters<typeof buildEncryptedSyncSession>[1],482+      "00".repeat(32),483+      () => {484+        reads += 1;485+        return reads === 1 ? Promise.resolve(new Uint8Array(1)) : new Promise(() => undefined);486+      },487+      () => undefined,488+      100,489+    );490+491+    const pending = session.receive();492+    const rejection = expect(pending).rejects.toThrow(/reassembly timeout/i);493+    await Promise.resolve();494+    await vi.advanceTimersByTimeAsync(100);495+    await rejection;496+    expect(close).toHaveBeenCalledWith(1008, "sync frame timeout");497+    vi.useRealTimers();498+  });499+});

view file

src/shared/sync-transport.ts+26 −2
@@ -61,6 +61,7 @@6161 const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;6262 const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;6363 export const RELAY_WAITER_KEEPALIVE_MS = 20_000;64+export const SYNC_REASSEMBLY_TIMEOUT_MS = 30_000;6465 6566 export interface SyncSession {6667   send(plaintext: Uint8Array): void;@@ -95,6 +96,13 @@9596   }9697 }9798 99+export class SyncFrameReassemblyTimeoutError extends Error {100+  constructor() {101+    super("sync frame reassembly timeout");102+    this.name = "SyncFrameReassemblyTimeoutError";103+  }104+}105+98106 export interface SyncSessionOptions {99107   connectTimeoutMs?: number;100108   handshakeTimeoutMs?: number;@@ -479,6 +487,7 @@479487   expectedRemotePubkeyHex: string,480488   receiveCiphertext: () => Promise<Uint8Array>,481489   beforeClose: () => void = () => undefined,490+  reassemblyTimeoutMs = SYNC_REASSEMBLY_TIMEOUT_MS,482491 ): SyncSession {483492   return {484493     async send(plaintext: Uint8Array) {@@ -526,12 +535,24 @@526535       // — the padded-frame length header comes inside the first chunk's527536       // payload, so we read it there to learn the total.528537       let buffered: Uint8Array | null = null;529-      while (true) {530-        const ct = await receiveCiphertext();538+      let deadlineTimer: ReturnType<typeof setTimeout> | undefined;539+      let deadline: Promise<never> | undefined;540+      try {541+        while (true) {542+          const ct = await (deadline543+            ? Promise.race([receiveCiphertext(), deadline])544+            : receiveCiphertext());531545         const pt = session.decrypt(ct);532546         if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {533547           // Strip the continuation byte and keep the payload.534548           buffered = pt.subarray(1);549+          deadline = new Promise<never>((_resolve, reject) => {550+            deadlineTimer = setTimeout(() => {551+              session.close();552+              try { ws.close(1008, "sync frame timeout"); } catch { /* ignore */ }553+              reject(new SyncFrameReassemblyTimeoutError());554+            }, reassemblyTimeoutMs);555+          });535556           continue;536557         }537558         if (buffered !== null) {@@ -567,6 +588,9 @@567588         }568589         // Single-chunk frame, exactly as before.569590         return unpadSyncFrame(pt);591+        }592+      } finally {593+        if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);570594       }571595     },572596     async close() {

view file