c62fe9f5c847

pin that each reconnect derives fresh session keys, and record why intra-session rekeying buys nothing on a one-shot session (v4.1.8)

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

view file

src/shared/noise-kk.test.ts+45 −0
@@ -48,6 +48,51 @@4848     expect(new TextDecoder().decode(a.decrypt(b.encrypt(new TextEncoder().encode("two"))))).toBe("two");4949   });5050 51+  it("E6: every reconnect derives fresh keys, so one session never exposes another", async () => {52+    // Peach sync sessions are one-shot: ~10 frames, a 30s coordinator timeout,53+    // then close. There is no long-lived session to rekey inside, and each54+    // reconnect runs a full KK handshake with fresh ephemerals — which gives55+    // strictly stronger post-compromise security than intra-session Rekey()56+    // would. This pins that property so a future change cannot quietly reuse57+    // ephemerals and turn every past session's traffic into one key's problem.58+    const initiator = generateKeyPair();59+    const responder = generateKeyPair();60+61+    async function handshake() {62+      const wire = channels();63+      const [a, b] = await Promise.all([64+        noiseKKInitiator(65+          initiator.secretKey,66+          initiator.publicKey,67+          responder.publicKey,68+          wire.initiatorSend,69+          wire.initiatorReceive,70+        ),71+        noiseKKResponder(72+          responder.secretKey,73+          responder.publicKey,74+          initiator.publicKey,75+          wire.responderSend,76+          wire.responderReceive,77+        ),78+      ]);79+      return { a, b };80+    }81+82+    const first = await handshake();83+    const second = await handshake();84+85+    // Same long-term statics, different session — the transcripts must differ.86+    expect(bytesToHex(first.a.handshakeHash)).not.toBe(87+      bytesToHex(second.a.handshakeHash),88+    );89+90+    // And the keys must genuinely differ: a frame from session two must not be91+    // readable by session one.92+    const frame = second.a.encrypt(new TextEncoder().encode("session two"));93+    expect(() => first.b.decrypt(frame)).toThrow();94+  });95+5196   it("pins the cross-platform deterministic handshake hash", async () => {5297     const initiator = generateKeyPairFromSeed(new Uint8Array(32).fill(0x01));5398     const responder = generateKeyPairFromSeed(new Uint8Array(32).fill(0x02));

view file

src/shared/sync-transport.ts+15 −0
@@ -27,6 +27,21 @@2727 import { buildRendezvousUrl } from "./rendezvous";2828 import type { DeviceType } from "./sync-types";2929 30+// E6 (session rekeying) is deliberately NOT wired, and this is a reality check31+// 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 already35+// gives stronger post-compromise security than intra-session Rekey() would,36+// because no two sessions share key material at all (pinned by the E6 test in37+// noise-kk.test.ts).38+//39+// Wiring Rekey() anyway would mean both ends rekeying at the identical frame40+// index with no wire signal to resynchronise on. One frame of drift between41+// browser and Android breaks all sync — the exact failure class this protocol42+// has already been burned by twice. The primitives are exposed on NoiseSession43+// (rekeyOutgoing/rekeyIncoming) if a genuinely long-lived session ever appears;44+// until then the cost is real and the benefit is zero.3045 const DEFAULT_PROBE_TIMEOUT_MS = 500;3146 const DEFAULT_PROBE_BATCH = 16;3247 const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;

view file