a7989ee758fe

fix: Correct large sync frame chunking (v4.2.17)

time
2026-07-27 18:46 UTC · 3 months ago
machine
koh2
agent
codex-gpt-5
session
peach-sync-e2e
parent
5b989b3f66e2
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-padding.test.ts+22 −0src/shared/sync-padding.ts+31 −0src/shared/sync-transport.ts+2 −30
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.16",4+  "version": "4.2.17",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.16",4+  "version": "4.2.17",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.2.16",3+  "version": "4.2.17",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/sync-padding.test.ts+22 −0
@@ -5,6 +5,7 @@55   unpadSyncFrame,66   MAX_NOISE_PLAINTEXT_BYTES,77   FRAME_CONTINUATION_MARKER,8+  chunkPaddedSyncFrame,89 } from "./sync-padding";910 1011 describe("sync frame padding (E7)", () => {@@ -94,5 +95,26 @@9495     // (which begins with a 4-byte big-endian length, so byte 0 of a 3-byte9596     // payload is 0). 0x01 is unambiguous and matches the KAT on Android.9697     expect(FRAME_CONTINUATION_MARKER).toBe(0x01);98+  });99+100+  it("production chunking reserves marker space at the Noise boundary", () => {101+    const payload = Uint8Array.from(102+      { length: 32 * 1024 },103+      (_, index) => index & 0xff,104+    );105+    const padded = padSyncFrame(payload);106+    const chunks = chunkPaddedSyncFrame(padded);107+108+    expect(chunks).toHaveLength(2);109+    expect(chunks[0]![0]).toBe(FRAME_CONTINUATION_MARKER);110+    expect(chunks[0]!.byteLength).toBe(MAX_NOISE_PLAINTEXT_BYTES);111+    expect(chunks[1]![0]).not.toBe(FRAME_CONTINUATION_MARKER);112+113+    const reassembled = new Uint8Array(114+      chunks[0]!.byteLength - 1 + chunks[1]!.byteLength,115+    );116+    reassembled.set(chunks[0]!.subarray(1), 0);117+    reassembled.set(chunks[1]!, chunks[0]!.byteLength - 1);118+    expect(unpadSyncFrame(reassembled)).toEqual(payload);97119   });98120 });

view file

src/shared/sync-padding.ts+31 −0
@@ -84,3 +84,34 @@8484  */8585 export const FRAME_CONTINUATION_MARKER: number = 0x01;8686 87+/**88+ * Split one already-padded frame into Noise-sized plaintext chunks.89+ *90+ * Non-final chunks reserve one byte for the continuation marker. Keeping this91+ * math here, rather than open-coding it in the WebSocket transport, lets the92+ * production path and its boundary test share the exact implementation.93+ */94+export function chunkPaddedSyncFrame(padded: Uint8Array): Uint8Array[] {95+  if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) return [padded];96+97+  const chunks: Uint8Array[] = [];98+  for (let offset = 0; offset < padded.byteLength; ) {99+    const remaining = padded.byteLength - offset;100+    const isLast = remaining <= MAX_NOISE_PLAINTEXT_BYTES;101+    const payloadLength = isLast102+      ? remaining103+      : MAX_NOISE_PLAINTEXT_BYTES - 1;104+    const payload = padded.subarray(offset, offset + payloadLength);105+106+    if (isLast) {107+      chunks.push(payload);108+    } else {109+      const marked = new Uint8Array(payload.byteLength + 1);110+      marked[0] = FRAME_CONTINUATION_MARKER;111+      marked.set(payload, 1);112+      chunks.push(marked);113+    }114+    offset += payloadLength;115+  }116+  return chunks;117+}

view file

src/shared/sync-transport.ts+2 −30
@@ -28,6 +28,7 @@2828   unpadSyncFrame,2929   MAX_NOISE_PLAINTEXT_BYTES,3030   FRAME_CONTINUATION_MARKER,31+  chunkPaddedSyncFrame,3132 } from "./sync-padding";3233 import { hexToPubkey } from "./sync-identity";3334 import { buildRendezvousUrl } from "./rendezvous";@@ -325,28 +326,7 @@325326         ws.send(ct as Uint8Array<ArrayBuffer>);326327         return;327328       }328-      const total = padded.byteLength;329-      const payload = padded.subarray(0);330-      for (let offset = 0; offset < total; ) {331-        const remaining = total - offset;332-        // A non-last chunk is followed by a 1-byte continuation marker in333-        // its wire plaintext, so it has to leave room for that. Slicing at334-        // MAX_NOISE_PLAINTEXT_BYTES here produced 65520-byte plaintexts that335-        // the crypto core refused — slicing at MAX - 1 keeps the marker336-        // under the cap. The last chunk (no marker) and the only chunk of337-        // a single-chunk frame still go out at full size.338-        const isLast = remaining <= MAX_NOISE_PLAINTEXT_BYTES;339-        const chunkPlaintext = isLast340-          ? payload.subarray(offset, total)341-          : payload.subarray(offset, offset + MAX_NOISE_PLAINTEXT_BYTES);342-        // First chunk of a multi-chunk frame: the receiver already has the343-        // length header in the payload, so a continuation byte is enough to344-        // signal "more coming, don't try to use this yet". Last (and345-        // only) chunks do not carry the byte — the length header tells the346-        // receiver the frame is complete.347-        const plaintext = isLast348-          ? chunkPlaintext349-          : concatBytes(FRAME_CONTINUATION_MARKER, chunkPlaintext);329+      for (const plaintext of chunkPaddedSyncFrame(padded)) {350330         if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {351331           throw new Error(352332             `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,@@ -359,7 +339,6 @@359339           );360340         }361341         ws.send(ct as Uint8Array<ArrayBuffer>);362-        offset += chunkPlaintext.byteLength;363342       }364343     },365344     async receive() {@@ -401,13 +380,6 @@401380     },402381     remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),403382   };404-}405-406-function concatBytes(prefix: number, payload: Uint8Array): Uint8Array {407-  const out = new Uint8Array(1 + payload.byteLength);408-  out[0] = prefix;409-  out.set(payload, 1);410-  return out;411383 }412384 413385 function connectWebSocketUrl(

view file