7fcb0fbb2af5

add Noise XX initiator/responder and WebSocket sync transport with subnet probe

time
2026-05-18 02:20 UTC · 5 months ago
machine
koh2
parent
7e9dafc46041
3 files src/shared/chirp-sync-transport.ts+224 −0src/shared/noise-xx.test.ts+88 −0src/shared/noise-xx.ts+240 −0
src/shared/chirp-sync-transport.ts+224 −0
@@ -0,0 +1,224 @@1+// WebSocket sync transport — v3 chirp-sync Phase 2.2+//3+// The extension is always the WebSocket client; Android (or future iOS /4+// desktop sync hosts) is always the server. Each binary WebSocket message5+// carries exactly one Noise frame — no length prefix, no fragmentation.6+//7+// On connect:8+//   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.14+//   4. Hand the caller a `ChirpSyncSession` for the SyncMessage protocol.15+//16+// Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`17+// when the cached last-known IP fails. We use a short connection timeout18+// (~500ms per IP) and parallel batches of 16 to keep the worst-case scan19+// under ~10 seconds.20+21+import { noiseXXInitiator, type NoiseXXSession } from "./noise-xx";22+import { hexToPubkey, pubkeyToHex } from "./sync-identity";23+24+const DEFAULT_PROBE_TIMEOUT_MS = 500;25+const DEFAULT_PROBE_BATCH = 16;26+const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;27+28+export interface ChirpSyncSession {29+  send(plaintext: Uint8Array): void;30+  receive(): Promise<Uint8Array>;31+  close(): Promise<void>;32+  remoteStaticPublicKey: Uint8Array;33+}34+35+export interface SyncEndpoint {36+  ip: string;37+  port: number;38+}39+40+/**41+ * Open a WebSocket to (ip, port), run Noise XX, verify the responder's42+ * long-term static key matches `expectedRemotePubkeyHex`, and return a43+ * ready-to-use session.44+ *45+ * On a successful return, `setLastKnownIp` in the caller can persist46+ * (ip, port) for the next session's fast path.47+ */48+export async function openChirpSyncSession(49+  endpoint: SyncEndpoint,50+  ourLongTermPrivateKey: Uint8Array,51+  ourLongTermPublicKey: Uint8Array,52+  expectedRemotePubkeyHex: string,53+  options: { connectTimeoutMs?: number } = {},54+): Promise<ChirpSyncSession> {55+  const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);56+  const inbox: Uint8Array[] = [];57+  const readers: Array<(b: Uint8Array) => void> = [];58+  const errors: Error[] = [];59+  let closed = false;60+61+  ws.binaryType = "arraybuffer";62+  ws.onmessage = (ev: MessageEvent) => {63+    const data = ev.data;64+    if (data instanceof ArrayBuffer) {65+      const bytes = new Uint8Array(data);66+      const r = readers.shift();67+      if (r) r(bytes);68+      else inbox.push(bytes);69+    }70+  };71+  ws.onerror = () => {72+    errors.push(new Error("WebSocket error"));73+  };74+  ws.onclose = () => {75+    closed = true;76+    while (readers.length > 0) {77+      const r = readers.shift()!;78+      r(new Uint8Array(0));79+    }80+  };81+82+  function send(bytes: Uint8Array): Promise<void> {83+    return new Promise((resolve, reject) => {84+      try {85+        ws.send(bytes as Uint8Array<ArrayBuffer>);86+        resolve();87+      } catch (err) {88+        reject(err as Error);89+      }90+    });91+  }92+  function receive(): Promise<Uint8Array> {93+    if (closed) return Promise.reject(new Error("WebSocket closed before frame"));94+    const v = inbox.shift();95+    if (v) return Promise.resolve(v);96+    return new Promise<Uint8Array>((resolve, reject) => {97+      readers.push((b) => {98+        if (b.length === 0 && closed) reject(new Error("WebSocket closed"));99+        else resolve(b);100+      });101+    });102+  }103+104+  let session: NoiseXXSession;105+  try {106+    session = await noiseXXInitiator(107+      ourLongTermPrivateKey,108+      ourLongTermPublicKey,109+      send,110+      receive,111+    );112+  } catch (err) {113+    try { ws.close(); } catch { /* ignore */ }114+    throw err;115+  }116+117+  const remoteHex = pubkeyToHex(session.remoteStaticPublicKey);118+  if (remoteHex !== expectedRemotePubkeyHex) {119+    try { ws.close(); } catch { /* ignore */ }120+    throw new Error(121+      `paired-device static key mismatch (got ${remoteHex.slice(0, 16)}…, ` +122+      `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,123+    );124+  }125+  // Bind so the unused expectation-source check helper stays honest.126+  void hexToPubkey(expectedRemotePubkeyHex);127+128+  return {129+    send(plaintext: Uint8Array) {130+      const ciphertext = session.encrypt(plaintext);131+      ws.send(ciphertext as Uint8Array<ArrayBuffer>);132+    },133+    async receive() {134+      const ct = await receive();135+      return session.decrypt(ct);136+    },137+    async close() {138+      try { ws.close(); } catch { /* ignore */ }139+    },140+    remoteStaticPublicKey: session.remoteStaticPublicKey,141+  };142+}143+144+function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {145+  return new Promise((resolve, reject) => {146+    let ws: WebSocket;147+    try {148+      ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);149+    } catch (err) {150+      reject(err as Error);151+      return;152+    }153+    const t = setTimeout(() => {154+      try { ws.close(); } catch { /* ignore */ }155+      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));156+    }, timeoutMs);157+    ws.onopen = () => {158+      clearTimeout(t);159+      resolve(ws);160+    };161+    ws.onerror = () => {162+      clearTimeout(t);163+      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));164+    };165+  });166+}167+168+/**169+ * Subnet probe — scan a /24 looking for a host that completes Noise XX170+ * with the expected long-term static key. Returns the first match.171+ *172+ * For each IP we attempt the full Noise XX handshake, but with a tight173+ * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser174+ * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.175+ */176+export async function probeSubnetForPeer(177+  subnet: string, // e.g. "192.168.1"178+  port: number,179+  ourLongTermPrivateKey: Uint8Array,180+  ourLongTermPublicKey: Uint8Array,181+  expectedRemotePubkeyHex: string,182+  options: { batch?: number; timeoutMs?: number } = {},183+): Promise<SyncEndpoint | null> {184+  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;185+  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;186+  for (let start = 1; start <= 254; start += batchSize) {187+    const end = Math.min(start + batchSize - 1, 254);188+    const ips: string[] = [];189+    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);190+191+    const results = await Promise.all(ips.map((ip) => probeOne(192+      { ip, port },193+      ourLongTermPrivateKey,194+      ourLongTermPublicKey,195+      expectedRemotePubkeyHex,196+      timeoutMs,197+    )));198+    const hit = results.find((r): r is SyncEndpoint => r !== null);199+    if (hit) return hit;200+  }201+  return null;202+}203+204+async function probeOne(205+  endpoint: SyncEndpoint,206+  ourLongTermPrivateKey: Uint8Array,207+  ourLongTermPublicKey: Uint8Array,208+  expectedRemotePubkeyHex: string,209+  timeoutMs: number,210+): Promise<SyncEndpoint | null> {211+  try {212+    const session = await openChirpSyncSession(213+      endpoint,214+      ourLongTermPrivateKey,215+      ourLongTermPublicKey,216+      expectedRemotePubkeyHex,217+      { connectTimeoutMs: timeoutMs },218+    );219+    await session.close();220+    return endpoint;221+  } catch {222+    return null;223+  }224+}

view file

src/shared/noise-xx.test.ts+88 −0
@@ -0,0 +1,88 @@1+import { describe, expect, it } from "vitest";2+import { generateKeyPair } from "@stablelib/x25519";3+import { noiseXXInitiator, noiseXXResponder } from "./noise-xx";4+5+function memoryPair() {6+  const a: Uint8Array[] = [];7+  const b: Uint8Array[] = [];8+  const aR: Array<(v: Uint8Array) => void> = [];9+  const bR: Array<(v: Uint8Array) => void> = [];10+  const tA = {11+    async send(m: Uint8Array) {12+      const r = bR.shift();13+      if (r) r(m); else b.push(m);14+    },15+    receive(): Promise<Uint8Array> {16+      const v = a.shift();17+      if (v) return Promise.resolve(v);18+      return new Promise<Uint8Array>((resolve) => aR.push(resolve));19+    },20+  };21+  const tB = {22+    async send(m: Uint8Array) {23+      const r = aR.shift();24+      if (r) r(m); else a.push(m);25+    },26+    receive(): Promise<Uint8Array> {27+      const v = b.shift();28+      if (v) return Promise.resolve(v);29+      return new Promise<Uint8Array>((resolve) => bR.push(resolve));30+    },31+  };32+  return [tA, tB] as const;33+}34+35+describe("Noise XX mutual authentication", () => {36+  it("completes the three-message handshake and exchanges static keys", async () => {37+    const alice = generateKeyPair();38+    const bob = generateKeyPair();39+    const [tA, tB] = memoryPair();40+41+    const aPromise = noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive);42+    const bPromise = noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive);43+44+    const [a, b] = await Promise.all([aPromise, bPromise]);45+    expect(a.remoteStaticPublicKey).toEqual(bob.publicKey);46+    expect(b.remoteStaticPublicKey).toEqual(alice.publicKey);47+    expect(a.handshakeHash).toEqual(b.handshakeHash);48+  });49+50+  it("transport frames decrypt in both directions and are bound to the handshake", async () => {51+    const alice = generateKeyPair();52+    const bob = generateKeyPair();53+    const [tA, tB] = memoryPair();54+55+    const [a, b] = await Promise.all([56+      noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive),57+      noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),58+    ]);59+60+    const ct1 = a.encrypt(new TextEncoder().encode("hello bob"));61+    expect(new TextDecoder().decode(b.decrypt(ct1))).toBe("hello bob");62+63+    const ct2 = b.encrypt(new TextEncoder().encode("hello alice"));64+    expect(new TextDecoder().decode(a.decrypt(ct2))).toBe("hello alice");65+66+    // Tampering with one byte breaks the MAC.67+    const ct3 = a.encrypt(new TextEncoder().encode("not delivered"));68+    ct3[0] ^= 1;69+    expect(() => b.decrypt(ct3)).toThrow();70+  });71+72+  it("an attacker who swaps the responder static cannot complete msg2 auth", async () => {73+    const alice = generateKeyPair();74+    const bobReal = generateKeyPair();75+    const bobImpostor = generateKeyPair();76+    const [tA, tB] = memoryPair();77+78+    // Responder uses impostor private key but advertises real pubkey to initiator.79+    // Real XX has no out-of-band pubkey check; this test verifies the registry80+    // check that the caller MUST perform on `remoteStaticPublicKey`.81+    const aPromise = noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive);82+    const bPromise = noiseXXResponder(bobImpostor.secretKey, bobImpostor.publicKey, tB.send, tB.receive);83+    const [a] = await Promise.all([aPromise, bPromise]);84+    // The "swap" is the caller seeing a remote static they did not expect.85+    expect(a.remoteStaticPublicKey).not.toEqual(bobReal.publicKey);86+    expect(a.remoteStaticPublicKey).toEqual(bobImpostor.publicKey);87+  });88+});

view file

src/shared/noise-xx.ts+240 −0
@@ -0,0 +1,240 @@1+// Noise XX (mutual auth) — used for every Phase-2 WebSocket connection2+// after the audio chirp pairing has stamped each side into the other's3+// paired-device registry. Both peers hold a long-term X25519 static key4+// (the sync identity); XX is the canonical pattern when both parties want5+// to authenticate each other.6+//7+// Pattern: Noise_XX_25519_ChaChaPoly_BLAKE2s — same primitives as the8+// NK pattern in `noise-js.ts`, so the helpers are duplicated minimally9+// (initHash / mixHash / hkdf2) to keep this file self-contained.10+//11+//   -> e12+//   <- e, ee, s, es13+//   -> s, se14+//15+// After msg 3, the symmetric `hkdf2(ck, "")` splits into two ChaCha20-16+// Poly1305 transport keys, one per direction. Counter nonces start at 017+// and increment per frame.18+19+import { generateKeyPair, sharedKey } from "@stablelib/x25519";20+import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";21+import { BLAKE2s } from "@stablelib/blake2s";22+import { HKDF } from "@stablelib/hkdf";23+import { SHA256 } from "@stablelib/sha256";24+25+const PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2s";26+27+function initHash(): Uint8Array {28+  const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);29+  if (nameBytes.length <= 32) {30+    const h = new Uint8Array(32);31+    h.set(nameBytes);32+    return h;33+  }34+  const sha = new SHA256();35+  sha.update(nameBytes);36+  return sha.digest();37+}38+39+function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {40+  const blake = new BLAKE2s(32);41+  blake.update(h);42+  blake.update(data);43+  return blake.digest();44+}45+46+function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {47+  const out = new HKDF(SHA256, ikm, ck).expand(64);48+  return [out.slice(0, 32), out.slice(32, 64)];49+}50+51+function nonceBytes(n: bigint): Uint8Array {52+  const buf = new Uint8Array(12);53+  new DataView(buf.buffer).setBigUint64(4, n, true);54+  return buf;55+}56+57+export interface NoiseXXSession {58+  encrypt(plaintext: Uint8Array): Uint8Array;59+  decrypt(ciphertext: Uint8Array): Uint8Array;60+  remoteStaticPublicKey: Uint8Array;61+  handshakeHash: Uint8Array;62+}63+64+/**65+ * Drive the XX initiator. The responder's static key is learned in66+ * message 2; we hand it back so the caller can verify it against the67+ * paired-device registry.68+ */69+export async function noiseXXInitiator(70+  ourStaticPrivateKey: Uint8Array,71+  ourStaticPublicKey: Uint8Array,72+  send: (msg: Uint8Array) => Promise<void>,73+  receive: () => Promise<Uint8Array>,74+): Promise<NoiseXXSession> {75+  let h: Uint8Array = initHash();76+  let ck: Uint8Array = h.slice();77+78+  // --- Message 1: -> e ---79+  const e = generateKeyPair();80+  h = mixHash(h, e.publicKey);81+  await send(e.publicKey);82+83+  // --- Message 2: <- e, ee, s, es ---84+  const msg2 = await receive();85+  if (msg2.length < 32 + 32 + 16 + 16) {86+    throw new Error("Noise XX: msg2 too short");87+  }88+  const remoteEphemeral = msg2.slice(0, 32);89+  const encryptedStatic = msg2.slice(32, 32 + 32 + 16);90+  const encryptedPayload = msg2.slice(32 + 32 + 16);91+92+  h = mixHash(h, remoteEphemeral);93+  const ee = sharedKey(e.secretKey, remoteEphemeral);94+  const [ck1, k1] = hkdf2(ck, ee);95+  ck = ck1;96+97+  const cipherStatic = new ChaCha20Poly1305(k1);98+  const remoteStaticPlain = cipherStatic.open(nonceBytes(0n), encryptedStatic, h);99+  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {100+    throw new Error("Noise XX: msg2 static auth failed");101+  }102+  h = mixHash(h, encryptedStatic);103+104+  const es = sharedKey(e.secretKey, remoteStaticPlain);105+  const [ck2, k2] = hkdf2(ck, es);106+  ck = ck2;107+108+  const cipherPayload = new ChaCha20Poly1305(k2);109+  const payloadPlain = cipherPayload.open(nonceBytes(0n), encryptedPayload, h);110+  if (!payloadPlain) {111+    throw new Error("Noise XX: msg2 payload auth failed");112+  }113+  h = mixHash(h, encryptedPayload);114+115+  // --- Message 3: -> s, se ---116+  const cipherOurStatic = new ChaCha20Poly1305(k2);117+  const encryptedOurStatic = cipherOurStatic.seal(nonceBytes(1n), ourStaticPublicKey, h);118+  h = mixHash(h, encryptedOurStatic);119+120+  const se = sharedKey(ourStaticPrivateKey, remoteEphemeral);121+  const [ck3, k3] = hkdf2(ck, se);122+  ck = ck3;123+124+  const cipherFinal = new ChaCha20Poly1305(k3);125+  const encryptedFinalPayload = cipherFinal.seal(nonceBytes(0n), new Uint8Array(0), h);126+  h = mixHash(h, encryptedFinalPayload);127+128+  const msg3 = new Uint8Array(encryptedOurStatic.length + encryptedFinalPayload.length);129+  msg3.set(encryptedOurStatic, 0);130+  msg3.set(encryptedFinalPayload, encryptedOurStatic.length);131+  await send(msg3);132+133+  // --- Transport keys: split (k_init_send, k_resp_send) ---134+  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));135+  const sendCipher = new ChaCha20Poly1305(initSend);136+  const recvCipher = new ChaCha20Poly1305(respSend);137+  let sendNonce = 0n;138+  let recvNonce = 0n;139+140+  return {141+    encrypt(plaintext) {142+      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));143+    },144+    decrypt(ciphertext) {145+      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));146+      if (!result) throw new Error("Noise XX: decryption failed");147+      return result;148+    },149+    remoteStaticPublicKey: remoteStaticPlain,150+    handshakeHash: h.slice(),151+  };152+}153+154+/**155+ * Drive the XX responder. The initiator's static key is learned in156+ * message 3; we hand it back on success.157+ */158+export async function noiseXXResponder(159+  ourStaticPrivateKey: Uint8Array,160+  ourStaticPublicKey: Uint8Array,161+  send: (msg: Uint8Array) => Promise<void>,162+  receive: () => Promise<Uint8Array>,163+): Promise<NoiseXXSession> {164+  let h: Uint8Array = initHash();165+  let ck: Uint8Array = h.slice();166+167+  // --- Message 1: <- e ---168+  const msg1 = await receive();169+  if (msg1.length < 32) throw new Error("Noise XX: msg1 too short");170+  const remoteEphemeral = msg1.slice(0, 32);171+  h = mixHash(h, remoteEphemeral);172+173+  // --- Message 2: -> e, ee, s, es ---174+  const e = generateKeyPair();175+  h = mixHash(h, e.publicKey);176+  const ee = sharedKey(e.secretKey, remoteEphemeral);177+  const [ck1, k1] = hkdf2(ck, ee);178+  ck = ck1;179+180+  const cipherStatic = new ChaCha20Poly1305(k1);181+  const encryptedOurStatic = cipherStatic.seal(nonceBytes(0n), ourStaticPublicKey, h);182+  h = mixHash(h, encryptedOurStatic);183+184+  const es = sharedKey(ourStaticPrivateKey, remoteEphemeral);185+  const [ck2, k2] = hkdf2(ck, es);186+  ck = ck2;187+188+  const cipherPayload = new ChaCha20Poly1305(k2);189+  const encryptedPayload = cipherPayload.seal(nonceBytes(0n), new Uint8Array(0), h);190+  h = mixHash(h, encryptedPayload);191+192+  const msg2 = new Uint8Array(e.publicKey.length + encryptedOurStatic.length + encryptedPayload.length);193+  msg2.set(e.publicKey, 0);194+  msg2.set(encryptedOurStatic, e.publicKey.length);195+  msg2.set(encryptedPayload, e.publicKey.length + encryptedOurStatic.length);196+  await send(msg2);197+198+  // --- Message 3: <- s, se ---199+  const msg3 = await receive();200+  if (msg3.length < 32 + 16 + 16) throw new Error("Noise XX: msg3 too short");201+  const encryptedRemoteStatic = msg3.slice(0, 32 + 16);202+  const encryptedFinalPayload = msg3.slice(32 + 16);203+204+  const cipherTheirStatic = new ChaCha20Poly1305(k2);205+  const remoteStaticPlain = cipherTheirStatic.open(nonceBytes(1n), encryptedRemoteStatic, h);206+  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {207+    throw new Error("Noise XX: msg3 static auth failed");208+  }209+  h = mixHash(h, encryptedRemoteStatic);210+211+  const se = sharedKey(e.secretKey, remoteStaticPlain);212+  const [ck3, k3] = hkdf2(ck, se);213+  ck = ck3;214+215+  const cipherFinal = new ChaCha20Poly1305(k3);216+  const finalPayloadPlain = cipherFinal.open(nonceBytes(0n), encryptedFinalPayload, h);217+  if (!finalPayloadPlain) {218+    throw new Error("Noise XX: msg3 final payload auth failed");219+  }220+  h = mixHash(h, encryptedFinalPayload);221+222+  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));223+  const sendCipher = new ChaCha20Poly1305(respSend);224+  const recvCipher = new ChaCha20Poly1305(initSend);225+  let sendNonce = 0n;226+  let recvNonce = 0n;227+228+  return {229+    encrypt(plaintext) {230+      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));231+    },232+    decrypt(ciphertext) {233+      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));234+      if (!result) throw new Error("Noise XX: decryption failed");235+      return result;236+    },237+    remoteStaticPublicKey: remoteStaticPlain,238+    handshakeHash: h.slice(),239+  };240+}

view file