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
8 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 −4
manifest.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": {

view file

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",

view file

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": {

view file

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),

view file

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);

view file

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(() => {

view file

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 });

view file

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 {

view file