cf887cb18434

fix: subnet probe pins responder static before Noise XX msg3 so a scanned LAN host never receives our long-term identity (report PCH-01-003 / brief PCH-01-018) (v3.8.1)

time
2026-06-10 19:33 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01 fix integration
parent
e37a085ed620
3 files src/shared/chirp-sync-transport.ts+11 −0src/shared/noise-xx.test.ts+61 −0src/shared/noise-xx.ts+11 −0
src/shared/chirp-sync-transport.ts+11 −0
@@ -121,6 +121,17 @@121121       ourLongTermPublicKey,122122       send,123123       receive,124+      // PCH-01-003: abort BEFORE msg3 if the responder is not the peer we125+      // expect, so a probed-but-unexpected LAN host never receives our static.126+      (remoteStatic) => {127+        const seenHex = pubkeyToHex(remoteStatic);128+        if (seenHex !== expectedRemotePubkeyHex) {129+          throw new Error(130+            `paired-device static key mismatch (got ${seenHex.slice(0, 16)}…, ` +131+            `expected ${expectedRemotePubkeyHex.slice(0, 16)}…)`,132+          );133+        }134+      },124135     );125136   } catch (err) {126137     try { ws.close(); } catch { /* ignore */ }

view file

src/shared/noise-xx.test.ts+61 −0
@@ -85,4 +85,65 @@8585     expect(a.remoteStaticPublicKey).not.toEqual(bobReal.publicKey);8686     expect(a.remoteStaticPublicKey).toEqual(bobImpostor.publicKey);8787   });88+89+  // PCH-01-003: the pre-msg3 verifier must abort BEFORE the initiator transmits90+  // its own static (msg3), so a probed-but-unexpected LAN host never learns our91+  // long-term sync identity.92+  it("initiator_aborts_before_msg3_on_unexpected_responder", async () => {93+    const alice = generateKeyPair();94+    const bob = generateKeyPair();95+    const expected = generateKeyPair(); // the peer we WANTED — not bob96+    const [tA, tB] = memoryPair();97+98+    let sendCount = 0;99+    const countingSend = async (m: Uint8Array) => {100+      sendCount++;101+      await tA.send(m);102+    };103+104+    const aPromise = noiseXXInitiator(105+      alice.secretKey,106+      alice.publicKey,107+      countingSend,108+      tA.receive,109+      (remoteStatic) => {110+        const matches =111+          remoteStatic.length === expected.publicKey.length &&112+          remoteStatic.every((v, i) => v === expected.publicKey[i]);113+        if (!matches) throw new Error("paired-device static key mismatch");114+      },115+    );116+    // Honest responder advertising bob's static; it will hang waiting for msg3117+    // that never arrives, so we don't await it.118+    const bPromise = noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive);119+    bPromise.catch(() => {});120+121+    await expect(aPromise).rejects.toThrow("paired-device static key mismatch");122+    // Load-bearing assertion: only msg1 was sent. msg3 (our static) never left.123+    expect(sendCount).toBe(1);124+  });125+126+  it("matching_pin_completes_handshake_and_sends_msg3", async () => {127+    const alice = generateKeyPair();128+    const bob = generateKeyPair();129+    const [tA, tB] = memoryPair();130+131+    let sendCount = 0;132+    const countingSend = async (m: Uint8Array) => {133+      sendCount++;134+      await tA.send(m);135+    };136+137+    const [a] = await Promise.all([138+      noiseXXInitiator(alice.secretKey, alice.publicKey, countingSend, tA.receive, (remoteStatic) => {139+        const matches = remoteStatic.every((v, i) => v === bob.publicKey[i]);140+        if (!matches) throw new Error("paired-device static key mismatch");141+      }),142+      noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),143+    ]);144+145+    expect(a.remoteStaticPublicKey).toEqual(bob.publicKey);146+    // msg1 + msg3 both sent on the honest path — handshake completed.147+    expect(sendCount).toBe(2);148+  });88149 });

view file

src/shared/noise-xx.ts+11 −0
@@ -71,6 +71,12 @@7171   ourStaticPublicKey: Uint8Array,7272   send: (msg: Uint8Array) => Promise<void>,7373   receive: () => Promise<Uint8Array>,74+  // PCH-01-003: called once the responder's static is authenticated (end of75+  // msg2) and BEFORE msg3 — which carries OUR static — is sent. Throwing here76+  // aborts the handshake without disclosing our long-term identity to an77+  // unexpected peer. Honest callers pass a pin check; omitting it preserves the78+  // old behaviour.79+  verifyRemoteStatic?: (remoteStaticPublicKey: Uint8Array) => void,7480 ): Promise<NoiseXXSession> {7581   let h: Uint8Array = initHash();7682   let ck: Uint8Array = h.slice();@@ -111,6 +117,11 @@111117     throw new Error("Noise XX: msg2 payload auth failed");112118   }113119   h = mixHash(h, encryptedPayload);120+121+  // PCH-01-003: pin the responder static BEFORE we transmit our own static in122+  // msg3. `remoteStaticPlain` was authenticated above (msg2). If the caller's123+  // check throws, we return without sending msg3 — our identity stays private.124+  if (verifyRemoteStatic) verifyRemoteStatic(remoteStaticPlain);114125 115126   // --- Message 3: -> s, se ---116127   const cipherOurStatic = new ChaCha20Poly1305(k2);

view file