66a788d0df33

dial Android's Noise NK reconnect listener (49142) for ongoing sync, replacing the Noise XX/8765 path that could never complete against it

time
2026-07-25 01:47 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
standardize sync on Noise NK
parent
2d111c938645
4 files src/background/sync-runner.ts+23 −34src/shared/sync-transport.test.ts+13 −11src/shared/sync-transport.ts+61 −50src/shared/sync.disconnect.test.ts+14 −15
src/background/sync-runner.ts+23 −34
@@ -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 XX3+// `setSyncRunner`. The runner is responsible for opening one Noise NK44 // 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.@@ -29,32 +29,33 @@2929   probeSubnetForPeer,3030   type SyncSession,3131   type SyncEndpoint,32+  type SyncPeerIdentity,3233 } from "../shared/sync-transport";33-import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity";34+import { hexToPubkey } from "../shared/sync-identity";3435 import { getLocalSubnet } from "../shared/local-network";35-import { getOrCreateDeviceId } from "./device-identity";36+import { buildLocalIdentity } from "./device-identity";37+import { PAIRING_PORT } from "../shared/pairing-dial";3638 import { getRootKeyHex, isUnlocked } from "./vault";3739 import type { PairedDevice } from "../shared/sync-types";3840 import type { SyncRunResult } from "./sync-trigger";3941 40-const DEFAULT_PORT = 8765;42+/** Same fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */43+const DEFAULT_PORT = PAIRING_PORT;4144 4245 /**4346  * Test/injection seam. Production wires in the real `openSyncSession`;44- * unit tests inject a fake to avoid real WebSocket + Noise XX.47+ * unit tests inject a fake to avoid real WebSocket + Noise NK.4548  */4649 export interface SyncRunnerDeps {4750   openSession: (4851     endpoint: SyncEndpoint,49-    ourPriv: Uint8Array,50-    ourPub: Uint8Array,52+    ourIdentity: SyncPeerIdentity,5153     expectedRemoteHex: string,5254   ) => Promise<SyncSession>;5355   probeSubnet: (5456     subnet: string,5557     port: number,56-    ourPriv: Uint8Array,57-    ourPub: Uint8Array,58+    ourIdentity: SyncPeerIdentity,5859     expectedRemoteHex: string,5960   ) => Promise<SyncEndpoint | null>;6061   getSubnet: () => Promise<string | null>;@@ -62,10 +63,10 @@6263 }6364 6465 const productionDeps: SyncRunnerDeps = {65-  openSession: (endpoint, priv, pub, expected) =>66-    openSyncSession(endpoint, priv, pub, expected),67-  probeSubnet: (subnet, port, priv, pub, expected) =>68-    probeSubnetForPeer(subnet, port, priv, pub, expected),66+  openSession: (endpoint, identity, expected) =>67+    openSyncSession(endpoint, identity, expected),68+  probeSubnet: (subnet, port, identity, expected) =>69+    probeSubnetForPeer(subnet, port, identity, expected),6970   getSubnet: getLocalSubnet,7071   createAdapter: (context) => createSyncVaultAdapter(context),7172 };@@ -89,19 +90,20 @@8990   const port = device.wsPort ?? DEFAULT_PORT;9091 9192   // Sync identity derives from the stable root key, not the password-derived92-  // key — keeps the Noise XX static key constant across password changes (SEV-2-B).93+  // 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.9396   const masterKeyHex = getRootKeyHex();9497   if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };9598   const masterKey = hexToBytesLocal(masterKeyHex);96-  const ourDeviceId = await getOrCreateDeviceId();97-  const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId);99+  const ourIdentity = await buildLocalIdentity(masterKey);98100 99101   // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.100102   void hexToPubkey(expectedRemoteHex);101103 102104   // HV-1b: pass the peer being synced so remote-mutation alerts can name it.103105   const adapter = deps.createAdapter({ deviceName: device.deviceName });104-  const session = await connect(device, expectedRemoteHex, port, ourKeypair, deps);106+  const session = await connect(expectedRemoteHex, port, ourIdentity, deps);105107 106108   try {107109     const outcome = await runSyncInitiator(session, adapter);@@ -127,10 +129,9 @@127129 }128130 129131 async function connect(130-  _device: PairedDevice,131132   expectedRemoteHex: string,132133   port: number,133-  ourKeypair: { secretKey: Uint8Array; publicKey: Uint8Array },134+  ourIdentity: SyncPeerIdentity,134135   deps: SyncRunnerDeps,135136 ): Promise<SyncSession> {136137   const cached = await getLastKnownIp(expectedRemoteHex);@@ -138,8 +139,7 @@138139     try {139140       const session = await deps.openSession(140141         { ip: cached, port },141-        ourKeypair.secretKey,142-        ourKeypair.publicKey,142+        ourIdentity,143143         expectedRemoteHex,144144       );145145       return session;@@ -153,21 +153,10 @@153153     throw new Error(cached ? "host_unreachable" : "subnet_unknown");154154   }155155 156-  const found = await deps.probeSubnet(157-    subnet,158-    port,159-    ourKeypair.secretKey,160-    ourKeypair.publicKey,161-    expectedRemoteHex,162-  );156+  const found = await deps.probeSubnet(subnet, port, ourIdentity, expectedRemoteHex);163157   if (!found) throw new Error("host_unreachable");164158 165-  const session = await deps.openSession(166-    found,167-    ourKeypair.secretKey,168-    ourKeypair.publicKey,169-    expectedRemoteHex,170-  );159+  const session = await deps.openSession(found, ourIdentity, expectedRemoteHex);171160   await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);172161   return session;173162 }

view file

src/shared/sync-transport.test.ts+13 −11
@@ -1,4 +1,4 @@1-// Subnet-probe rate-limit tests. The full Noise XX handshake side of the1+// Subnet-probe rate-limit tests. The full Noise NK 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.@@ -9,9 +9,16 @@99   probeSubnetForPeer,1010   subnetProbeOnCooldown,1111   SUBNET_PROBE_COOLDOWN_MS,12+  type SyncPeerIdentity,1213 } from "./sync-transport";1314 1415 const PEER_PUBKEY = "ab".repeat(32);16+const TEST_IDENTITY: SyncPeerIdentity = {17+  deviceId: "test-device",18+  deviceName: "Test",19+  deviceType: "extension",20+  longTermPublicKeyHex: "11".repeat(32),21+};1522 1623 describe("subnetProbeOnCooldown", () => {1724   beforeEach(() => _resetSubnetProbeCooldown());@@ -28,8 +35,7 @@2835     await probeSubnetForPeer(2936       "10.0.0",3037       49142,31-      new Uint8Array(32),32-      new Uint8Array(32),38+      TEST_IDENTITY,3339       PEER_PUBKEY,3440       { batch: 254, timeoutMs: 1, now },3541     );@@ -42,8 +48,7 @@4248     await probeSubnetForPeer(4349       "10.0.0",4450       49142,45-      new Uint8Array(32),46-      new Uint8Array(32),51+      TEST_IDENTITY,4752       PEER_PUBKEY,4853       { batch: 254, timeoutMs: 1, now },4954     );@@ -55,8 +60,7 @@5560     await probeSubnetForPeer(5661       "10.0.0",5762       49142,58-      new Uint8Array(32),59-      new Uint8Array(32),63+      TEST_IDENTITY,6064       PEER_PUBKEY,6165       { batch: 254, timeoutMs: 1, now },6266     );@@ -66,8 +70,7 @@6670     const result = await probeSubnetForPeer(6771       "10.0.0",6872       49142,69-      new Uint8Array(32),70-      new Uint8Array(32),73+      TEST_IDENTITY,7174       PEER_PUBKEY,7275       { batch: 254, timeoutMs: 1, now: now + 1_000 },7376     );@@ -80,8 +83,7 @@8083     await probeSubnetForPeer(8184       "10.0.0",8285       49142,83-      new Uint8Array(32),84-      new Uint8Array(32),86+      TEST_IDENTITY,8587       PEER_PUBKEY,8688       { batch: 254, timeoutMs: 1, now },8789     );

view file

src/shared/sync-transport.ts+61 −50
@@ -1,4 +1,7 @@1-// WebSocket sync transport — v3 sync Phase 2.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 no separate Noise XX responder.25 //36 // The extension is always the WebSocket client; Android (or future iOS /47 // desktop sync hosts) is always the server. Each binary WebSocket message@@ -6,11 +9,15 @@69 //710 // On connect:811 //   1. Open ws://<ip>:<port>/9-//   2. Run Noise XX as the initiator, using our long-term sync identity10-//      static keypair.11-//   3. Verify the responder's static public key matches the paired-device12-//      registry entry we expect. Mismatch is a hard fail — no implicit13-//      pubkey trust on the LAN.12+//   2. Run Noise NK as the initiator against the paired device's known13+//      long-term static public key. Unlike XX, 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 frame17+//      Android's SyncReconnectListener expects before it will treat the18+//      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.1421 //   4. Hand the caller a `SyncSession` for the SyncMessage protocol.1522 //1623 // Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`@@ -18,9 +25,10 @@1825 // (~500ms per IP) and parallel batches of 16 to keep the worst-case scan1926 // under ~10 seconds.2027 21-import { noiseXXInitiator, type NoiseXXSession } from "./noise-xx";28+import { noiseNKInitiator, type NoiseNKSession } from "./noise-js";2229 import { SYNC_LIMITS } from "./sync-limits";23-import { hexToPubkey, pubkeyToHex } from "./sync-identity";30+import { hexToPubkey } from "./sync-identity";31+import type { DeviceType } from "./sync-types";2432 2533 const DEFAULT_PROBE_TIMEOUT_MS = 500;2634 const DEFAULT_PROBE_BATCH = 16;@@ -40,18 +48,38 @@4048   port: number;4149 }4250 51+/** Our identity, asserted in the post-handshake PAIR_IDENTITY frame — the52+ *  same shape `code-pairing.ts` sends during pairing (`device_id` etc.). */53+export interface SyncPeerIdentity {54+  deviceId: string;55+  deviceName: string;56+  deviceType: DeviceType;57+  longTermPublicKeyHex: string;58+  signingPublicKeyHex?: string;59+}60+61+function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {62+  return new TextEncoder().encode(JSON.stringify({63+    type: "PAIR_IDENTITY",64+    device_id: id.deviceId,65+    device_name: id.deviceName,66+    device_type: id.deviceType,67+    long_term_public_key_hex: id.longTermPublicKeyHex,68+    signing_public_key_hex: id.signingPublicKeyHex,69+  }));70+}71+4372 /**44- * Open a WebSocket to (ip, port), run Noise XX, verify the responder's45- * long-term static key matches `expectedRemotePubkeyHex`, and return a46- * ready-to-use session.73+ * Open a WebSocket to (ip, port), run Noise NK against the paired device's74+ * known static key, exchange PAIR_IDENTITY, and return a ready-to-use75+ * session.4776  *4877  * On a successful return, `setLastKnownIp` in the caller can persist4978  * (ip, port) for the next session's fast path.5079  */5180 export async function openSyncSession(5281   endpoint: SyncEndpoint,53-  ourLongTermPrivateKey: Uint8Array,54-  ourLongTermPublicKey: Uint8Array,82+  ourIdentity: SyncPeerIdentity,5583   expectedRemotePubkeyHex: string,5684   options: { connectTimeoutMs?: number } = {},5785 ): Promise<SyncSession> {@@ -120,40 +148,27 @@120148     });121149   }122150 123-  let session: NoiseXXSession;151+  let session: NoiseNKSession;124152   try {125-    session = await noiseXXInitiator(126-      ourLongTermPrivateKey,127-      ourLongTermPublicKey,128-      send,129-      receive,130-      // PCH-01-003: abort BEFORE msg3 if the responder is not the peer we131-      // expect, so a probed-but-unexpected LAN host never receives our static.132-      (remoteStatic) => {133-        const seenHex = pubkeyToHex(remoteStatic);134-        if (seenHex !== expectedRemotePubkeyHex) {135-          throw new Error(136-            `paired-device static key mismatch (got ${seenHex.slice(0, 16)}…, ` +137-            `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,138-          );139-        }140-      },141-    );153+    // NK's responder static key is a handshake INPUT, not something learned154+    // mid-handshake — a wrong key simply fails msg2 decryption below, so155+    // there is no separate mismatch check to run first (unlike XX).156+    session = await noiseNKInitiator(hexToPubkey(expectedRemotePubkeyHex), send, receive);142157   } catch (err) {143158     try { ws.close(); } catch { /* ignore */ }144159     throw err;145160   }146161 147-  const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);148-  if (remoteHex !== expectedRemotePubkeyHex) {162+  try {163+    await send(session.encrypt(encodePairIdentity(ourIdentity)));164+    const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));165+    if (theirFrame?.type !== "PAIR_IDENTITY") {166+      throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);167+    }168+  } catch (err) {149169     try { ws.close(); } catch { /* ignore */ }150-    throw new Error(151-      `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +152-      `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,153-    );170+    throw err;154171   }155-  // Bind so the unused expectation-source check helper stays honest.156-  void hexToPubkey(expectedRemotePubkeyHex);157172 158173   return {159174     send(plaintext: Uint8Array) {@@ -167,7 +182,7 @@167182     async close() {168183       try { ws.close(); } catch { /* ignore */ }169184     },170-    remoteStaticPublicKey: session.remoteStaticPublicKey,185+    remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),171186   };172187 }173188 @@ -222,7 +237,7 @@222237 }223238 224239 /**225- * Subnet probe — scan a /24 looking for a host that completes Noise XX240+ * Subnet probe — scan a /24 looking for a host that completes Noise NK226241  * with the expected long-term static key. Returns the first match, or227242  * null if the cooldown is still active or no host responded.228243  *@@ -231,15 +246,14 @@231246  * `last_known_ip`/`last_known_port` directly first — the subnet probe is232247  * fallback only.233248  *234- * For each IP we attempt the full Noise XX handshake, but with a tight249+ * For each IP we attempt the full Noise NK handshake, but with a tight235250  * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser236251  * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.237252  */238253 export async function probeSubnetForPeer(239254   subnet: string, // e.g. "192.168.1"240255   port: number,241-  ourLongTermPrivateKey: Uint8Array,242-  ourLongTermPublicKey: Uint8Array,256+  ourIdentity: SyncPeerIdentity,243257   expectedRemotePubkeyHex: string,244258   options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},245259 ): Promise<SyncEndpoint | null> {@@ -258,8 +272,7 @@258272 259273     const results = await Promise.all(ips.map((ip) => probeOne(260274       { ip, port },261-      ourLongTermPrivateKey,262-      ourLongTermPublicKey,275+      ourIdentity,263276       expectedRemotePubkeyHex,264277       timeoutMs,265278     )));@@ -271,16 +284,14 @@271284 272285 async function probeOne(273286   endpoint: SyncEndpoint,274-  ourLongTermPrivateKey: Uint8Array,275-  ourLongTermPublicKey: Uint8Array,287+  ourIdentity: SyncPeerIdentity,276288   expectedRemotePubkeyHex: string,277289   timeoutMs: number,278290 ): Promise<SyncEndpoint | null> {279291   try {280292     const session = await openSyncSession(281293       endpoint,282-      ourLongTermPrivateKey,283-      ourLongTermPublicKey,294+      ourIdentity,284295       expectedRemotePubkeyHex,285296       { connectTimeoutMs: timeoutMs },286297     );

view file

src/shared/sync.disconnect.test.ts+14 −15
@@ -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 XX11+// No test verifies that a WebSocket error or close during the Noise NK1212 // 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.@@ -20,9 +20,16 @@2020 2121 import { describe, expect, it, vi } from "vitest";2222 import { generateKeyPair } from "@stablelib/x25519";23-import { openSyncSession } from "./sync-transport";23+import { openSyncSession, type SyncPeerIdentity } from "./sync-transport";2424 import { SYNC_LIMITS } from "./sync-limits";2525 26+const TEST_IDENTITY: SyncPeerIdentity = {27+  deviceId: "test-device",28+  deviceName: "Test",29+  deviceType: "extension",30+  longTermPublicKeyHex: "11".repeat(32),31+};32+2633 // ---------------------------------------------------------------------------2734 // Minimal WebSocket mock2835 // ---------------------------------------------------------------------------@@ -84,7 +91,7 @@8491 describe("T-020: WebSocket sync-disconnect handling", () => {8592   // T-020a: onerror during handshake → session promise rejects, no hang.8693   // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()87-  // to drain the readers queue. Without this, the Noise XX initiator's receive()94+  // to drain the readers queue. Without this, the Noise NK initiator's receive()8895   // would park forever waiting for msg2.8996   //9097   // Bug this catches: removing the onerror → ws.close() bridge causes the@@ -97,14 +104,12 @@97104     });98105     vi.stubGlobal("WebSocket", MockWebSocketCtor);99106 100-    const ourKp = generateKeyPair();101107     const peerKp = generateKeyPair();102108     const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");103109 104110     const sessionPromise = openSyncSession(105111       { ip: "127.0.0.1", port: 9999 },106-      ourKp.secretKey,107-      ourKp.publicKey,112+      TEST_IDENTITY,108113       peerHex,109114     );110115 @@ -139,14 +144,12 @@139144     });140145     vi.stubGlobal("WebSocket", MockWebSocketCtor);141146 142-    const ourKp = generateKeyPair();143147     const peerKp = generateKeyPair();144148     const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");145149 146150     const sessionPromise = openSyncSession(147151       { ip: "127.0.0.1", port: 9999 },148-      ourKp.secretKey,149-      ourKp.publicKey,152+      TEST_IDENTITY,150153       peerHex,151154     );152155 @@ -178,15 +181,13 @@178181     });179182     vi.stubGlobal("WebSocket", MockWebSocketCtor);180183 181-    const ourKp = generateKeyPair();182184     const peerKp = generateKeyPair();183185     const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");184186 185187     // Pass a very short connectTimeoutMs so the test doesn't take 5 seconds186188     const sessionPromise = openSyncSession(187189       { ip: "127.0.0.1", port: 9999 },188-      ourKp.secretKey,189-      ourKp.publicKey,190+      TEST_IDENTITY,190191       peerHex,191192       { connectTimeoutMs: 10 },192193     );@@ -210,14 +211,12 @@210211     });211212     vi.stubGlobal("WebSocket", MockWebSocketCtor);212213 213-    const ourKp = generateKeyPair();214214     const peerKp = generateKeyPair();215215     const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");216216 217217     const sessionPromise = openSyncSession(218218       { ip: "127.0.0.1", port: 9999 },219-      ourKp.secretKey,220-      ourKp.publicKey,219+      TEST_IDENTITY,221220       peerHex,222221     );223222 

view file