chirp-transport.probe.test.ts

9.5 KB · raw

1// T-010: PCH-01-018 subnet-probe identity-leak integration2//3// Why this test exists: PCH-01-018 added a static-key pin check to the Noise4// XX initiator that fires BEFORE msg3 is sent. msg3 contains the initiator's5// long-term static public key (our sync identity). If the responder's static6// doesn't match the expected paired-device key, the handshake must abort7// WITHOUT ever transmitting msg3. The pin check is tested in isolation in8// chirp-sync-transport.test.ts, but no integration test verifies the end-to-end9// "wrong peer → no msg3" guarantee.10//11// Approach: wire noiseXXInitiator and noiseXXResponder together through12// in-memory channels. The responder uses a DIFFERENT static keypair than13// expected. We spy on the channel to confirm the initiator never sends a14// third message (msg3 would be the initiator's encrypted static key).1516import { describe, expect, it } from "vitest";17import { generateKeyPair } from "@stablelib/x25519";18import {19  noiseXXInitiator,20  noiseXXResponder,21} from "./noise-xx";2223// ---------------------------------------------------------------------------24// In-memory bidirectional channel25// ---------------------------------------------------------------------------2627interface Channel {28  send: (msg: Uint8Array) => Promise<void>;29  receive: () => Promise<Uint8Array>;30}3132function createChannelPair(): { a: Channel; b: Channel } {33  const aToB: Uint8Array[] = [];34  const bToA: Uint8Array[] = [];35  const aToBWaiters: Array<(v: Uint8Array) => void> = [];36  const bToAWaiters: Array<(v: Uint8Array) => void> = [];3738  return {39    a: {40      send: async (msg) => {41        const waiter = aToBWaiters.shift();42        if (waiter) waiter(msg.slice());43        else aToB.push(msg.slice());44      },45      receive: () => {46        const msg = bToA.shift();47        if (msg) return Promise.resolve(msg);48        return new Promise((resolve) => bToAWaiters.push(resolve));49      },50    },51    b: {52      send: async (msg) => {53        const waiter = bToAWaiters.shift();54        if (waiter) waiter(msg.slice());55        else bToA.push(msg.slice());56      },57      receive: () => {58        const msg = aToB.shift();59        if (msg) return Promise.resolve(msg);60        return new Promise((resolve) => aToBWaiters.push(resolve));61      },62    },63  };64}6566function pubkeyToHex(key: Uint8Array): string {67  return Array.from(key).map(b => b.toString(16).padStart(2, "0")).join("");68}6970// ---------------------------------------------------------------------------71// Tests72// ---------------------------------------------------------------------------7374describe("T-010: PCH-01-018 subnet-probe identity-leak integration", () => {75  // T-010a: the initiator aborts before msg3 when the responder's static76  // doesn't match the expected paired-device key.77  //78  // Key assertion: the responder never receives a third message. msg1 and msg279  // are exchanged (the Noise XX pre-msg3 handshake), but msg3 — which contains80  // the initiator's long-term static — is never sent.81  //82  // Bug this catches: if verifyRemoteStatic is removed from noiseXXInitiator,83  // the initiator would send msg3 (exposing our static key to an unknown peer)84  // and the responder would complete the handshake.85  it("T-010a: wrong-key responder → initiator aborts before msg3; initiator static not transmitted", async () => {86    // Initiator (our extension's sync identity)87    const initiatorKp = generateKeyPair();88    // Responder (what we EXPECT to talk to)89    const expectedResponderKp = generateKeyPair();90    // Actual responder (a different device on the LAN — the attacker scenario)91    const wrongResponderKp = generateKeyPair();9293    const expectedHex = pubkeyToHex(expectedResponderKp.publicKey);94    const { a: initiatorChannel, b: responderChannel } = createChannelPair();9596    // Track how many messages the responder receives97    let responderMsgCount = 0;98    const wrappedResponderReceive = async (): Promise<Uint8Array> => {99      const msg = await responderChannel.receive();100      responderMsgCount++;101      return msg;102    };103104    // Run the responder in the background using the WRONG static key.105    // It will send msg2 with its wrong static, then wait for msg3.106    const responderPromise = noiseXXResponder(107      wrongResponderKp.secretKey,108      wrongResponderKp.publicKey,109      responderChannel.send,110      wrappedResponderReceive,111    ).catch(() => { /* responder may error when msg3 never arrives */ });112113    // Run the initiator with a verifyRemoteStatic check against the EXPECTED key.114    const initiatorPromise = noiseXXInitiator(115      initiatorKp.secretKey,116      initiatorKp.publicKey,117      initiatorChannel.send,118      initiatorChannel.receive,119      (remoteStatic) => {120        const hex = pubkeyToHex(remoteStatic);121        if (hex !== expectedHex) {122          throw new Error(123            `PCH-01-018: unexpected peer static (got ${hex.slice(0, 8)}…, ` +124            `expected ${expectedHex.slice(0, 8)}…)`,125          );126        }127      },128    );129130    // The initiator must throw (pin check failed), not succeed.131    await expect(initiatorPromise).rejects.toThrow(/unexpected peer static|PCH-01-018/i);132133    // The responder received msg1 (the initiator's ephemeral) and sent msg2.134    // After that the initiator aborts — msg3 is NEVER sent. The responder135    // had its receive() called once (for msg1); it never receives msg3.136    //137    // responderMsgCount === 1: the responder only received msg1.138    // If it were 2, the initiator sent msg3 — our static was leaked.139    expect(responderMsgCount).toBe(1);140141    // Unblock the parked responder receive() — the responder is waiting142    // for msg3 via b.receive() which reads from the a→b queue. To unblock143    // it we must push into that queue via initiatorChannel.send (a.send).144    initiatorChannel.send(new Uint8Array(0)).catch(() => {});145    await responderPromise;146  });147148  // T-010b: a correct-key responder allows the handshake to complete.149  // This is the happy path — the pin check passes and msg3 is sent normally.150  // Bug this catches: an overly-aggressive pin check that also rejects valid peers.151  it("T-010b: correct-key responder → handshake proceeds to completion", async () => {152    const initiatorKp = generateKeyPair();153    const responderKp = generateKeyPair();154    const expectedHex = pubkeyToHex(responderKp.publicKey);155156    const { a: initiatorChannel, b: responderChannel } = createChannelPair();157158    const responderPromise = noiseXXResponder(159      responderKp.secretKey,160      responderKp.publicKey,161      responderChannel.send,162      responderChannel.receive,163    );164165    const initiatorPromise = noiseXXInitiator(166      initiatorKp.secretKey,167      initiatorKp.publicKey,168      initiatorChannel.send,169      initiatorChannel.receive,170      (remoteStatic) => {171        const hex = pubkeyToHex(remoteStatic);172        if (hex !== expectedHex) {173          throw new Error("unexpected peer");174        }175      },176    );177178    const [initiatorSession, responderSession] = await Promise.all([179      initiatorPromise,180      responderPromise,181    ]);182183    // Both sides get a session — handshake is complete.184    expect(initiatorSession).toBeDefined();185    expect(responderSession).toBeDefined();186187    // Verify the remote static visible to the initiator is the responder's key188    expect(pubkeyToHex(initiatorSession.remoteStaticPublicKey)).toBe(expectedHex);189  });190191  // T-010c: no response from the responder (simulate probe timeout).192  // When the initiator's receive() never resolves, the handshake hangs.193  // This test verifies the initiator's abort path works and no static is sent.194  // In production, openChirpSyncSession wraps this in a connection timeout.195  //196  // Implementation note: we can't easily test a pure timeout here without197  // fake timers and AbortController. This test instead verifies that the198  // initiator does NOT emit msg3 before it hears msg2 — i.e., the handshake199  // is sequential and the initiator never speculatively leaks its static.200  it("T-010c: initiator sends only msg1 before receiving msg2 (no speculative msg3)", async () => {201    const initiatorKp = generateKeyPair();202    const expectedHex = pubkeyToHex(generateKeyPair().publicKey); // any valid hex203204    const sentMessages: Uint8Array[] = [];205    let receiveCallCount = 0;206207    // A send spy that records messages208    const mockSend = async (msg: Uint8Array) => {209      sentMessages.push(msg.slice());210    };211212    // A receive that hangs forever (simulates no peer response)213    const mockReceive = (): Promise<Uint8Array> => {214      receiveCallCount++;215      return new Promise(() => { /* never resolves */ });216    };217218    // Start the initiator — it will send msg1 and then wait for msg2.219    const initiatorPromise = noiseXXInitiator(220      initiatorKp.secretKey,221      initiatorKp.publicKey,222      mockSend,223      mockReceive,224      (remoteStatic) => {225        const hex = pubkeyToHex(remoteStatic);226        if (hex !== expectedHex) throw new Error("wrong peer");227      },228    );229230    // Give the event loop a tick to let the initiator send msg1.231    await new Promise((r) => setTimeout(r, 10));232233    // At this point: msg1 has been sent (initiator's ephemeral), but msg2234    // has not arrived. The initiator must NOT have sent msg3 (our static).235    //236    // msg1 = 32 bytes (ephemeral public key, no AEAD)237    // msg3 = 48 + 16 bytes (encrypted static + encrypted empty payload)238    expect(sentMessages).toHaveLength(1);239    expect(sentMessages[0]).toHaveLength(32); // only msg1 (32-byte ephemeral)240241    // Clean up: the promise hangs; we don't await it.242    initiatorPromise.catch(() => {});243  });244});