935ae427d7ee
fix(security): pairing now gates persistence on a user-confirmed Short Authentication String (SAS) derived from the shared Noise handshake hash — both devices show it, user confirms match before enrolling; defeats LAN/audio-range initiator impersonation (HIGH #4, extension+shared) (v3.6.126)
- time
- 2026-05-28 22:34 UTC · 5 months ago
- machine
- koh2
- parent
- 2da1a2aff52c
↓ peach-browser-935ae427.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/chirp-pair.test.ts+6 −0src/popup/chirp-pair.ts+49 −4src/popup/views/ChirpSync.tsx+62 −9src/shared/chirp-handshake.test.ts+35 −1src/shared/chirp-handshake.ts+65 −4manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.6.125",5+ "version": "3.6.126",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.125",4+ "version": "3.6.126",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.125",3+ "version": "3.6.126",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/popup/chirp-pair.test.ts+6 −0
@@ -71,6 +71,7 @@7171 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };7272 },7373 async completePairing() { /* unreached */ },74+ async confirmSAS() { return true; },7475 async getLocalSubnet() { return "192.168.1"; },7576 chirpTimeoutMs: 200,7677 openAudio: fakeAudio(null),@@ -99,6 +100,7 @@99100 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };100101 },101102 async completePairing() { /* unreached */ },103+ async confirmSAS() { return true; },102104 async getLocalSubnet() { return "192.168.1"; },103105 openAudio: fakeAudio(peerChirp),104106 async openPairingTransport() { throw new Error("connection refused"); },@@ -126,6 +128,7 @@126128 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };127129 },128130 async completePairing() { /* unreached */ },131+ async confirmSAS() { return true; },129132 async getLocalSubnet() { return null; },130133 openAudio: fakeAudio(peerChirp),131134 async openPairingTransport() { throw new Error("unreached"); },@@ -142,6 +145,7 @@142145 return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };143146 },144147 async completePairing() { /* unreached */ },148+ async confirmSAS() { return true; },145149 async getLocalSubnet() { return "192.168.1"; },146150 chirpTimeoutMs: 200,147151 openAudio: fakeAudio(null),@@ -158,6 +162,7 @@158162 return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };159163 },160164 async completePairing() { /* unreached */ },165+ async confirmSAS() { return true; },161166 async getLocalSubnet() { return null; },162167 chirpTimeoutMs: 200,163168 openAudio: fakeAudio(null),@@ -175,6 +180,7 @@175180 return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };176181 },177182 async completePairing() { /* unreached */ },183+ async confirmSAS() { return true; },178184 async getLocalSubnet() { return null; },179185 chirpTimeoutMs: 200,180186 openAudio: fakeAudio(null),src/popup/chirp-pair.ts+49 −4
@@ -57,6 +57,16 @@5757 fetchLocalIdentity(): Promise<LocalIdentity>;5858 /** Background round-trip — persists the paired device. */5959 completePairing(paired: PairedDevice): Promise<void>;60+ /**61+ * Show the Short Authentication String and resolve true only when the user62+ * confirms it matches the code displayed on the OTHER device. Pairing is63+ * aborted (the peer is NOT persisted) on false. This is the out-of-band64+ * mutual-authentication step that defeats a LAN/audio-range attacker who65+ * completes the Noise NK handshake in place of the real device — their66+ * handshake produces a different SAS than the genuine peer shows, so the67+ * user declines (audit HIGH — pairing initiator authentication).68+ */69+ confirmSAS(sas: string): Promise<boolean>;6070 /** Optional — cache the peer's IP for next session's fast path. */6171 cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;6272 /**@@ -176,9 +186,9 @@176186 }177187 178188 const transport = await deps.openPairingTransport(peerEndpoint);179- let paired: PairedDevice;189+ let result: { device: PairedDevice; sas: string };180190 try {181- paired = await runPairingResponder(191+ result = await runPairingResponder(182192 attempt.ephemeralPrivateKey,183193 attempt.ephemeralPublicKey,184194 localIdentity,@@ -191,6 +201,12 @@191201 }192202 await transport.close();193203 204+ // Out-of-band verification BEFORE persisting: the user must confirm the SAS205+ // matches the code on the other device. Without this, whoever completed the206+ // handshake (possibly a LAN/audio-range attacker) would be enrolled as a207+ // trusted sync peer (audit HIGH).208+ const paired = await gateOnSAS(deps, result);209+194210 // The connection's IP is cached in `chrome.storage.local` keyed by195211 // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3196212 // `PairedDevice` record itself has no `lastKnownIp` field because the@@ -201,6 +217,31 @@201217 return paired;202218 }203219 220+/**221+ * Surface the SAS for user confirmation and abort the pairing if it is not222+ * confirmed to match the peer device. Shared by both roles.223+ */224+async function gateOnSAS(225+ deps: ChirpPairingDeps,226+ result: { device: PairedDevice; sas: string },227+): Promise<PairedDevice> {228+ let confirmed: boolean;229+ try {230+ confirmed = await deps.confirmSAS(result.sas);231+ } catch (err) {232+ deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });233+ throw new ChirpPairingFailure("handshake_failed", (err as Error).message);234+ }235+ if (!confirmed) {236+ deps.onProgress({ phase: "handshake_failed", errorMessage: "sas_rejected" });237+ throw new ChirpPairingFailure(238+ "handshake_failed",239+ "pairing rejected — the verification codes did not match",240+ );241+ }242+ return result.device;243+}244+204245 async function runInitiatorHandshake(205246 deps: ChirpPairingDeps,206247 remoteChirpPublicKey: Uint8Array,@@ -217,9 +258,9 @@217258 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);218259 }219260 220- let paired: PairedDevice;261+ let result: { device: PairedDevice; sas: string };221262 try {222- paired = await runPairingInitiator(263+ result = await runPairingInitiator(223264 remoteChirpPublicKey,224265 localIdentity,225266 server.transport,@@ -230,6 +271,10 @@230271 throw new ChirpPairingFailure("handshake_failed", (err as Error).message);231272 }232273 await server.transport.close();274+275+ // Same out-of-band SAS confirmation as the responder side — the initiator276+ // (mobile vault host) must not sync to an unverified peer either.277+ const paired = await gateOnSAS(deps, result);233278 234279 await deps.completePairing(paired);235280 await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);src/popup/views/ChirpSync.tsx+62 −9
@@ -58,7 +58,19 @@5858 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);5959 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");6060 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);61+ // Out-of-band pairing verification: the 6-digit code both devices derive62+ // from the shared handshake; the user confirms it matches before the pair63+ // is persisted (audit HIGH — pairing initiator authentication).64+ const [sasCode, setSasCode] = createSignal<string | null>(null);65+ const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);6166 67+ function resolveSAS(ok: boolean) {68+ const r = sasResolve();69+ setSasCode(null);70+ setSasResolve(null);71+ r?.(ok);72+ }73+6274 async function handleSyncNow(): Promise<void> {6375 setSyncStatus("syncing");6476 setSyncFlash(null);@@ -145,6 +157,11 @@145157 const r = res as { ok: boolean; error?: string };146158 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");147159 },160+ confirmSAS: (sas) =>161+ new Promise<boolean>((resolve) => {162+ setSasResolve(() => resolve);163+ setSasCode(sas);164+ }),148165 getLocalSubnet,149166 openPairingTransport: async (endpoint) => {150167 // Native WebSocket transport for the pairing-mode peer scan. The@@ -409,15 +426,51 @@409426 );410427 411428 const pairingView = () => (412- <ChirpPairingFlow413- progress={pairing()}414- onCancel={() => setMode("rest")}415- onRetry={() => void handleStartPairing()}416- onDismiss={() => {417- setMode("rest");418- void refresh();419- }}420- />429+ <Show430+ when={sasCode()}431+ fallback={432+ <ChirpPairingFlow433+ progress={pairing()}434+ onCancel={() => setMode("rest")}435+ onRetry={() => void handleStartPairing()}436+ onDismiss={() => {437+ setMode("rest");438+ void refresh();439+ }}440+ />441+ }442+ >443+ {/* Out-of-band verification gate — pairing is NOT persisted until the444+ user confirms this code matches the one on the other device. */}445+ <div446+ class="chirp-sas-verify"447+ style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"448+ >449+ <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>450+ <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">451+ Confirm this code matches the one shown on your other device before pairing.452+ </div>453+ <div style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;">454+ {sasCode()}455+ </div>456+ <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">457+ <button458+ class="peach-btn peach-btn--accent peach-btn--md"459+ type="button"460+ onClick={() => resolveSAS(true)}461+ >462+ codes match — pair463+ </button>464+ <button465+ class="peach-btn peach-btn--ghost peach-btn--md"466+ type="button"467+ onClick={() => resolveSAS(false)}468+ >469+ they don't match470+ </button>471+ </div>472+ </div>473+ </Show>421474 );422475 423476 const currentMode = createMemo(() => {src/shared/chirp-handshake.test.ts+35 −1
@@ -1,6 +1,7 @@11 import { describe, expect, it } from "vitest";22 import { generateKeyPair } from "@stablelib/x25519";33 import {4+ derivePairingSAS,45 memoryTransportPair,56 runPairingInitiator,67 runPairingResponder,@@ -38,7 +39,9 @@3839 );3940 const initPromise = runPairingInitiator(respEphemeral.publicKey, initIdent, t1);4041 41- const [respLearned, initLearned] = await Promise.all([respPromise, initPromise]);42+ const [respResult, initResult] = await Promise.all([respPromise, initPromise]);43+ const initLearned = initResult.device;44+ const respLearned = respResult.device;4245 expect(initLearned.deviceId).toBe("ext-001");4346 expect(initLearned.deviceName).toBe("Firefox on MacBook");4447 expect(initLearned.deviceType).toBe("extension");@@ -52,6 +55,11 @@5255 expect(respLearned.longTermPublicKeyHex).toBe(initIdent.longTermPublicKeyHex);5356 expect(respLearned.signingPublicKeyHex).toBe(initIdent.signingPublicKeyHex);5457 expect(respLearned.trustSource).toBe("direct");58+59+ // Both ends derive the SAME 6-digit SAS from the shared handshake hash —60+ // this is what the user compares across devices.61+ expect(initResult.sas).toMatch(/^\d{6}$/);62+ expect(initResult.sas).toBe(respResult.sas);5563 });5664 5765 it("rejects a malformed identity frame", async () => {@@ -131,5 +139,31 @@131139 132140 await expect(respPromise).rejects.toThrow(/signing_public_key_hex/);133141 await evilInitiator.catch(() => undefined);142+ });143+});144+145+describe("derivePairingSAS (out-of-band MITM-detection code)", () => {146+ it("returns a deterministic 6-digit code", () => {147+ const h = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));148+ const sas = derivePairingSAS(h);149+ expect(sas).toMatch(/^\d{6}$/);150+ expect(derivePairingSAS(h)).toBe(sas);151+ });152+153+ it("both peers derive the same SAS from the shared Noise handshake hash", () => {154+ // After the handshake both ends hold the identical handshakeHash, so both155+ // independently compute the same comparison code.156+ const shared = crypto.getRandomValues(new Uint8Array(32));157+ expect(derivePairingSAS(shared.slice())).toBe(derivePairingSAS(shared.slice()));158+ });159+160+ it("differs for different handshake hashes", () => {161+ expect(derivePairingSAS(Uint8Array.of(0, 0, 0, 0))).not.toBe(162+ derivePairingSAS(Uint8Array.of(0xff, 0xff, 0xff, 0xff)),163+ );164+ });165+166+ it("handles undersized input without throwing", () => {167+ expect(derivePairingSAS(new Uint8Array(2))).toBe("000000");134168 });135169 });src/shared/chirp-handshake.ts+65 −4
@@ -33,6 +33,13 @@3333 receive(): Promise<Uint8Array>;3434 }3535 36+/** Result of a pairing handshake: the peer's identity plus the SAS the user37+ * must confirm matches the peer device before the pair is persisted. */38+export interface PairingHandshakeResult {39+ device: PairedDevice;40+ sas: string;41+}42+3643 export interface LocalIdentity {3744 deviceId: string;3845 deviceName: string;@@ -88,7 +95,7 @@8895 remoteEphemeralPublicKey: Uint8Array,8996 ourIdentity: LocalIdentity,9097 transport: PairingTransport,91-): Promise<PairedDevice> {98+): Promise<PairingHandshakeResult> {9299 const session = await noiseNKInitiator(93100 remoteEphemeralPublicKey,94101 transport.send,@@ -110,10 +117,61 @@110117 const theirCipher = await transport.receive();111118 const theirPlain = session.decrypt(theirCipher);112119 const theirFrame = decodeFrame(theirPlain);113- return frameToPairedDevice(theirFrame);120+ // The SAS is derived from the shared Noise handshake hash; the caller MUST121+ // have the user confirm it matches the peer's before persisting the device.122+ return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };114123 }115124 125+// ───────────────────────────────────────────────────────────────────────────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*131+// 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.137+//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.152+// ───────────────────────────────────────────────────────────────────────────153+116154 /**155+ * Derive a 6-digit Short Authentication String from a Noise handshake hash.156+ * Both pairing peers compute the SAME value because the Noise `handshakeHash`157+ * is shared after the handshake. Showing it on both devices and having the158+ * user confirm they match authenticates the channel out-of-band, detecting a159+ * man-in-the-middle the NK handshake alone cannot (see SECURITY note above).160+ */161+export function derivePairingSAS(handshakeHash: Uint8Array): string {162+ // Fold the first 4 bytes into an unsigned int, mod 1e6, zero-padded. Uniform163+ // enough for a 6-digit comparison code; the handshake hash is already a PRF164+ // output so no further mixing is needed.165+ if (handshakeHash.length < 4) return "000000";166+ const n =167+ ((handshakeHash[0] << 24) >>> 0) +168+ (handshakeHash[1] << 16) +169+ (handshakeHash[2] << 8) +170+ handshakeHash[3];171+ return String((n >>> 0) % 1_000_000).padStart(6, "0");172+}173+174+/**117175 * Run the post-chirp pairing handshake from the RESPONDER side (extension).118176 *119177 * `ourEphemeralPrivateKey` / `ourEphemeralPublicKey` come from the chirp@@ -125,7 +183,7 @@125183 ourEphemeralPublicKey: Uint8Array,126184 ourIdentity: LocalIdentity,127185 transport: PairingTransport,128-): Promise<PairedDevice> {186+): Promise<PairingHandshakeResult> {129187 const session = await noiseNKResponder(130188 ourEphemeralPrivateKey,131189 ourEphemeralPublicKey,@@ -149,7 +207,10 @@149207 });150208 await transport.send(session.encrypt(ourFrame));151209 152- return frameToPairedDevice(theirFrame);210+ // SAS derived from the shared handshake hash — caller must have the user211+ // confirm it matches the peer's code before persisting (defeats the212+ // initiator-impersonation / MITM attack the NK handshake alone allows).213+ return { device: frameToPairedDevice(theirFrame), sas: derivePairingSAS(session.handshakeHash) };153214 }154215 155216 function frameToPairedDevice(frame: PairingFrame): PairedDevice {