// T-010: PCH-01-018 subnet-probe identity-leak integration // // Why this test exists: PCH-01-018 added a static-key pin check to the Noise // XX initiator that fires BEFORE msg3 is sent. msg3 contains the initiator's // long-term static public key (our sync identity). If the responder's static // doesn't match the expected paired-device key, the handshake must abort // WITHOUT ever transmitting msg3. The pin check is tested in isolation in // chirp-sync-transport.test.ts, but no integration test verifies the end-to-end // "wrong peer → no msg3" guarantee. // // Approach: wire noiseXXInitiator and noiseXXResponder together through // in-memory channels. The responder uses a DIFFERENT static keypair than // expected. We spy on the channel to confirm the initiator never sends a // third message (msg3 would be the initiator's encrypted static key). import { describe, expect, it } from "vitest"; import { generateKeyPair } from "@stablelib/x25519"; import { noiseXXInitiator, noiseXXResponder, } from "./noise-xx"; // --------------------------------------------------------------------------- // In-memory bidirectional channel // --------------------------------------------------------------------------- interface Channel { send: (msg: Uint8Array) => Promise; receive: () => Promise; } function createChannelPair(): { a: Channel; b: Channel } { const aToB: Uint8Array[] = []; const bToA: Uint8Array[] = []; const aToBWaiters: Array<(v: Uint8Array) => void> = []; const bToAWaiters: Array<(v: Uint8Array) => void> = []; return { a: { send: async (msg) => { const waiter = aToBWaiters.shift(); if (waiter) waiter(msg.slice()); else aToB.push(msg.slice()); }, receive: () => { const msg = bToA.shift(); if (msg) return Promise.resolve(msg); return new Promise((resolve) => bToAWaiters.push(resolve)); }, }, b: { send: async (msg) => { const waiter = bToAWaiters.shift(); if (waiter) waiter(msg.slice()); else bToA.push(msg.slice()); }, receive: () => { const msg = aToB.shift(); if (msg) return Promise.resolve(msg); return new Promise((resolve) => aToBWaiters.push(resolve)); }, }, }; } function pubkeyToHex(key: Uint8Array): string { return Array.from(key).map(b => b.toString(16).padStart(2, "0")).join(""); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-010: PCH-01-018 subnet-probe identity-leak integration", () => { // T-010a: the initiator aborts before msg3 when the responder's static // doesn't match the expected paired-device key. // // Key assertion: the responder never receives a third message. msg1 and msg2 // are exchanged (the Noise XX pre-msg3 handshake), but msg3 — which contains // the initiator's long-term static — is never sent. // // Bug this catches: if verifyRemoteStatic is removed from noiseXXInitiator, // the initiator would send msg3 (exposing our static key to an unknown peer) // and the responder would complete the handshake. it("T-010a: wrong-key responder → initiator aborts before msg3; initiator static not transmitted", async () => { // Initiator (our extension's sync identity) const initiatorKp = generateKeyPair(); // Responder (what we EXPECT to talk to) const expectedResponderKp = generateKeyPair(); // Actual responder (a different device on the LAN — the attacker scenario) const wrongResponderKp = generateKeyPair(); const expectedHex = pubkeyToHex(expectedResponderKp.publicKey); const { a: initiatorChannel, b: responderChannel } = createChannelPair(); // Track how many messages the responder receives let responderMsgCount = 0; const wrappedResponderReceive = async (): Promise => { const msg = await responderChannel.receive(); responderMsgCount++; return msg; }; // Run the responder in the background using the WRONG static key. // It will send msg2 with its wrong static, then wait for msg3. const responderPromise = noiseXXResponder( wrongResponderKp.secretKey, wrongResponderKp.publicKey, responderChannel.send, wrappedResponderReceive, ).catch(() => { /* responder may error when msg3 never arrives */ }); // Run the initiator with a verifyRemoteStatic check against the EXPECTED key. const initiatorPromise = noiseXXInitiator( initiatorKp.secretKey, initiatorKp.publicKey, initiatorChannel.send, initiatorChannel.receive, (remoteStatic) => { const hex = pubkeyToHex(remoteStatic); if (hex !== expectedHex) { throw new Error( `PCH-01-018: unexpected peer static (got ${hex.slice(0, 8)}…, ` + `expected ${expectedHex.slice(0, 8)}…)`, ); } }, ); // The initiator must throw (pin check failed), not succeed. await expect(initiatorPromise).rejects.toThrow(/unexpected peer static|PCH-01-018/i); // The responder received msg1 (the initiator's ephemeral) and sent msg2. // After that the initiator aborts — msg3 is NEVER sent. The responder // had its receive() called once (for msg1); it never receives msg3. // // responderMsgCount === 1: the responder only received msg1. // If it were 2, the initiator sent msg3 — our static was leaked. expect(responderMsgCount).toBe(1); // Unblock the parked responder receive() — the responder is waiting // for msg3 via b.receive() which reads from the a→b queue. To unblock // it we must push into that queue via initiatorChannel.send (a.send). initiatorChannel.send(new Uint8Array(0)).catch(() => {}); await responderPromise; }); // T-010b: a correct-key responder allows the handshake to complete. // This is the happy path — the pin check passes and msg3 is sent normally. // Bug this catches: an overly-aggressive pin check that also rejects valid peers. it("T-010b: correct-key responder → handshake proceeds to completion", async () => { const initiatorKp = generateKeyPair(); const responderKp = generateKeyPair(); const expectedHex = pubkeyToHex(responderKp.publicKey); const { a: initiatorChannel, b: responderChannel } = createChannelPair(); const responderPromise = noiseXXResponder( responderKp.secretKey, responderKp.publicKey, responderChannel.send, responderChannel.receive, ); const initiatorPromise = noiseXXInitiator( initiatorKp.secretKey, initiatorKp.publicKey, initiatorChannel.send, initiatorChannel.receive, (remoteStatic) => { const hex = pubkeyToHex(remoteStatic); if (hex !== expectedHex) { throw new Error("unexpected peer"); } }, ); const [initiatorSession, responderSession] = await Promise.all([ initiatorPromise, responderPromise, ]); // Both sides get a session — handshake is complete. expect(initiatorSession).toBeDefined(); expect(responderSession).toBeDefined(); // Verify the remote static visible to the initiator is the responder's key expect(pubkeyToHex(initiatorSession.remoteStaticPublicKey)).toBe(expectedHex); }); // T-010c: no response from the responder (simulate probe timeout). // When the initiator's receive() never resolves, the handshake hangs. // This test verifies the initiator's abort path works and no static is sent. // In production, openChirpSyncSession wraps this in a connection timeout. // // Implementation note: we can't easily test a pure timeout here without // fake timers and AbortController. This test instead verifies that the // initiator does NOT emit msg3 before it hears msg2 — i.e., the handshake // is sequential and the initiator never speculatively leaks its static. it("T-010c: initiator sends only msg1 before receiving msg2 (no speculative msg3)", async () => { const initiatorKp = generateKeyPair(); const expectedHex = pubkeyToHex(generateKeyPair().publicKey); // any valid hex const sentMessages: Uint8Array[] = []; let receiveCallCount = 0; // A send spy that records messages const mockSend = async (msg: Uint8Array) => { sentMessages.push(msg.slice()); }; // A receive that hangs forever (simulates no peer response) const mockReceive = (): Promise => { receiveCallCount++; return new Promise(() => { /* never resolves */ }); }; // Start the initiator — it will send msg1 and then wait for msg2. const initiatorPromise = noiseXXInitiator( initiatorKp.secretKey, initiatorKp.publicKey, mockSend, mockReceive, (remoteStatic) => { const hex = pubkeyToHex(remoteStatic); if (hex !== expectedHex) throw new Error("wrong peer"); }, ); // Give the event loop a tick to let the initiator send msg1. await new Promise((r) => setTimeout(r, 10)); // At this point: msg1 has been sent (initiator's ephemeral), but msg2 // has not arrived. The initiator must NOT have sent msg3 (our static). // // msg1 = 32 bytes (ephemeral public key, no AEAD) // msg3 = 48 + 16 bytes (encrypted static + encrypted empty payload) expect(sentMessages).toHaveLength(1); expect(sentMessages[0]).toHaveLength(32); // only msg1 (32-byte ephemeral) // Clean up: the promise hangs; we don't await it. initiatorPromise.catch(() => {}); }); });