ba39b05bd7e8

pad sync frames to size buckets before encryption so the relay cannot read vault size or operation type off traffic shape (v4.1.9)

time
2026-07-26 09:07 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync campaign remaining items
parent
c62fe9f5c847
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/sync-kk-transport.test.ts+6 −3src/shared/sync-padding.test.ts+61 −0src/shared/sync-padding.ts+63 −0src/shared/sync-transport.ts+15 −7
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.8",4+  "version": "4.1.9",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.1.8",4+  "version": "4.1.9",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.1.8",3+  "version": "4.1.9",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/sync-kk-transport.test.ts+6 −3
@@ -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 { padSyncFrame, unpadSyncFrame } from "./sync-padding";45 import {56   openRelaySyncSession,67   openSyncSession,@@ -73,17 +74,19 @@7374           sendServer,7475           receiveClient,7576         );76-        const theirIdentity = decode(session.decrypt(await receiveClient()));77+        // E7: the browser pads every session frame, so this stand-in for the78+        // Android listener must unpad exactly as SyncPadding does.79+        const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));7780         if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {7881           throw new Error("initiator identity was not bound to KK key");7982         }80-        await sendServer(session.encrypt(encode({83+        await sendServer(session.encrypt(padSyncFrame(encode({8184           type: "PAIR_IDENTITY",8285           device_id: "phone",8386           device_name: "Pixel",8487           device_type: "android",8588           long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),86-        })));89+        }))));8790       } catch (error) {8891         reject(error);8992       }

view file

src/shared/sync-padding.test.ts+61 −0
@@ -0,0 +1,61 @@1+import { describe, expect, it } from "vitest";2+import {3+  padSyncFrame,4+  syncFrameBucket,5+  unpadSyncFrame,6+} from "./sync-padding";7+8+describe("sync frame padding (E7)", () => {9+  it("round-trips payloads of every shape, including empty", () => {10+    for (const size of [0, 1, 5, 251, 252, 253, 1000, 70_000]) {11+      const payload = Uint8Array.from({ length: size }, (_, i) => (i * 31) & 0xff);12+      expect(Array.from(unpadSyncFrame(padSyncFrame(payload)))).toEqual(13+        Array.from(payload),14+      );15+    }16+  });17+18+  it("collapses every small control frame to one indistinguishable size", () => {19+    // The point of the exercise: a relay must not be able to tell a HELLO from20+    // a REQUEST from a DONE by looking at the frame it is forwarding.21+    const hello = new TextEncoder().encode('{"type":"SYNC_HELLO","vaultId":"' + "a".repeat(64) + '"}');22+    const done = new TextEncoder().encode('{"type":"SYNC_DONE"}');23+    expect(padSyncFrame(hello).length).toBe(padSyncFrame(done).length);24+    expect(padSyncFrame(done).length).toBe(256);25+  });26+27+  it("pins the bucket ladder — cross-platform KAT, must match peach-android", () => {28+    const cases: Array<[number, number]> = [29+      [0, 256],30+      [252, 256],31+      [253, 512],32+      [1024, 2048],33+      [65_532, 65_536],34+      [65_533, 131_072],35+      [200_000, 262_144],36+    ];37+    for (const [payload, expected] of cases) {38+      expect(syncFrameBucket(4 + payload)).toBe(expected);39+      expect(padSyncFrame(new Uint8Array(payload)).length).toBe(expected);40+    }41+  });42+43+  it("keeps large-frame overhead bounded rather than doubling", () => {44+    // Doubling a 12 MiB frame would exceed the 24 MiB transport cap for no45+    // extra privacy, so buckets go linear above 64 KiB.46+    const twelveMiB = 12 * 1024 * 1024;47+    const padded = syncFrameBucket(twelveMiB);48+    expect(padded).toBeGreaterThanOrEqual(twelveMiB);49+    expect(padded - twelveMiB).toBeLessThan(64 * 1024);50+  });51+52+  it("rejects a frame whose declared length exceeds what it carries", () => {53+    const forged = new Uint8Array(256);54+    new DataView(forged.buffer).setUint32(0, 9999, false);55+    expect(() => unpadSyncFrame(forged)).toThrow(/more payload than it carries/);56+  });57+58+  it("rejects a frame shorter than its length header", () => {59+    expect(() => unpadSyncFrame(new Uint8Array(3))).toThrow(/length header/);60+  });61+});

view file

src/shared/sync-padding.ts+63 −0
@@ -0,0 +1,63 @@1+// E7: frame padding at the relay boundary.2+//3+// A relay forwards only Noise ciphertext it cannot read, so the one thing it4+// can still learn is traffic SHAPE — frame sizes and timing. Sizes alone5+// distinguish a SYNC_HELLO from a SYNC_ENTRIES carrying a whole vault, and6+// track how a vault grows over time. Padding to size buckets removes that7+// resolution.8+//9+// Wire format of a padded plaintext, BEFORE Noise encryption:10+//11+//     [4-byte big-endian payload length][payload][zero padding to bucket size]12+//13+// The length header is what makes unpadding unambiguous, so the padding may be14+// any bytes; zeros are used because they compress and cost nothing.15+//16+// COORDINATED WIRE CHANGE — peach-android's SyncPadding must bucket17+// identically. A mismatch is loud, not silent: the receiver reads a length18+// that does not fit its frame and rejects it.19+20+const HEADER_BYTES = 4;21+/** Below this, every frame looks the same — covers HELLO/REQUEST/DONE and any22+ *  other small control frame. */23+const MIN_BUCKET = 256;24+/** Above this, buckets grow linearly instead of doubling: doubling a 12 MiB25+ *  frame would blow past the 24 MiB cap for no extra privacy. */26+const LINEAR_BUCKET_STEP = 64 * 1024;27+28+/**29+ * Round a total frame size up to the next bucket: powers of two while small,30+ * then multiples of LINEAR_BUCKET_STEP. Exported for the cross-platform KAT.31+ */32+export function syncFrameBucket(totalBytes: number): number {33+  if (totalBytes <= MIN_BUCKET) return MIN_BUCKET;34+  if (totalBytes <= LINEAR_BUCKET_STEP) {35+    let bucket = MIN_BUCKET;36+    while (bucket < totalBytes) bucket *= 2;37+    return bucket;38+  }39+  return Math.ceil(totalBytes / LINEAR_BUCKET_STEP) * LINEAR_BUCKET_STEP;40+}41+42+export function padSyncFrame(payload: Uint8Array): Uint8Array {43+  const bucket = syncFrameBucket(HEADER_BYTES + payload.length);44+  const out = new Uint8Array(bucket);45+  new DataView(out.buffer).setUint32(0, payload.length, false);46+  out.set(payload, HEADER_BYTES);47+  return out;48+}49+50+export function unpadSyncFrame(padded: Uint8Array): Uint8Array {51+  if (padded.length < HEADER_BYTES) {52+    throw new Error("padded sync frame is shorter than its length header");53+  }54+  const length = new DataView(55+    padded.buffer,56+    padded.byteOffset,57+    padded.byteLength,58+  ).getUint32(0, false);59+  if (length > padded.length - HEADER_BYTES) {60+    throw new Error("padded sync frame declares more payload than it carries");61+  }62+  return padded.slice(HEADER_BYTES, HEADER_BYTES + length);63+}

view file

src/shared/sync-transport.ts+15 −7
@@ -23,15 +23,19 @@2323 2424 import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";2525 import { SYNC_LIMITS } from "./sync-limits";26+import { padSyncFrame, unpadSyncFrame } from "./sync-padding";2627 import { hexToPubkey } from "./sync-identity";2728 import { buildRendezvousUrl } from "./rendezvous";2829 import type { DeviceType } from "./sync-types";2930 3031 // E6 (session rekeying) is deliberately NOT wired, and this is a reality check3132 // against the spec rather than an omission. E6 targets "long-lived sync32-// sessions"; Peach has none. A session is one-shot — roughly ten frames each33-// way, a 30s coordinator timeout (SESSION_TIMEOUT_MS), then close — and every34-// reconnect runs a fresh Noise KK handshake with fresh ephemerals. That already33+// sessions"; the browser has none. Sessions opened here are one-shot — roughly34+// ten frames each way, a 30s coordinator timeout (SESSION_TIMEOUT_MS), then35+// sync-runner closes them — and every reconnect runs a fresh Noise KK handshake36+// with fresh ephemerals. (Android's listener does park a connection open after37+// a round so it can act as a relay hub for another native peer, but the browser38+// drives the lifetime of every session it opens, and closes it.) That already3539 // gives stronger post-compromise security than intra-session Rekey() would,3640 // because no two sessions share key material at all (pinned by the E6 test in3741 // noise-kk.test.ts).@@ -273,8 +277,10 @@273277   }274278 275279   try {276-    await send(session.encrypt(encodePairIdentity(ourIdentity)));277-    const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));280+    await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));281+    const theirFrame = JSON.parse(282+      new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),283+    );278284     if (theirFrame?.type !== "PAIR_IDENTITY") {279285       throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);280286     }@@ -291,7 +297,9 @@291297 292298   return {293299     send(plaintext: Uint8Array) {294-      const ciphertext = session.encrypt(plaintext);300+      // E7: bucket the size before encrypting, so the relay sees frame shapes301+      // that no longer distinguish one operation (or vault size) from another.302+      const ciphertext = session.encrypt(padSyncFrame(plaintext));295303       if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {296304         throw new SyncFrameTooLargeError(297305           `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,@@ -301,7 +309,7 @@301309     },302310     async receive() {303311       const ct = await receive();304-      return session.decrypt(ct);312+      return unpadSyncFrame(session.decrypt(ct));305313     },306314     async close() {307315       try { ws.close(); } catch { /* ignore */ }

view file