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
↓ peach-browser-72295801.tar.gzatlas at this save
files changed7 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 −2manifest.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": {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",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",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 }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 () => {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);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 };