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