9c0983a25210
feat: require mutual KK reconnect auth (v4.0.43)
- time
- 2026-07-26 03:43 UTC · 3 months ago
- machine
- koh2
- parent
- 7229580119fb
↓ peach-browser-9c0983a2.tar.gzatlas at this save
files changed21 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 −3manifest.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": {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",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",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(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.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)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 };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 }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 onsrc/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 rotationsrc/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.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,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 /**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+});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 /**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+});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 = {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);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 }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.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));