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
↓ peach-browser-7fcb0fbb.tar.gzatlas at this save
files changed3 files
src/shared/chirp-sync-transport.ts+224 −0src/shared/noise-xx.test.ts+88 −0src/shared/noise-xx.ts+240 −0src/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+}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+});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+}