9c0983a25210

feat: require mutual KK reconnect auth (v4.0.43)

time
2026-07-26 03:43 UTC · 3 months ago
machine
koh2
parent
7229580119fb
21 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/device-identity.ts+1 −1src/background/paired-devices.ts+2 −2src/background/sync-coordinator.ts+2 −3src/background/sync-runner.test.ts+43 −6src/background/sync-runner.ts+71 −25src/background/sync-vault-adapter.ts+2 −2src/background/vault.rotate-sync-identity.test.ts+1 −1src/background/vault.ts+1 −1src/shared/device-manifest.ts+3 −3src/shared/noise-js.ts+206 −2src/shared/noise-kk.test.ts+107 −0src/shared/pairing-handshake.ts+16 −40src/shared/sync-kk-transport.test.ts+127 −0src/shared/sync-limits.ts+1 −1src/shared/sync-transport.test.ts+14 −2src/shared/sync-transport.ts+71 −37src/shared/sync-types.ts+10 −12src/shared/sync.disconnect.test.ts+14 −3
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.42",4+  "version": "4.0.43",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.42",4+  "version": "4.0.43",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.42",3+  "version": "4.0.43",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/device-identity.ts+1 −1
@@ -73,7 +73,7 @@7373 7474 /**7575  * Build the public-facing identity record for the local extension. The76- * private key half (used only for ongoing Noise NK sessions) is not76+ * private key half (used only for ongoing Noise KK sessions) is not7777  * exposed by this function — Phase-1 pairing uses ephemeral keys only.7878  */7979 export async function buildLocalIdentity(

view file

src/background/paired-devices.ts+2 −2
@@ -17,7 +17,7 @@1717 // The registry has only ever lived in `chrome.storage.local` for released1818 // builds, so no cross-profile evacuation path is needed — the public keys1919 // never leave the device via a third-party sync back-end. Unpairing is20-// local-only; Noise NK rejection on the next connection is the security20+// local-only; Noise KK rejection on the next connection is the security2121 // boundary (see removePairedDeviceById).2222 2323 import type { PairedDevice } from "../shared/sync-types";@@ -84,7 +84,7 @@8484   const devices = await listPairedDevices();8585   const filtered = devices.filter((d) => d.deviceId !== deviceId);8686   // Unpairing is local-only — there is no remote unpair RPC. The remote87-  // device retains this device in its own registry. Noise NK rejection on87+  // device retains this device in its own registry. Noise KK rejection on8888   // the next connection attempt is the security boundary.8989   await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });9090   // Also clear the per-network IP cache for this device.

view file

src/background/sync-coordinator.ts+2 −3
@@ -1,8 +1,7 @@11 // Vault sync — Phase 2 manifest-diff coordinator.22 //3-// After Noise NK is open and PAIR_IDENTITY has been exchanged4-// (`shared/sync-transport.ts`, HANDSHAKE_STANDARDIZE_ON_NK.md), this5-// coordinator drives the manifest exchange:3+// After reconnect Noise KK is open and PAIR_IDENTITY has been exchanged4+// (`shared/sync-transport.ts`), this coordinator drives the manifest exchange:65 //76 //   ->  SYNC_HELLO { vaultId, deviceId }87 //   <-  SYNC_HELLO { vaultId, deviceId }       (mismatch → SYNC_ERROR)

view file

src/background/sync-runner.test.ts+43 −6
@@ -42,6 +42,7 @@4242     deviceName: "Pixel",4343     deviceType: "android",4444     longTermPublicKeyHex: REMOTE_PUBKEY_HEX,45+    rendezvousSecretHex: "11".repeat(32),4546     wsPort: 49142,4647     pairedAt: 1700000000000,4748     ...overrides,@@ -218,6 +219,20 @@218219     ).rejects.toThrow("paired_device_missing_pubkey");219220   });220221 222+  it("refuses reconnect when legacy pairing state has no rendezvous secret", async () => {223+    await bootVault();224+    const { runWithDeps } = await import("./sync-runner");225+    const deps: SyncRunnerDeps = {226+      openSession: vi.fn(),227+      probeSubnet: vi.fn(),228+      getSubnet: vi.fn(),229+      createAdapter: emptyAdapter,230+    };231+    await expect(232+      runWithDeps(makeDevice({ rendezvousSecretHex: undefined }), deps),233+    ).rejects.toThrow("paired_device_missing_rendezvous_secret");234+  });235+221236   it("uses the cached last-known IP first and skips the subnet probe", async () => {222237     await bootVault();223238     const { setLastKnownIp } = await import("./paired-devices");@@ -251,8 +266,16 @@251266     const { session, peer } = inMemoryPair();252267     const openSession = vi.fn()253268       .mockRejectedValueOnce(new Error("ECONNREFUSED"))269+      .mockRejectedValueOnce(new Error("ECONNREFUSED"))270+      .mockRejectedValueOnce(new Error("ECONNREFUSED"))254271       .mockResolvedValueOnce(session);255-    const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 49142 }));272+    const probeSubnet = vi.fn(async (273+      ...args: Parameters<SyncRunnerDeps["probeSubnet"]>274+    ) => ({275+      ip: "192.168.1.99",276+      port: 49142,277+      rendezvousId: args[4][2],278+    }));256279     const deps: SyncRunnerDeps = {257280       openSession,258281       probeSubnet,@@ -264,9 +287,11 @@264287     const runPromise = runWithDeps(makeDevice(), deps);265288     await runPeer(peer);266289     await runPromise;267-    expect(openSession).toHaveBeenCalledTimes(2);268-    expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 49142 });290+    expect(openSession).toHaveBeenCalledTimes(4);291+    expect(openSession.mock.calls[3][0]).toEqual({ ip: "192.168.1.99", port: 49142 });292+    expect(openSession.mock.calls[3][3]).toBe(probeSubnet.mock.calls[0][4][2]);269293     expect(probeSubnet).toHaveBeenCalledTimes(1);294+    expect(probeSubnet.mock.calls[0][4]).toHaveLength(3);270295     expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");271296   });272297 @@ -303,7 +328,11 @@303328     const openSession = vi.fn(async () => session);304329     const deps: SyncRunnerDeps = {305330       openSession,306-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),331+      probeSubnet: vi.fn(async () => ({332+        ip: "192.168.1.1",333+        port: 49142,334+        rendezvousId: "A".repeat(43),335+      })),307336       getSubnet: vi.fn(async () => "192.168.1"),308337       createAdapter: emptyAdapter,309338     };@@ -333,7 +362,11 @@333362     };334363     const deps: SyncRunnerDeps = {335364       openSession: vi.fn(async () => session),336-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),365+      probeSubnet: vi.fn(async () => ({366+        ip: "192.168.1.1",367+        port: 49142,368+        rendezvousId: "A".repeat(43),369+      })),337370       getSubnet: vi.fn(async () => "192.168.1"),338371       createAdapter: () => adapter,339372     };@@ -350,7 +383,11 @@350383     const { session, peer } = inMemoryPair();351384     const deps: SyncRunnerDeps = {352385       openSession: vi.fn(async () => session),353-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),386+      probeSubnet: vi.fn(async () => ({387+        ip: "192.168.1.1",388+        port: 49142,389+        rendezvousId: "A".repeat(43),390+      })),354391       getSubnet: vi.fn(async () => "192.168.1"),355392       createAdapter: emptyAdapter,356393     };

view file

src/background/sync-runner.ts+71 −25
@@ -1,6 +1,6 @@11 // Production sync runner — composes the live vault adapter, WebSocket22 // transport, and coordinator into the function the dispatcher injects via3-// `setSyncRunner`. The runner is responsible for opening one Noise NK3+// `setSyncRunner`. The runner is responsible for opening one Noise KK44 // session against a single paired device, driving the sync protocol, and55 // surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in66 // sync-trigger.ts iterates over hosts and aggregates results.@@ -27,46 +27,53 @@2727 import {2828   openSyncSession,2929   probeSubnetForPeer,30+  type ProbedSyncEndpoint,3031   type SyncSession,3132   type SyncEndpoint,3233   type SyncPeerIdentity,3334 } from "../shared/sync-transport";3435 import { hexToPubkey } from "../shared/sync-identity";3536 import { getLocalSubnet } from "../shared/local-network";36-import { buildLocalIdentity } from "./device-identity";37+import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";3738 import { PAIRING_PORT } from "../shared/pairing-dial";3839 import { getRootKeyHex, isUnlocked } from "./vault";3940 import type { PairedDevice } from "../shared/sync-types";4041 import type { SyncRunResult } from "./sync-trigger";42+import {43+  dialingRendezvousIds,44+  rendezvousSecretFromHex,45+} from "../shared/rendezvous";4146 4247 /** Same fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */4348 const DEFAULT_PORT = PAIRING_PORT;4449 4550 /**4651  * Test/injection seam. Production wires in the real `openSyncSession`;47- * unit tests inject a fake to avoid real WebSocket + Noise NK.52+ * unit tests inject a fake to avoid real WebSocket + Noise KK.4853  */4954 export interface SyncRunnerDeps {5055   openSession: (5156     endpoint: SyncEndpoint,5257     ourIdentity: SyncPeerIdentity,5358     expectedRemoteHex: string,59+    rendezvousId: string,5460   ) => Promise<SyncSession>;5561   probeSubnet: (5662     subnet: string,5763     port: number,5864     ourIdentity: SyncPeerIdentity,5965     expectedRemoteHex: string,60-  ) => Promise<SyncEndpoint | null>;66+    rendezvousIds: readonly string[],67+  ) => Promise<ProbedSyncEndpoint | null>;6168   getSubnet: () => Promise<string | null>;6269   createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;6370 }6471 6572 const productionDeps: SyncRunnerDeps = {66-  openSession: (endpoint, identity, expected) =>67-    openSyncSession(endpoint, identity, expected),68-  probeSubnet: (subnet, port, identity, expected) =>69-    probeSubnetForPeer(subnet, port, identity, expected),73+  openSession: (endpoint, identity, expected, rendezvousId) =>74+    openSyncSession(endpoint, identity, expected, rendezvousId),75+  probeSubnet: (subnet, port, identity, expected, rendezvousIds) =>76+    probeSubnetForPeer(subnet, port, identity, expected, rendezvousIds),7077   getSubnet: getLocalSubnet,7178   createAdapter: (context) => createSyncVaultAdapter(context),7279 };@@ -86,24 +93,45 @@8693   if (!device.longTermPublicKeyHex) {8794     throw new Error("paired_device_missing_pubkey");8895   }96+  if (!device.rendezvousSecretHex) {97+    throw new Error("paired_device_missing_rendezvous_secret");98+  }8999   const expectedRemoteHex = device.longTermPublicKeyHex;100+  const reconnectIds = dialingRendezvousIds(101+    rendezvousSecretFromHex(device.rendezvousSecretHex),102+    Date.now() / 1000,103+  );90104   const port = device.wsPort ?? DEFAULT_PORT;91105 92106   // Sync identity derives from the stable root key, not the password-derived93-  // key — keeps the long-term static key constant across password changes94-  // (SEV-2-B). NK's initiator side has no static keypair in the handshake95-  // itself; `ourIdentity` is only what we assert in the PAIR_IDENTITY frame.107+  // key. Reconnect uses both halves directly in standard Noise KK.96108   const masterKeyHex = getRootKeyHex();97109   if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };98110   const masterKey = hexToBytesLocal(masterKeyHex);99-  const ourIdentity = await buildLocalIdentity(masterKey);111+  const publicIdentity = await buildLocalIdentity(masterKey);112+  const localKeys = deriveLocalSyncKeypair(masterKey, publicIdentity.deviceId);113+  if (publicIdentity.longTermPublicKeyHex !== bytesToHex(localKeys.publicKey)) {114+    masterKey.fill(0);115+    localKeys.secretKey.fill(0);116+    throw new Error("derived reconnect identity mismatch");117+  }118+  const ourIdentity: SyncPeerIdentity = {119+    ...publicIdentity,120+    longTermPrivateKey: localKeys.secretKey,121+  };100122 101123   // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.102124   void hexToPubkey(expectedRemoteHex);103125 104126   // HV-1b: pass the peer being synced so remote-mutation alerts can name it.105127   const adapter = deps.createAdapter({ deviceName: device.deviceName });106-  const session = await connect(expectedRemoteHex, port, ourIdentity, deps);128+  let session: SyncSession;129+  try {130+    session = await connect(expectedRemoteHex, port, ourIdentity, reconnectIds, deps);131+  } finally {132+    masterKey.fill(0);133+    localKeys.secretKey.fill(0);134+  }107135 108136   try {109137     const outcome = await runSyncInitiator(session, adapter);@@ -132,19 +160,23 @@132160   expectedRemoteHex: string,133161   port: number,134162   ourIdentity: SyncPeerIdentity,163+  rendezvousIds: string[],135164   deps: SyncRunnerDeps,136165 ): Promise<SyncSession> {137166   const cached = await getLastKnownIp(expectedRemoteHex);138167   if (cached) {139-    try {140-      const session = await deps.openSession(141-        { ip: cached, port },142-        ourIdentity,143-        expectedRemoteHex,144-      );145-      return session;146-    } catch {147-      // Cached IP stale — fall through to the subnet probe.168+    for (const rendezvousId of rendezvousIds) {169+      try {170+        const session = await deps.openSession(171+          { ip: cached, port },172+          ourIdentity,173+          expectedRemoteHex,174+          rendezvousId,175+        );176+        return session;177+      } catch {178+        // Try the next clock-skew window before treating the IP as stale.179+      }148180     }149181   }150182 @@ -153,10 +185,20 @@153185     throw new Error(cached ? "host_unreachable" : "subnet_unknown");154186   }155187 156-  const found = await deps.probeSubnet(subnet, port, ourIdentity, expectedRemoteHex);188+  const found = await deps.probeSubnet(189+    subnet,190+    port,191+    ourIdentity,192+    expectedRemoteHex,193+    rendezvousIds,194+  );157195   if (!found) throw new Error("host_unreachable");158-159-  const session = await deps.openSession(found, ourIdentity, expectedRemoteHex);196+  const session = await deps.openSession(197+    { ip: found.ip, port: found.port },198+    ourIdentity,199+    expectedRemoteHex,200+    found.rendezvousId,201+  );160202   await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);161203   return session;162204 }@@ -167,4 +209,8 @@167209     out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);168210   }169211   return out;212+}213+214+function bytesToHex(bytes: Uint8Array): string {215+  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");170216 }

view file

src/background/sync-vault-adapter.ts+2 −2
@@ -13,7 +13,7 @@1313 // into another: decryptEntryOrNull rejects any envelope whose decrypted1414 // entry.id does not match the map key it arrived under. Both sides hold the1515 // same syncMasterKey (same vault) so keys match without prior negotiation. The16-// Noise NK session encrypts the transport on top — the envelope is16+// Noise KK reconnect session encrypts the transport on top — the envelope is1717 // belt-and-braces so a transport-layer leak doesn't expose plaintext entries.1818 //1919 // Device-manifest exchange (v2 brief §4.3) is plumbed here. The extension is@@ -363,7 +363,7 @@363363   if (!isUnlocked()) throw new VaultLockedError();364364 }365365 366-// Sync derives everything — vault_id, the Noise NK static key, the Ed25519366+// Sync derives everything — vault_id, the Noise static key, the Ed25519367367 // manifest signing key, and the per-entry envelope keys — from the vault's368368 // stable root key, NOT the password-derived key. This keeps paired devices'369369 // identities and envelope keys constant across a master-password change on

view file

src/background/vault.rotate-sync-identity.test.ts+1 −1
@@ -118,7 +118,7 @@118118 119119   // T-016c: paired devices are cleared after rotation.120120   // After rotation, the old paired devices must no longer appear in storage —121-  // their Noise NK static keys are derived from the old root key and cannot121+  // their Noise static keys are derived from the old root key and cannot122122   // be used to authenticate against the new sync identity.123123   //124124   // Bug this catches: forgetting to call clearPairedDevices() after rotation

view file

src/background/vault.ts+1 −1
@@ -2902,7 +2902,7 @@29022902 /**29032903  * Rotate the vault's sync identity by generating a FRESH ROOT KEY and29042904  * re-wrapping everything under it (MV-8). Because every sync credential —2905- * vault_id, the Noise NK static keypair, the Ed25519 manifest signing key,2905+ * vault_id, the Noise static keypair, the Ed25519 manifest signing key,29062906  * and the per-entry envelope keys — derives from the root key, rotation29072907  * makes a lost/stolen device's retained key material useless. All paired29082908  * devices must re-pair afterwards.

view file

src/shared/device-manifest.ts+3 −3
@@ -3,7 +3,7 @@33 // A device manifest is a list of paired-device entries, signed by the44 // issuer's long-term Ed25519 signing key (`deriveSyncSigningKeypair`).55 // Manifests are exchanged after the entry sync completes during a normal6-// `SyncSession`, encrypted under Noise NK along with everything else.6+// `SyncSession`, encrypted under reconnect Noise KK along with everything else.77 //88 // Purpose: propagate trust between mobile devices without requiring99 // additional audio pairing sessions. The extension is the trust anchor and@@ -18,8 +18,8 @@1818 //     against the issuer's stored signing pubkey.1919 //   - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS2020 //     of the receiver's clock; older manifests are silently discarded.21-//   - Vault binding: the manifest is exchanged over Noise NK which has22-//     already proven the peer holds the right vault-derived static key.21+//   - Vault binding: the manifest is exchanged over Noise KK which has already22+//     proven both locally-paired vault-derived static keys.2323 2424 import {2525   DEVICE_MANIFEST_FRESHNESS_MS,

view file

src/shared/noise-js.ts+206 −2
@@ -22,9 +22,10 @@2222 import { HMAC } from "@stablelib/hmac";2323 2424 const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";25+const KK_PROTOCOL_NAME = "Noise_KK_25519_ChaChaPoly_BLAKE2s";2526 26-export function initHash(): Uint8Array {27-  const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);27+function initProtocolHash(protocolName: string): Uint8Array {28+  const nameBytes = new TextEncoder().encode(protocolName);2829   if (nameBytes.length <= 32) {2930     const h = new Uint8Array(32);3031     h.set(nameBytes);@@ -35,6 +36,10 @@3536   return new BLAKE2s(32).update(nameBytes).digest();3637 }3738 39+export function initHash(): Uint8Array {40+  return initProtocolHash(PROTOCOL_NAME);41+}42+3843 function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {3944   const blake = new BLAKE2s(32);4045   blake.update(h);@@ -60,6 +65,205 @@6065   encrypt(plaintext: Uint8Array): Uint8Array;6166   decrypt(ciphertext: Uint8Array): Uint8Array;6267   handshakeHash: Uint8Array;68+}69+70+interface NoiseKeyPair {71+  publicKey: Uint8Array;72+  secretKey: Uint8Array;73+}74+75+function requireX25519Key(key: Uint8Array, label: string): void {76+  if (key.length !== 32) throw new Error(`${label} must be 32 bytes`);77+}78+79+/**80+ * Standard Noise KK initiator for reconnects.81+ *82+ * Pre-messages: -> s, <- s83+ * Message 1:    -> e, es, ss84+ * Message 2:    <- e, ee, se85+ *86+ * Both static keys are local inputs. There is no negotiation and no fallback87+ * to NK: a mismatch hard-fails this handshake.88+ */89+export async function noiseKKInitiator(90+  ourStaticPrivateKey: Uint8Array,91+  ourStaticPublicKey: Uint8Array,92+  responderStaticPublicKey: Uint8Array,93+  send: (msg: Uint8Array) => Promise<void>,94+  receive: () => Promise<Uint8Array>,95+  ephemeral: NoiseKeyPair = generateKeyPair(),96+): Promise<NoiseNKSession> {97+  requireX25519Key(ourStaticPrivateKey, "initiator private key");98+  requireX25519Key(ourStaticPublicKey, "initiator public key");99+  requireX25519Key(responderStaticPublicKey, "responder public key");100+  requireX25519Key(ephemeral.secretKey, "initiator ephemeral private key");101+  requireX25519Key(ephemeral.publicKey, "initiator ephemeral public key");102+103+  let h: Uint8Array = initProtocolHash(KK_PROTOCOL_NAME);104+  let ck: Uint8Array = h.slice();105+  h = mixHash(h, ourStaticPublicKey);106+  h = mixHash(h, responderStaticPublicKey);107+108+  // -> e, es, ss109+  h = mixHash(h, ephemeral.publicKey);110+  let keys = hkdf2(111+    ck,112+    sharedKey(ephemeral.secretKey, responderStaticPublicKey, true),113+  );114+  ck = keys[0];115+  keys = hkdf2(116+    ck,117+    sharedKey(ourStaticPrivateKey, responderStaticPublicKey, true),118+  );119+  ck = keys[0];120+  const encrypted1 = new ChaCha20Poly1305(keys[1]).seal(121+    new Uint8Array(12),122+    new Uint8Array(0),123+    h,124+  );125+  h = mixHash(h, encrypted1);126+  const msg1 = new Uint8Array(32 + encrypted1.length);127+  msg1.set(ephemeral.publicKey, 0);128+  msg1.set(encrypted1, 32);129+  await send(msg1);130+131+  // <- e, ee, se132+  const msg2 = await receive();133+  if (msg2.length < 48) throw new Error("Noise KK: msg2 too short");134+  const responderEphemeralPublic = msg2.slice(0, 32);135+  const encrypted2 = msg2.slice(32);136+  h = mixHash(h, responderEphemeralPublic);137+  keys = hkdf2(138+    ck,139+    sharedKey(ephemeral.secretKey, responderEphemeralPublic, true),140+  );141+  ck = keys[0];142+  keys = hkdf2(143+    ck,144+    sharedKey(ourStaticPrivateKey, responderEphemeralPublic, true),145+  );146+  ck = keys[0];147+  const opened2 = new ChaCha20Poly1305(keys[1]).open(148+    new Uint8Array(12),149+    encrypted2,150+    h,151+  );152+  if (!opened2) {153+    throw new Error("Noise KK: msg2 auth failed — wrong peer key or MITM");154+  }155+  h = mixHash(h, encrypted2);156+157+  const [sendKey, receiveKey] = hkdf2(ck, new Uint8Array(0));158+  return transportSession(sendKey, receiveKey, h, "KK");159+}160+161+/**162+ * Standard Noise KK responder. The expected initiator key must come from the163+ * local paired-device registry before this function is called.164+ */165+export async function noiseKKResponder(166+  ourStaticPrivateKey: Uint8Array,167+  ourStaticPublicKey: Uint8Array,168+  expectedInitiatorStaticPublicKey: Uint8Array,169+  send: (msg: Uint8Array) => Promise<void>,170+  receive: () => Promise<Uint8Array>,171+  ephemeral: NoiseKeyPair = generateKeyPair(),172+): Promise<NoiseNKSession> {173+  requireX25519Key(ourStaticPrivateKey, "responder private key");174+  requireX25519Key(ourStaticPublicKey, "responder public key");175+  requireX25519Key(expectedInitiatorStaticPublicKey, "initiator public key");176+  requireX25519Key(ephemeral.secretKey, "responder ephemeral private key");177+  requireX25519Key(ephemeral.publicKey, "responder ephemeral public key");178+179+  let h: Uint8Array = initProtocolHash(KK_PROTOCOL_NAME);180+  let ck: Uint8Array = h.slice();181+  h = mixHash(h, expectedInitiatorStaticPublicKey);182+  h = mixHash(h, ourStaticPublicKey);183+184+  // -> e, es, ss185+  const msg1 = await receive();186+  if (msg1.length < 48) throw new Error("Noise KK: msg1 too short");187+  const initiatorEphemeralPublic = msg1.slice(0, 32);188+  const encrypted1 = msg1.slice(32);189+  h = mixHash(h, initiatorEphemeralPublic);190+  let keys = hkdf2(191+    ck,192+    sharedKey(ourStaticPrivateKey, initiatorEphemeralPublic, true),193+  );194+  ck = keys[0];195+  keys = hkdf2(196+    ck,197+    sharedKey(ourStaticPrivateKey, expectedInitiatorStaticPublicKey, true),198+  );199+  ck = keys[0];200+  const opened1 = new ChaCha20Poly1305(keys[1]).open(201+    new Uint8Array(12),202+    encrypted1,203+    h,204+  );205+  if (!opened1) {206+    throw new Error("Noise KK: msg1 auth failed — wrong peer key or MITM");207+  }208+  h = mixHash(h, encrypted1);209+210+  // <- e, ee, se211+  h = mixHash(h, ephemeral.publicKey);212+  keys = hkdf2(213+    ck,214+    sharedKey(ephemeral.secretKey, initiatorEphemeralPublic, true),215+  );216+  ck = keys[0];217+  keys = hkdf2(218+    ck,219+    sharedKey(ephemeral.secretKey, expectedInitiatorStaticPublicKey, true),220+  );221+  ck = keys[0];222+  const encrypted2 = new ChaCha20Poly1305(keys[1]).seal(223+    new Uint8Array(12),224+    new Uint8Array(0),225+    h,226+  );227+  h = mixHash(h, encrypted2);228+  const msg2 = new Uint8Array(32 + encrypted2.length);229+  msg2.set(ephemeral.publicKey, 0);230+  msg2.set(encrypted2, 32);231+  await send(msg2);232+233+  const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));234+  return transportSession(responderSendKey, initiatorSendKey, h, "KK");235+}236+237+function transportSession(238+  sendKey: Uint8Array,239+  receiveKey: Uint8Array,240+  handshakeHash: Uint8Array,241+  pattern: "KK",242+): NoiseNKSession {243+  let sendNonce = 0n;244+  let receiveNonce = 0n;245+  const sendCipher = new ChaCha20Poly1305(sendKey);246+  const receiveCipher = new ChaCha20Poly1305(receiveKey);247+  const nonceBytes = (n: bigint) => {248+    const nonce = new Uint8Array(12);249+    new DataView(nonce.buffer).setBigUint64(4, n, true);250+    return nonce;251+  };252+  return {253+    encrypt(plaintext) {254+      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));255+    },256+    decrypt(ciphertext) {257+      const plaintext = receiveCipher.open(258+        nonceBytes(receiveNonce++),259+        ciphertext,260+        new Uint8Array(0),261+      );262+      if (!plaintext) throw new Error(`Noise ${pattern}: decryption failed`);263+      return plaintext;264+    },265+    handshakeHash: handshakeHash.slice(),266+  };63267 }64268 65269 /**

view file

src/shared/noise-kk.test.ts+107 −0
@@ -0,0 +1,107 @@1+import { describe, expect, it } from "vitest";2+import { generateKeyPair, generateKeyPairFromSeed } from "@stablelib/x25519";3+import {4+  bytesToHex,5+  noiseKKInitiator,6+  noiseKKResponder,7+} from "./noise-js";8+9+function channels() {10+  const initiatorToResponder: Uint8Array[] = [];11+  const responderToInitiator: Uint8Array[] = [];12+  const drain = async (queue: Uint8Array[]) => {13+    while (queue.length === 0) await new Promise<void>((resolve) => setTimeout(resolve, 0));14+    return queue.shift()!;15+  };16+  return {17+    initiatorSend: async (frame: Uint8Array) => { initiatorToResponder.push(frame); },18+    initiatorReceive: () => drain(responderToInitiator),19+    responderSend: async (frame: Uint8Array) => { responderToInitiator.push(frame); },20+    responderReceive: () => drain(initiatorToResponder),21+  };22+}23+24+describe("Noise KK reconnect", () => {25+  it("mutually authenticates known static keys and carries transport frames", async () => {26+    const initiator = generateKeyPair();27+    const responder = generateKeyPair();28+    const wire = channels();29+    const [a, b] = await Promise.all([30+      noiseKKInitiator(31+        initiator.secretKey,32+        initiator.publicKey,33+        responder.publicKey,34+        wire.initiatorSend,35+        wire.initiatorReceive,36+      ),37+      noiseKKResponder(38+        responder.secretKey,39+        responder.publicKey,40+        initiator.publicKey,41+        wire.responderSend,42+        wire.responderReceive,43+      ),44+    ]);45+46+    expect(bytesToHex(a.handshakeHash)).toBe(bytesToHex(b.handshakeHash));47+    expect(new TextDecoder().decode(b.decrypt(a.encrypt(new TextEncoder().encode("one"))))).toBe("one");48+    expect(new TextDecoder().decode(a.decrypt(b.encrypt(new TextEncoder().encode("two"))))).toBe("two");49+  });50+51+  it("pins the cross-platform deterministic handshake hash", async () => {52+    const initiator = generateKeyPairFromSeed(new Uint8Array(32).fill(0x01));53+    const responder = generateKeyPairFromSeed(new Uint8Array(32).fill(0x02));54+    const initiatorEphemeral = generateKeyPairFromSeed(new Uint8Array(32).fill(0x03));55+    const responderEphemeral = generateKeyPairFromSeed(new Uint8Array(32).fill(0x04));56+    const wire = channels();57+    const [a, b] = await Promise.all([58+      noiseKKInitiator(59+        initiator.secretKey,60+        initiator.publicKey,61+        responder.publicKey,62+        wire.initiatorSend,63+        wire.initiatorReceive,64+        initiatorEphemeral,65+      ),66+      noiseKKResponder(67+        responder.secretKey,68+        responder.publicKey,69+        initiator.publicKey,70+        wire.responderSend,71+        wire.responderReceive,72+        responderEphemeral,73+      ),74+    ]);75+76+    expect(bytesToHex(a.handshakeHash)).toBe(77+      "e92392c530684df3eed06a76bd18da5c7a1887e88f5fdee758f9870dadd28182",78+    );79+    expect(bytesToHex(b.handshakeHash)).toBe(bytesToHex(a.handshakeHash));80+  });81+82+  it("hard-fails a mismatched expected initiator key without downgrade", async () => {83+    const initiator = generateKeyPair();84+    const attacker = generateKeyPair();85+    const responder = generateKeyPair();86+    const wire = channels();87+88+    const initiatorHandshake = noiseKKInitiator(89+      initiator.secretKey,90+      initiator.publicKey,91+      responder.publicKey,92+      wire.initiatorSend,93+      wire.initiatorReceive,94+    );95+    const responderHandshake = noiseKKResponder(96+      responder.secretKey,97+      responder.publicKey,98+      attacker.publicKey,99+      wire.responderSend,100+      wire.responderReceive,101+    );102+103+    await expect(Promise.all([initiatorHandshake, responderHandshake])).rejects.toThrow(104+      /Noise KK: msg1 auth failed/,105+    );106+  });107+});

view file

src/shared/pairing-handshake.ts+16 −40
@@ -23,11 +23,10 @@2323 //      `long_term_public_key_hex`, and device-manifest signing key. Both sides2424 //      persist the counterpart in the paired-device registry.2525 //26-//   3. The Noise NK session and the ephemerals are discarded — future27-//      WebSocket sessions (`shared/sync-transport.ts`) run Noise NK again,28-//      this time against the long-term static key exchanged in step 2,29-//      followed by a fresh PAIR_IDENTITY exchange (see30-//      HANDSHAKE_STANDARDIZE_ON_NK.md; ongoing sync uses the same pattern).26+//   3. This first-pair NK session is discarded. Future WebSocket reconnects27+//      (`shared/sync-transport.ts`) use standard Noise KK with both long-term28+//      static keys from paired local state, followed by a bound29+//      PAIR_IDENTITY exchange. KK failure never falls back to NK.3130 3231 import { noiseNKInitiator, noiseNKResponder } from "./noise-js";3332 import type { DeviceType, PairedDevice } from "./sync-types";@@ -205,42 +204,19 @@205204 }206205 207206 // ───────────────────────────────────────────────────────────────────────────208-// SECURITY — TRUST MODEL (PCH-02-003): the pairing handshake does NOT cryptographically209-// authenticate the initiator. Noise NK authenticates only the RESPONDER (it210-// proves the initiator encrypted to the responder's "static" key — here the211-// ephemeral the responder published in the clear out of band). On its own212-// it gives the extension (always the responder) zero assurance about *who*213-// completed the handshake, and there is no proof-of-possession over the214-// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.207+// SECURITY — TRUST MODEL: first pairing uses NK because the responder key is208+// known from the one-time code but the initiator identity is not paired yet.209+// Immediately after NK, Android sends a transcript-bound challenge and the210+// browser proves possession of the private half of the X25519 key it will place211+// in PAIR_IDENTITY. Android constant-time binds that proven key to the frame;212+// browser likewise requires Android's identity key to equal the NK responder213+// static. This is structural authentication, not a claim carried by JSON.215214 //216-// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a217-// user-verified Short Authentication String. Both peers derive the SAME 6-digit218-// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both219-// ends because the Noise handshake hash is shared — and DISPLAY it. The220-// extension pairing flow gates persistence on the user confirming the codes221-// match: the SAS gate in `background/code-pairing.ts` runs BEFORE any device is222-// written to the registry and ABORTS pairing on mismatch/rejection. This is the223-// Bluetooth-numeric-comparison / Signal-safety-number model and detects a224-// MITM/relay despite the lack of full mutual auth. Residual risk is the standard225-// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the226-// guarantee depends on the user actually comparing the codes.227-//228-// OPEN ITEM (NOT verifiable from this repository): the Android/native client229-// MUST surface and enforce the identical SAS gate before it persists the peer.230-// If the app skips or auto-confirms the SAS, the channel is only as strong as231-// the app's weakest end — confirm the app-side gate against the Android source232-// at kepr.uk/peach-android before any release that touches pairing. A MITM who233-// intercepts the out-of-band ephemeral and completes the handshake against a non-234-// enforcing peer can pair without user awareness.235-// A future hardening step (coordinated wire change with the app) could add236-// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`237-// (noise-js) so each side proves possession of the key it advertises, making238-// authentication structural rather than dependent on the user's SAS comparison.239-//240-// This is documented residual risk in the cross-repo trust model, not a bug in241-// this codebase: the extension-side gateOnSAS is enforced here and regression-242-// tested (PCH-01-024); the peer-side gate lives in Android and must be re-243-// verified there on every pairing-touching Android release.215+// The user-compared SAS remains mandatory defense in depth. Both sides derive216+// the same value from the handshake hash, display it, and persist nothing until217+// the user confirms. Android's live CodePairingServer blocks on the same gate.218+// Reconnect is a separate local-state-selected Noise KK path and never falls219+// back to NK.244220 // ───────────────────────────────────────────────────────────────────────────245221 246222 /**

view file

src/shared/sync-kk-transport.test.ts+127 −0
@@ -0,0 +1,127 @@1+import { afterEach, describe, expect, it, vi } from "vitest";2+import { generateKeyPair } from "@stablelib/x25519";3+import { noiseKKResponder } from "./noise-js";4+import { openSyncSession, type SyncPeerIdentity } from "./sync-transport";5+6+const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));7+const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));8+const hex = (bytes: Uint8Array) =>9+  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");10+const RENDEZVOUS_ID = "Q".repeat(43);11+12+function installPeer(peerIdentityKeyHex?: string) {13+  const initiator = generateKeyPair();14+  const responder = generateKeyPair();15+  const clientFrames: Uint8Array[] = [];16+  const clientReaders: Array<(frame: Uint8Array) => void> = [];17+  let socket: {18+    binaryType: string;19+    onopen: ((event: Event) => void) | null;20+    onmessage: ((event: MessageEvent) => void) | null;21+    onerror: (() => void) | null;22+    onclose: ((event: CloseEvent) => void) | null;23+    send(data: Uint8Array | ArrayBuffer): void;24+    close(): void;25+  };26+  const receiveClient = async () => {27+    const queued = clientFrames.shift();28+    if (queued) return queued;29+    return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));30+  };31+  const sendServer = async (frame: Uint8Array) => {32+    queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));33+  };34+35+  const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {36+    const WebSocketMock = vi.fn().mockImplementation(function() {37+      socket = {38+        binaryType: "arraybuffer",39+        onopen: null,40+        onmessage: null,41+        onerror: null,42+        onclose: null,43+        send(data) {44+          const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));45+          const reader = clientReaders.shift();46+          if (reader) reader(frame);47+          else clientFrames.push(frame);48+        },49+        close() {50+          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));51+        },52+      };53+      queueMicrotask(() => socket.onopen?.(new Event("open")));54+      return socket;55+    });56+    vi.stubGlobal("WebSocket", WebSocketMock);57+58+    void (async () => {59+      try {60+        const selector = decode(await receiveClient()) as Record<string, unknown>;61+        resolve(selector);62+        const session = await noiseKKResponder(63+          responder.secretKey,64+          responder.publicKey,65+          initiator.publicKey,66+          sendServer,67+          receiveClient,68+        );69+        const theirIdentity = decode(session.decrypt(await receiveClient()));70+        if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {71+          throw new Error("initiator identity was not bound to KK key");72+        }73+        await sendServer(session.encrypt(encode({74+          type: "PAIR_IDENTITY",75+          device_id: "phone",76+          device_name: "Pixel",77+          device_type: "android",78+          long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),79+        })));80+      } catch (error) {81+        reject(error);82+      }83+    })();84+  });85+86+  const identity: SyncPeerIdentity = {87+    deviceId: "browser",88+    deviceName: "Firefox",89+    deviceType: "extension",90+    longTermPublicKeyHex: hex(initiator.publicKey),91+    longTermPrivateKey: initiator.secretKey,92+  };93+  return { identity, responder, selectorSeen };94+}95+96+afterEach(() => vi.unstubAllGlobals());97+98+describe("KK WebSocket reconnect transport", () => {99+  it("selects paired state then completes KK with both keys pinned", async () => {100+    const peer = installPeer();101+    const session = await openSyncSession(102+      { ip: "127.0.0.1", port: 49142 },103+      peer.identity,104+      hex(peer.responder.publicKey),105+      RENDEZVOUS_ID,106+    );107+108+    await expect(peer.selectorSeen).resolves.toEqual({109+      type: "PEACH_RECONNECT",110+      rendezvous_id: RENDEZVOUS_ID,111+    });112+    expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));113+    await session.close();114+  });115+116+  it("rejects a post-KK identity that claims a different responder key", async () => {117+    const peer = installPeer("ff".repeat(32));118+    await expect(119+      openSyncSession(120+        { ip: "127.0.0.1", port: 49142 },121+        peer.identity,122+        hex(peer.responder.publicKey),123+        RENDEZVOUS_ID,124+      ),125+    ).rejects.toThrow(/identity does not match authenticated KK key/);126+  });127+});

view file

src/shared/sync-limits.ts+1 −1
@@ -26,7 +26,7 @@2626 2727 /**2828  * MV-3: hard resource bounds on the sync receive path. A paired/vouched29- * peer (or anything that passes the Noise NK static-key pin) must not be29+ * peer (or anything that passes the Noise KK mutual static-key pin) must not be3030  * able to OOM the service worker with one frame or one giant manifest.3131  */3232 export const SYNC_LIMITS = {

view file

src/shared/sync-transport.test.ts+14 −2
@@ -1,4 +1,4 @@1-// Subnet-probe rate-limit tests. The full Noise NK handshake side of the1+// Subnet-probe rate-limit tests. The full Noise KK handshake side of the22 // transport requires a live WebSocket peer, so we exercise the cooldown33 // ledger directly — it is the only Phase 2 invariant we want to pin44 // regardless of network reachability.@@ -11,13 +11,20 @@1111   SUBNET_PROBE_COOLDOWN_MS,1212   type SyncPeerIdentity,1313 } from "./sync-transport";14+import { generateKeyPair } from "@stablelib/x25519";1415 1516 const PEER_PUBKEY = "ab".repeat(32);17+const RENDEZVOUS_IDS = ["A".repeat(43), "B".repeat(43), "C".repeat(43)];18+const TEST_KEYS = generateKeyPair();1619 const TEST_IDENTITY: SyncPeerIdentity = {1720   deviceId: "test-device",1821   deviceName: "Test",1922   deviceType: "extension",20-  longTermPublicKeyHex: "11".repeat(32),23+  longTermPublicKeyHex: Array.from(24+    TEST_KEYS.publicKey,25+    (b) => b.toString(16).padStart(2, "0"),26+  ).join(""),27+  longTermPrivateKey: TEST_KEYS.secretKey,2128 };2229 2330 describe("subnetProbeOnCooldown", () => {@@ -37,6 +44,7 @@3744       49142,3845       TEST_IDENTITY,3946       PEER_PUBKEY,47+      RENDEZVOUS_IDS,4048       { batch: 254, timeoutMs: 1, now },4149     );4250     expect(subnetProbeOnCooldown(PEER_PUBKEY, now + 1)).toBe(true);@@ -50,6 +58,7 @@5058       49142,5159       TEST_IDENTITY,5260       PEER_PUBKEY,61+      RENDEZVOUS_IDS,5362       { batch: 254, timeoutMs: 1, now },5463     );5564     expect(subnetProbeOnCooldown(PEER_PUBKEY, now + SUBNET_PROBE_COOLDOWN_MS)).toBe(false);@@ -62,6 +71,7 @@6271       49142,6372       TEST_IDENTITY,6473       PEER_PUBKEY,74+      RENDEZVOUS_IDS,6575       { batch: 254, timeoutMs: 1, now },6676     );6777     // Second call within the window must short-circuit before any@@ -72,6 +82,7 @@7282       49142,7383       TEST_IDENTITY,7484       PEER_PUBKEY,85+      RENDEZVOUS_IDS,7586       { batch: 254, timeoutMs: 1, now: now + 1_000 },7687     );7788     expect(result).toBeNull();@@ -85,6 +96,7 @@8596       49142,8697       TEST_IDENTITY,8798       PEER_PUBKEY,99+      RENDEZVOUS_IDS,88100       { batch: 254, timeoutMs: 1, now },89101     );90102     expect(subnetProbeOnCooldown(PEER_PUBKEY, now + 1)).toBe(true);

view file

src/shared/sync-transport.ts+71 −37
@@ -1,7 +1,5 @@1-// WebSocket sync transport — standardized on Noise NK (see2-// HANDSHAKE_STANDARDIZE_ON_NK.md). Android's ongoing-sync listener3-// (SyncReconnectListener.kt) is a Noise NK responder on the same fixed port4-// (49142) used for pairing; there is one listener and one handshake pattern.1+// WebSocket reconnect transport — Noise KK with both paired static keys.2+// First pairing remains Noise NK; reconnect never negotiates or falls back.53 //64 // The extension is always the WebSocket client; Android (or future iOS /75 // native sync hosts) is always the server. Each binary WebSocket message@@ -9,23 +7,21 @@97 //108 // On connect:119 //   1. Open ws://<ip>:<port>/12-//   2. Run Noise NK as the initiator against the paired device's known13-//      long-term static public key. NK authenticates only the14-//      responder during the handshake — there is no initiator static15-//      keypair in the crypto itself.16-//   3. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame10+//   2. Send PEACH_RECONNECT with an opaque rotating rendezvous capability so11+//      the listener can select paired local state without exposing identities.12+//   3. Run standard Noise KK using both locally-known static keys.13+//   4. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame1714 //      Android's SyncReconnectListener expects before it will treat the1815 //      connection as sync traffic rather than an unknown caller). Our19-//      identity is asserted, not proven, at this layer — this mirrors20-//      Android's own implementation, not a gap introduced here.21-//   4. Hand the caller a `SyncSession` for the SyncMessage protocol.16+//      identity is bound to the static key authenticated by KK.17+//   5. Hand the caller a `SyncSession` for the SyncMessage protocol.2218 //2319 // Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`2420 // when the cached last-known IP fails. We use a short connection timeout2521 // (~500ms per IP) and parallel batches of 16 to keep the worst-case scan2622 // under ~10 seconds.2723 28-import { noiseNKInitiator, type NoiseNKSession } from "./noise-js";24+import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";2925 import { SYNC_LIMITS } from "./sync-limits";3026 import { hexToPubkey } from "./sync-identity";3127 import type { DeviceType } from "./sync-types";@@ -48,13 +44,20 @@4844   port: number;4945 }5046 51-/** Our identity, asserted in the post-handshake PAIR_IDENTITY frame — the52- *  same shape `code-pairing.ts` sends during pairing (`device_id` etc.). */47+export interface ProbedSyncEndpoint extends SyncEndpoint {48+  /** Exact clock-skew window capability that completed KK at this endpoint. */49+  rendezvousId: string;50+}51+52+/** Our identity, repeated in the post-handshake PAIR_IDENTITY frame and bound53+ *  to the local static key already authenticated by KK. */5354 export interface SyncPeerIdentity {5455   deviceId: string;5556   deviceName: string;5657   deviceType: DeviceType;5758   longTermPublicKeyHex: string;59+  /** Local-only key material for the KK reconnect handshake; never encoded. */60+  longTermPrivateKey: Uint8Array;5861   signingPublicKeyHex?: string;5962 }6063 @@ -77,8 +80,8 @@7780 }7881 7982 /**80- * Open a WebSocket to (ip, port), run Noise NK against the paired device's81- * known static key, exchange PAIR_IDENTITY, and return a ready-to-use83+ * Open a WebSocket to (ip, port), run Noise KK against both paired static84+ * keys, exchange bound PAIR_IDENTITY frames, and return a ready-to-use8285  * session.8386  *8487  * On a successful return, `setLastKnownIp` in the caller can persist@@ -88,8 +91,12 @@8891   endpoint: SyncEndpoint,8992   ourIdentity: SyncPeerIdentity,9093   expectedRemotePubkeyHex: string,94+  rendezvousId: string,9195   options: { connectTimeoutMs?: number } = {},9296 ): Promise<SyncSession> {97+  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {98+    throw new Error("reconnect requires a valid rotating rendezvous id");99+  }93100   const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);94101   const inbox: Uint8Array[] = [];95102   const readers: Array<(b: Uint8Array) => void> = [];@@ -172,10 +179,24 @@172179 173180   let session: NoiseNKSession;174181   try {175-    // NK's responder static key is a handshake INPUT, not something learned176-    // mid-handshake — a wrong key simply fails msg2 decryption below, so177-    // there is no separate mismatch check to run first.178-    session = await noiseNKInitiator(hexToPubkey(expectedRemotePubkeyHex), send, receive);182+    const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);183+    if (ourIdentity.longTermPrivateKey.length !== 32) {184+      throw new Error("local reconnect private key must be exactly 32 bytes");185+    }186+    // This opaque rotating capability lets Android select paired local state187+    // without exposing either public key to a relay. It grants no identity188+    // trust; KK still proves both private keys immediately afterwards.189+    await send(new TextEncoder().encode(JSON.stringify({190+      type: "PEACH_RECONNECT",191+      rendezvous_id: rendezvousId,192+    })));193+    session = await noiseKKInitiator(194+      ourIdentity.longTermPrivateKey,195+      ourPublicKey,196+      hexToPubkey(expectedRemotePubkeyHex),197+      send,198+      receive,199+    );179200   } catch (err) {180201     try { ws.close(); } catch { /* ignore */ }181202     throw err;@@ -187,6 +208,9 @@187208     if (theirFrame?.type !== "PAIR_IDENTITY") {188209       throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);189210     }211+    if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {212+      throw new Error("peer identity does not match authenticated KK key");213+    }190214   } catch (err) {191215     try { ws.close(); } catch { /* ignore */ }192216     throw err;@@ -264,7 +288,7 @@264288 }265289 266290 /**267- * Subnet probe — scan a /24 looking for a host that completes Noise NK291+ * Subnet probe — scan a /24 looking for a host that completes Noise KK268292  * with the expected long-term static key. Returns the first match, or269293  * null if the cooldown is still active or no host responded.270294  *@@ -273,7 +297,7 @@273297  * `last_known_ip`/`last_known_port` directly first — the subnet probe is274298  * fallback only.275299  *276- * For each IP we attempt the full Noise NK handshake, but with a tight300+ * For each IP we attempt the full Noise KK handshake, but with a tight277301  * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser278302  * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.279303  */@@ -282,8 +306,12 @@282306   port: number,283307   ourIdentity: SyncPeerIdentity,284308   expectedRemotePubkeyHex: string,309+  rendezvousIds: readonly string[],285310   options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},286-): Promise<SyncEndpoint | null> {311+): Promise<ProbedSyncEndpoint | null> {312+  if (rendezvousIds.length === 0) {313+    throw new Error("subnet probe requires at least one rendezvous id");314+  }287315   const now = options.now ?? Date.now();288316   const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;289317   if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {@@ -301,9 +329,10 @@301329       { ip, port },302330       ourIdentity,303331       expectedRemotePubkeyHex,332+      rendezvousIds,304333       timeoutMs,305334     )));306-    const hit = results.find((r): r is SyncEndpoint => r !== null);335+    const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);307336     if (hit) return hit;308337   }309338   return null;@@ -313,18 +342,23 @@313342   endpoint: SyncEndpoint,314343   ourIdentity: SyncPeerIdentity,315344   expectedRemotePubkeyHex: string,345+  rendezvousIds: readonly string[],316346   timeoutMs: number,317-): Promise<SyncEndpoint | null> {318-  try {319-    const session = await openSyncSession(320-      endpoint,321-      ourIdentity,322-      expectedRemotePubkeyHex,323-      { connectTimeoutMs: timeoutMs },324-    );325-    await session.close();326-    return endpoint;327-  } catch {328-    return null;347+): Promise<ProbedSyncEndpoint | null> {348+  for (const rendezvousId of rendezvousIds) {349+    try {350+      const session = await openSyncSession(351+        endpoint,352+        ourIdentity,353+        expectedRemotePubkeyHex,354+        rendezvousId,355+        { connectTimeoutMs: timeoutMs },356+      );357+      await session.close();358+      return { ...endpoint, rendezvousId };359+    } catch {360+      // Try the next locally-derived clock-skew window at the same host.361+    }329362   }363+  return null;330364 }

view file

src/shared/sync-types.ts+10 −12
@@ -41,9 +41,8 @@4141  *4242  * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync4343  * identity static key — see `deriveSyncIdentityKeypair`. Every WebSocket44- * reconnect runs Noise NK against this key (authenticating the responder45- * only) followed by a PAIR_IDENTITY exchange checked against this registry —46- * see HANDSHAKE_STANDARDIZE_ON_NK.md.44+ * reconnect runs Noise KK with both local static keys, followed by a45+ * PAIR_IDENTITY exchange bound to the authenticated keys.4746  *4847  * `lastKnownIp` is intentionally NOT stored here — it's per-network and4948  * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See@@ -63,9 +62,8 @@6362   /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */6463   trustSource?: TrustSource;6564   /**66-   * The peer's WebSocket sync port. Mobile hosts pick a random high port67-   * (49152–65535) at first launch and announce it inside the encrypted68-   * pairing channel; the extension caches it here.65+   * The peer's WebSocket sync port. Current Android hosts use fixed port 4914266+   * and announce it inside the encrypted pairing channel.6967    */7068   wsPort?: number;7169   /** Pair-only 32-byte reconnect capability, derived from the first handshake. */@@ -81,10 +79,10 @@8179 // ---------------------------------------------------------------------------8280 8381 /**84- * v3 wire protocol — after Noise NK is open and PAIR_IDENTITY has been85- * exchanged (see HANDSHAKE_STANDARDIZE_ON_NK.md).82+ * v3 wire protocol — after reconnect Noise KK is open and PAIR_IDENTITY has83+ * been bound to both authenticated static keys.8684  *87- * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise NK. The85+ * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise KK. The8886  * WebSocket transport sees only ciphertext.8987  *9088  * The manifest carries per-entry timestamps; the receiver requests entries@@ -112,7 +110,7 @@112110 // that work starts from the real wire contract instead of the stale doc.113111 // ---------------------------------------------------------------------------114112 115-/** RelayMessage.targetDeviceId/peerDeviceId are the destination's Noise NK113+/** RelayMessage.targetDeviceId/peerDeviceId are the destination's Noise116114  *  static public key as lowercase hex — NOT `PairedDevice.deviceId`. */117115 export type RelayMessage =118116   | { type: "RELAY_REQUEST"; targetDeviceId: string }@@ -133,8 +131,8 @@133131 134132 /**135133  * One device entry inside a signed manifest. `longTermPublicKeyHex` is the136- * X25519 sync identity key the receiver will use for Noise NK, exactly as137- * if the entry had been audio-paired locally. `signingPublicKeyHex` is the134+ * X25519 sync identity key the receiver will use for Noise KK reconnect.135+ * `signingPublicKeyHex` is the138136  * peer's Ed25519 manifest-verification key — needed so a vouched mobile139137  * can in turn verify manifests issued by the same vault from a different140138  * device.

view file

src/shared/sync.disconnect.test.ts+14 −3
@@ -8,7 +8,7 @@88 //99 // PCH-01-026 #9 added the explicit ws.close() call in onerror so that a peer1010 // error that is NOT followed by onclose still unblocks the receive() queue.11-// No test verifies that a WebSocket error or close during the Noise NK11+// No test verifies that a WebSocket error or close during the Noise KK1212 // handshake produces a rejected promise (not a hang). A regression that1313 // removes the onerror→close bridge causes the session to hang indefinitely1414 // when the peer disconnects after msg1 but before msg2.@@ -23,11 +23,17 @@2323 import { openSyncSession, type SyncPeerIdentity } from "./sync-transport";2424 import { SYNC_LIMITS } from "./sync-limits";2525 26+const TEST_KEYS = generateKeyPair();27+const TEST_RENDEZVOUS_ID = "A".repeat(43);2628 const TEST_IDENTITY: SyncPeerIdentity = {2729   deviceId: "test-device",2830   deviceName: "Test",2931   deviceType: "extension",30-  longTermPublicKeyHex: "11".repeat(32),32+  longTermPublicKeyHex: Array.from(33+    TEST_KEYS.publicKey,34+    (b) => b.toString(16).padStart(2, "0"),35+  ).join(""),36+  longTermPrivateKey: TEST_KEYS.secretKey,3137 };3238 3339 // ---------------------------------------------------------------------------@@ -91,7 +97,7 @@9197 describe("T-020: WebSocket sync-disconnect handling", () => {9298   // T-020a: onerror during handshake → session promise rejects, no hang.9399   // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()94-  // to drain the readers queue. Without this, the Noise NK initiator's receive()100+  // to drain the readers queue. Without this, the Noise KK initiator's receive()95101   // would park forever waiting for msg2.96102   //97103   // Bug this catches: removing the onerror → ws.close() bridge causes the@@ -111,6 +117,7 @@111117       { ip: "127.0.0.1", port: 9999 },112118       TEST_IDENTITY,113119       peerHex,120+      TEST_RENDEZVOUS_ID,114121     );115122 116123     // Allow the WebSocket constructor and initial setup to run@@ -151,6 +158,7 @@151158       { ip: "127.0.0.1", port: 9999 },152159       TEST_IDENTITY,153160       peerHex,161+      TEST_RENDEZVOUS_ID,154162     );155163 156164     await new Promise((r) => setTimeout(r, 0));@@ -189,6 +197,7 @@189197       { ip: "127.0.0.1", port: 9999 },190198       TEST_IDENTITY,191199       peerHex,200+      TEST_RENDEZVOUS_ID,192201       { connectTimeoutMs: 10 },193202     );194203 @@ -218,6 +227,7 @@218227       { ip: "127.0.0.1", port: 9999 },219228       TEST_IDENTITY,220229       peerHex,230+      TEST_RENDEZVOUS_ID,221231     );222232 223233     await new Promise((r) => setTimeout(r, 0));@@ -247,6 +257,7 @@247257       { ip: "127.0.0.1", port: 9999 },248258       TEST_IDENTITY,249259       peerHex,260+      TEST_RENDEZVOUS_ID,250261     );251262 252263     await new Promise((r) => setTimeout(r, 0));

view file