0511f34134fc
fix: PCH-01-024 update stale chirp-handshake SAS warning to reflect the mandatory extension-side gateOnSAS gate; pin the open item to Android; add gateOnSAS regression test (v3.8.1)
- time
- 2026-06-10 20:24 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- 483a5e0d75c5
↓ peach-browser-0511f341.tar.gzatlas at this save
files changed3 files
src/popup/chirp-pair.test.ts+27 −1src/popup/chirp-pair.ts+3 −1src/shared/chirp-handshake.ts+26 −24src/popup/chirp-pair.test.ts+27 −1
@@ -1,5 +1,5 @@11 import { afterEach, beforeEach, describe, expect, it } from "vitest";2-import { runChirpPairing, ChirpPairingFailure } from "./chirp-pair";2+import { runChirpPairing, ChirpPairingFailure, gateOnSAS } from "./chirp-pair";33 import { __setGgwaveModuleForTest } from "../shared/ggwave";44 import { encodeChirpPayload } from "../shared/chirp-payload";55 import { generateKeyPair } from "@stablelib/x25519";@@ -188,5 +188,31 @@188188 onProgress() { /* noop */ },189189 }, "initiator"),190190 ).rejects.toThrow(/openPairingServer/);191+ });192+});193+194+describe("gateOnSAS (PCH-01-024 — the SAS gate is MANDATORY before persist)", () => {195+ const device = { deviceId: "peer" } as unknown as Parameters<typeof gateOnSAS>[1]["device"];196+197+ function makeDeps(confirm: boolean) {198+ const progress: PairingProgress[] = [];199+ const deps = {200+ confirmSAS: async () => confirm,201+ onProgress: (p: PairingProgress) => progress.push(p),202+ } as unknown as Parameters<typeof gateOnSAS>[0];203+ return { deps, progress };204+ }205+206+ it("returns the device when the user confirms the SAS", async () => {207+ const { deps } = makeDeps(true);208+ await expect(gateOnSAS(deps, { device, sas: "123456" })).resolves.toBe(device);209+ });210+211+ it("aborts with handshake_failed when the user rejects the SAS", async () => {212+ const { deps, progress } = makeDeps(false);213+ await expect(gateOnSAS(deps, { device, sas: "123456" })).rejects.toBeInstanceOf(214+ ChirpPairingFailure,215+ );216+ expect(progress.at(-1)?.phase).toBe("handshake_failed");191217 });192218 });src/popup/chirp-pair.ts+3 −1
@@ -221,7 +221,9 @@221221 * Surface the SAS for user confirmation and abort the pairing if it is not222222 * confirmed to match the peer device. Shared by both roles.223223 */224-async function gateOnSAS(224+// Exported for the PCH-01-024 regression test that pins the SAS gate as225+// MANDATORY (the chirp-handshake.ts security comment depends on this staying true).226+export async function gateOnSAS(225227 deps: ChirpPairingDeps,226228 result: { device: PairedDevice; sas: string },227229 ): Promise<PairedDevice> {src/shared/chirp-handshake.ts+26 −24
@@ -123,32 +123,34 @@123123 }124124 125125 // ───────────────────────────────────────────────────────────────────────────126-// SECURITY — KNOWN LIMITATION: the pairing handshake does NOT authenticate the127-// initiator (audit HIGH). Noise NK authenticates only the RESPONDER (it proves128-// the initiator encrypted to the responder's "static" key — here the chirp129-// ephemeral the responder BROADCAST in the clear over audio). It gives the130-// extension (always the responder) zero cryptographic assurance about *who*126+// SECURITY — TRUST MODEL: the pairing handshake does NOT cryptographically127+// authenticate the initiator. Noise NK authenticates only the RESPONDER (it128+// proves the initiator encrypted to the responder's "static" key — here the129+// chirp ephemeral the responder BROADCAST in the clear over audio). On its own130+// it gives the extension (always the responder) zero assurance about *who*131131 // completed the handshake, and there is no proof-of-possession over the132-// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame. An133-// attacker in audio range (or on the same LAN, racing the WebSocket accept)134-// who captures the broadcast ephemeral can complete a valid NK handshake and135-// enroll an attacker-controlled device into the registry as trustSource:"direct"136-// — which then becomes a Noise XX sync target and can exfiltrate the vault.132+// long_term_public_key_hex a peer advertises in its PAIR_IDENTITY frame.137133 //138-// PROPER FIX (requires a COORDINATED change with the mobile/native app, since139-// the wire protocol is shared and a unilateral change breaks live pairing):140-// 1. Mutually authenticate — bind BOTH parties' chirp ephemerals into the141-// handshake (Noise KK/IK) or run the existing challenge-response142-// proof-of-possession (performInitiatorAuth/performResponderAuth in143-// noise-js) so each side proves possession of the key it advertises.144-// 2. Add a user-verified Short Authentication String (SAS): both devices145-// derive `derivePairingSAS(session.handshakeHash)` (below) — identical on146-// both ends because the Noise handshake hash is shared — and DISPLAY it;147-// the user confirms the codes match before either side persists the peer.148-// This is the Bluetooth-numeric-comparison / Signal-safety-number model149-// and detects a MITM/relay even without full mutual auth.150-// The SAS primitive below is wire-compatible and ready; activating it requires151-// surfacing + confirming it in BOTH the extension pairing UI and the app.134+// COMPENSATING CONTROL (IMPLEMENTED, MANDATORY on the extension side): a135+// user-verified Short Authentication String. Both peers derive the SAME 6-digit136+// SAS via `derivePairingSAS(session.handshakeHash)` (below) — identical on both137+// ends because the Noise handshake hash is shared — and DISPLAY it. The138+// extension pairing flow gates persistence on the user confirming the codes139+// match: `gateOnSAS` in `src/popup/chirp-pair.ts` runs BEFORE any device is140+// written to the registry and ABORTS pairing on mismatch/rejection. This is the141+// Bluetooth-numeric-comparison / Signal-safety-number model and detects a142+// MITM/relay despite the lack of full mutual auth. Residual risk is the standard143+// 6-digit SAS bound: a MITM survives with probability 1e-6 per attempt, and the144+// guarantee depends on the user actually comparing the codes.145+//146+// OPEN ITEM (NOT verifiable from this repository): the Android/native client147+// MUST surface and enforce the identical SAS gate before it persists the peer.148+// If the app skips or auto-confirms the SAS, the channel is only as strong as149+// the app's weakest end — confirm the app-side gate against the Android source.150+// A future hardening step (coordinated wire change with the app) could add151+// proof-of-possession via `performInitiatorAuth`/`performResponderAuth`152+// (noise-js) so each side proves possession of the key it advertises, making153+// authentication structural rather than dependent on the user's SAS comparison.152154 // ───────────────────────────────────────────────────────────────────────────153155 154156 /**