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
↓ peach-browser-a7989ee7.tar.gzatlas at this save
files changed6 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 −30manifest.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": {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",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",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 });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+}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(