7229580119fb

feat: require pairing key proof (v4.0.42)

time
2026-07-26 03:12 UTC · 3 months ago
machine
koh2
agent
codex
session
peach relay sync implementation
parent
4d35c2a90536
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/code-pairing-bg.ts+12 −3src/background/code-pairing.test.ts+52 −2src/background/code-pairing.ts+32 −6src/shared/pairing-dial.ts+24 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.41",4+  "version": "4.0.42",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.0.41",4+  "version": "4.0.42",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.0.41",3+  "version": "4.0.42",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/code-pairing-bg.ts+12 −3
@@ -18,7 +18,7 @@1818 import { openPairingSocket } from "../shared/pairing-dial";1919 import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";2020 import { buildLocalIdentity } from "./device-identity";21-import { computeVaultId } from "../shared/sync-identity";21+import { computeVaultId, deriveSyncIdentityKeypair } from "../shared/sync-identity";2222 import { isUnlocked, getRootKeyHex } from "./vault";2323 import type { LocalIdentity } from "../shared/pairing-handshake";2424 import { getRelaySettings } from "./relay-settings";@@ -48,7 +48,11 @@4848     async getRelayUrl() {4949       return (await getRelaySettings()).relayUrl;5050     },51-    async getLocalIdentity(): Promise<LocalIdentity> {51+    async getLocalIdentity(): Promise<{52+      identity: LocalIdentity;53+      privateKey: Uint8Array;54+      publicKey: Uint8Array;55+    }> {5256       if (!isUnlocked()) throw new Error("vault_locked");5357       const masterKeyHex = getRootKeyHex();5458       if (!masterKeyHex) throw new Error("vault_locked");@@ -58,7 +62,12 @@5862       try {5963         const base = await buildLocalIdentity(masterKey);6064         const vaultId = await computeVaultId(masterKey);61-        return { ...base, vaultId };65+        const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);66+        return {67+          identity: { ...base, vaultId },68+          privateKey: keys.secretKey,69+          publicKey: keys.publicKey,70+        };6271       } finally {6372         masterKey.fill(0);6473       }

view file

src/background/code-pairing.test.ts+52 −2
@@ -26,7 +26,9 @@2626 const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));2727 2828 const PEER_EPHEMERAL_HEX = "a".repeat(64);29-const PHONE_LONGTERM_HEX = "f".repeat(64);29+// The ACK key is the Noise NK responder static, so the encrypted identity must30+// repeat exactly this key after the handshake.31+const PHONE_LONGTERM_HEX = PEER_EPHEMERAL_HEX;3032 const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);3133 const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);3234 @@ -38,6 +40,8 @@3840   signingPublicKeyHex: "d".repeat(64),3941   vaultId: "e".repeat(64),4042 };43+const OUR_PRIVATE_KEY = new Uint8Array(32).fill(0x0c);44+const OUR_PUBLIC_KEY = new Uint8Array(32).fill(0xcc);4145 4246 /**4347  * Scripted fake socket. Captures everything sent, and replays a queued list@@ -88,6 +92,7 @@8892       decrypt: (ct: Uint8Array) => ct,8993       handshakeHash: FAKE_HANDSHAKE_HASH,9094     })),95+    performInitiatorAuth: vi.fn(async () => undefined),9196   };9297 });9398 @@ -116,7 +121,11 @@116121       sockets.push(s);117122       return s.sock;118123     }),119-    getLocalIdentity: vi.fn(async () => OUR_IDENTITY),124+    getLocalIdentity: vi.fn(async () => ({125+      identity: OUR_IDENTITY,126+      privateKey: OUR_PRIVATE_KEY,127+      publicKey: OUR_PUBLIC_KEY,128+    })),120129     getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),121130     persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),122131     ...overrides,@@ -213,6 +222,47 @@213222     expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");214223     expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);215224     expect(completed).toHaveLength(1);225+  });226+227+  it("proves possession before accepting the peer identity", async () => {228+    const { performInitiatorAuth } = await import("../shared/noise-js");229+    const { deps } = makeDeps();230+    const { cb, errors } = makeCallbacks();231+232+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);233+234+    expect(errors).toHaveLength(0);235+    expect(performInitiatorAuth).toHaveBeenCalledTimes(1);236+    expect(performInitiatorAuth).toHaveBeenCalledWith(237+      expect.anything(),238+      OUR_PRIVATE_KEY,239+      OUR_PUBLIC_KEY,240+      expect.any(Uint8Array),241+      expect.any(Function),242+      expect.any(Function),243+    );244+  });245+246+  it("rejects a PAIR_IDENTITY that is not bound to the Noise responder key", async () => {247+    const { deps, persisted } = makeDeps({248+      openSocket: vi.fn(async () => makeFakeSocket([249+        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),250+        enc({251+          type: "PAIR_IDENTITY",252+          device_id: "attacker",253+          device_name: "Wrong key",254+          device_type: "android",255+          long_term_public_key_hex: "f".repeat(64),256+          ws_port: 49142,257+        }),258+      ]).sock),259+    });260+    const { cb, errors } = makeCallbacks();261+262+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);263+264+    expect(errors).toEqual(["handshake_failed"]);265+    expect(persisted).toHaveLength(0);216266   });217267 218268   it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {

view file

src/background/code-pairing.ts+32 −6
@@ -16,7 +16,7 @@1616 import { dialPairingChannel, PAIRING_PORT, type PairingDialDeps } from "../shared/pairing-dial";1717 import type { LocalIdentity } from "../shared/pairing-handshake";1818 import type { DeviceType, PairedDevice } from "../shared/sync-types";19-import { normalizeRelayUrl } from "../shared/rendezvous";19+import { bytesToHex, normalizeRelayUrl } from "../shared/rendezvous";2020 2121 /** Default sync port a peer announces; used when ws_port is absent. Same2222  *  fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */@@ -30,8 +30,15 @@3030   | "vault_locked";     // no master key available3131 3232 export interface CodePairingDeps extends PairingDialDeps {33-  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */34-  getLocalIdentity(): Promise<LocalIdentity>;33+  /**34+   * Public identity plus the private key used for structural35+   * proof-of-possession. The private half never enters a wire frame.36+   */37+  getLocalIdentity(): Promise<{38+    identity: LocalIdentity;39+    privateKey: Uint8Array;40+    publicKey: Uint8Array;41+  }>;3542   /** Current mesh relay URL; sent in the encrypted pairing identity. */3643   getRelayUrl(): Promise<string>;3744   /** Persist on user confirm. */@@ -103,9 +110,9 @@103110   deps: CodePairingDeps,104111   confirm: (sas: string) => Promise<boolean>,105112 ): Promise<void> {106-  let identity: LocalIdentity;113+  let local: Awaited<ReturnType<CodePairingDeps["getLocalIdentity"]>>;107114   try {108-    identity = await deps.getLocalIdentity();115+    local = await deps.getLocalIdentity();109116   } catch {110117     cb.onError("vault_locked");111118     return;@@ -116,9 +123,25 @@116123     cb.onError(dial.error);117124     return;118125   }119-  const { channel, sas, host, rendezvousSecretHex, socket } = dial.dialled;126+  const {127+    channel,128+    sas,129+    host,130+    rendezvousSecretHex,131+    remoteStaticPublicKey,132+    authenticateInitiator,133+    socket,134+  } = dial.dialled;120135 121136   try {137+    const { identity, privateKey, publicKey } = local;138+    if (bytesToHex(publicKey) !== identity.longTermPublicKeyHex) {139+      throw new Error("local pairing key does not match advertised identity");140+    }141+    // E1: prove the private half of the advertised initiator identity before142+    // either side trusts a PAIR_IDENTITY frame or asks the user for SAS consent.143+    await authenticateInitiator(privateKey, publicKey);144+122145     const relayUrl = await deps.getRelayUrl();123146     // Initiator (browser) sends its identity FIRST, then reads the peer's.124147     const ourFrame: PairingFrame = {@@ -135,6 +158,9 @@135158     await channel.send(encodeJson(ourFrame));136159 137160     const theirFrame = decodePairIdentity(await channel.receive());161+    if (theirFrame.long_term_public_key_hex !== bytesToHex(remoteStaticPublicKey)) {162+      throw new Error("peer identity does not match Noise responder key");163+    }138164 139165     cb.onSasReady(sas);140166     const confirmed = await confirm(sas);

view file

src/shared/pairing-dial.ts+24 −2
@@ -13,7 +13,7 @@1313 // Nothing here touches chrome, the vault, or a real WebSocket — the socket is1414 // injected — because vault adoption runs before a vault exists at all.1515 16-import { noiseNKInitiator } from "./noise-js";16+import { noiseNKInitiator, performInitiatorAuth } from "./noise-js";1717 import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";1818 import { parsePairingCode } from "./pairing-code";1919 import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";@@ -52,6 +52,17 @@5252   host: string;5353   /** Pair-only reconnect capability derived independently from the SAS. */5454   rendezvousSecretHex: string;55+  /** Static key authenticated by Noise NK. PAIR_IDENTITY must repeat this key. */56+  remoteStaticPublicKey: Uint8Array;57+  /**58+   * Prove possession of the initiator key before any identity or consent frame.59+   * Device pairing must call this exactly once; adoption cannot use it until it60+   * has a long-term key and remains a separately gated protocol.61+   */62+  authenticateInitiator(63+    privateKey: Uint8Array,64+    publicKey: Uint8Array,65+  ): Promise<void>;5566   socket: PairingSocket;5667 }5768 @@ -112,8 +123,9 @@112123       return { ok: false, error: "invalid_code" };113124     }114125 126+    const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);115127     const session = await noiseNKInitiator(116-      hexToBytes32(ephemeralPublicKeyHex),128+      remoteStaticPublicKey,117129       async (msg) => sock.send(msg),118130       () => sock.receive(),119131     );@@ -130,6 +142,16 @@130142         sas: derivePairingSAS(session.handshakeHash),131143         host,132144         rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),145+        remoteStaticPublicKey,146+        authenticateInitiator: (privateKey, publicKey) =>147+          performInitiatorAuth(148+            session,149+            privateKey,150+            publicKey,151+            remoteStaticPublicKey,152+            channel.send,153+            channel.receive,154+          ),133155         socket: sock,134156       },135157     };

view file