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
↓ peach-browser-66a788d0.tar.gzatlas at this save
files changed4 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 −15src/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 }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 );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 );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