chirp-transport.probe.test.ts

9.3 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 "../../shared/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    // Clean up the parked responder receive() promise.142    responderChannel.send(new Uint8Array(0)).catch(() => {});143    await responderPromise;144  });145146  // T-010b: a correct-key responder allows the handshake to complete.147  // This is the happy path — the pin check passes and msg3 is sent normally.148  // Bug this catches: an overly-aggressive pin check that also rejects valid peers.149  it("T-010b: correct-key responder → handshake proceeds to completion", async () => {150    const initiatorKp = generateKeyPair();151    const responderKp = generateKeyPair();152    const expectedHex = pubkeyToHex(responderKp.publicKey);153154    const { a: initiatorChannel, b: responderChannel } = createChannelPair();155156    const responderPromise = noiseXXResponder(157      responderKp.secretKey,158      responderKp.publicKey,159      responderChannel.send,160      responderChannel.receive,161    );162163    const initiatorPromise = noiseXXInitiator(164      initiatorKp.secretKey,165      initiatorKp.publicKey,166      initiatorChannel.send,167      initiatorChannel.receive,168      (remoteStatic) => {169        const hex = pubkeyToHex(remoteStatic);170        if (hex !== expectedHex) {171          throw new Error("unexpected peer");172        }173      },174    );175176    const [initiatorSession, responderSession] = await Promise.all([177      initiatorPromise,178      responderPromise,179    ]);180181    // Both sides get a session — handshake is complete.182    expect(initiatorSession).toBeDefined();183    expect(responderSession).toBeDefined();184185    // Verify the remote static visible to the initiator is the responder's key186    expect(pubkeyToHex(initiatorSession.remoteStaticPublicKey)).toBe(expectedHex);187  });188189  // T-010c: no response from the responder (simulate probe timeout).190  // When the initiator's receive() never resolves, the handshake hangs.191  // This test verifies the initiator's abort path works and no static is sent.192  // In production, openChirpSyncSession wraps this in a connection timeout.193  //194  // Implementation note: we can't easily test a pure timeout here without195  // fake timers and AbortController. This test instead verifies that the196  // initiator does NOT emit msg3 before it hears msg2 — i.e., the handshake197  // is sequential and the initiator never speculatively leaks its static.198  it("T-010c: initiator sends only msg1 before receiving msg2 (no speculative msg3)", async () => {199    const initiatorKp = generateKeyPair();200    const expectedHex = pubkeyToHex(generateKeyPair().publicKey); // any valid hex201202    const sentMessages: Uint8Array[] = [];203    let receiveCallCount = 0;204205    // A send spy that records messages206    const mockSend = async (msg: Uint8Array) => {207      sentMessages.push(msg.slice());208    };209210    // A receive that hangs forever (simulates no peer response)211    const mockReceive = (): Promise<Uint8Array> => {212      receiveCallCount++;213      return new Promise(() => { /* never resolves */ });214    };215216    // Start the initiator — it will send msg1 and then wait for msg2.217    const initiatorPromise = noiseXXInitiator(218      initiatorKp.secretKey,219      initiatorKp.publicKey,220      mockSend,221      mockReceive,222      (remoteStatic) => {223        const hex = pubkeyToHex(remoteStatic);224        if (hex !== expectedHex) throw new Error("wrong peer");225      },226    );227228    // Give the event loop a tick to let the initiator send msg1.229    await new Promise((r) => setTimeout(r, 10));230231    // At this point: msg1 has been sent (initiator's ephemeral), but msg2232    // has not arrived. The initiator must NOT have sent msg3 (our static).233    //234    // msg1 = 32 bytes (ephemeral public key, no AEAD)235    // msg3 = 48 + 16 bytes (encrypted static + encrypted empty payload)236    expect(sentMessages).toHaveLength(1);237    expect(sentMessages[0]).toHaveLength(32); // only msg1 (32-byte ephemeral)238239    // Clean up: the promise hangs; we don't await it.240    initiatorPromise.catch(() => {});241  });242});