c68215129f49

require explicit matching receiver SAS confirmation before adoption (v5.0.84)

time
2026-09-03 23:59 UTC · 1 month ago
machine
koh2
agent
claude-sonnet-4-6
session
PB-26-316 receiver SAS gate
parent
b871817e8ece
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/message-policy.ts+1 −0src/background/messages.ts+9 −8src/background/relay-adoption-bg.test.ts+40 −16src/background/relay-adoption-bg.ts+27 −11src/popup/views/AdoptVaultView.tsx+26 −6src/shared/types.ts+1 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.83",4+  "version": "5.0.84",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.83",5+  "version": "5.0.84",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.83",3+  "version": "5.0.84",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/message-policy.ts+1 −0
@@ -58,6 +58,7 @@5858   START_ADOPTION_RECEIVER: "extension-page-only",5959   CANCEL_ADOPTION_SOURCE: "extension-page-only",6060   SOURCE_SAS_DECISION: "extension-page-only",61+  RECEIVER_SAS_DECISION: "extension-page-only",6162   TRIGGER_SYNC: "extension-page-only",6263   GET_SYNC_STATE: "extension-page-only",6364   GET_SYNC_SETTINGS: "extension-page-only",

view file

src/background/messages.ts+9 −8
@@ -92,6 +92,7 @@9292 import {9393   cancelRelayAdoptionSource,9494   isRelayAdoptionSourceInFlight,95+  resolveReceiverSasConfirmation,9596   resolveSourceSasConfirmation,9697   startRelayAdoptionReceiver,9798   startRelayAdoptionSource,@@ -1656,17 +1657,17 @@16561657         }16571658       }16581659 1659-      // Popup→bg counterpart to ADOPTION_SOURCE_SAS (receiver flow).1660-      // The receiver now auto-accepts the SAS (both sides derive it from the1661-      // same Noise handshake hash, so the codes match by construction) so1662-      // the popup never has a SAS screen to tap on. The id round-trips the1663-      // same way so a stale late response can't approve the wrong session.1664-      // Popup→bg counterpart for the source flow: Confirm/Cancel on the1665-      // source's SAS screen.1666-16671660       // Source-side mirror of RECEIVER_SAS_DECISION: the popup's confirm/cancel16681661       // on the source's SAS screen. The id round-trips the same way so a stale16691662       // late response can't approve the wrong session.1663+      case "RECEIVER_SAS_DECISION": {1664+        const payload = msg.payload as { id?: unknown; sas?: unknown; ok?: unknown } | undefined;1665+        if (typeof payload?.id !== "string" || typeof payload.sas !== "string" || typeof payload.ok !== "boolean") {1666+          return { ok: false, error: "invalid_payload" };1667+        }1668+        return { ok: resolveReceiverSasConfirmation(payload.id, payload.sas, payload.ok) };1669+      }1670+16701671       case "SOURCE_SAS_DECISION": {16711672         const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;16721673         if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {

view file

src/background/relay-adoption-bg.test.ts+40 −16
@@ -203,11 +203,16 @@203203         // resolves the decision itself; the source tests rely on this default204204         // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.205205         if (message.type === "ADOPTION_SOURCE_SAS") {206-          const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");207-          const p = message.payload as { id?: string };208-          if (typeof p?.id === "string") {209-            mocks.sasDecisions.push({ id: p.id, ok: true });210-            resolveSourceSasConfirmation(p.id, true);206+          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };207+          if (typeof p?.id === "string" && typeof p.sas === "string") {208+            if (p.displayOnly === false) {209+              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");210+              resolveReceiverSasConfirmation(p.id, p.sas, true);211+            } else {212+              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");213+              mocks.sasDecisions.push({ id: p.id, ok: true });214+              resolveSourceSasConfirmation(p.id, true);215+            }211216           }212217         }213218         return Promise.resolve();@@ -598,9 +603,16 @@598603       configurable: true,599604       value: {600605         runtime: {601-          sendMessage(message: { type: string; payload?: unknown }) {606+          async sendMessage(message: { type: string; payload?: unknown }) {602607             mocks.pushes.push({ type: message.type, payload: message.payload });603-            return Promise.resolve({ ok: true });608+            if (message.type === "ADOPTION_SOURCE_SAS") {609+              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };610+              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {611+                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");612+                resolveReceiverSasConfirmation(p.id, p.sas, true);613+              }614+            }615+            return { ok: true };604616           },605617           onMessage: {606618             addListener() { /* no-op */ },@@ -676,9 +688,16 @@676688       configurable: true,677689       value: {678690         runtime: {679-          sendMessage(message: { type: string; payload?: unknown }) {691+          async sendMessage(message: { type: string; payload?: unknown }) {680692             mocks.pushes.push({ type: message.type, payload: message.payload });681-            return Promise.resolve({ ok: true });693+            if (message.type === "ADOPTION_SOURCE_SAS") {694+              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };695+              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {696+                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");697+                resolveReceiverSasConfirmation(p.id, p.sas, true);698+              }699+            }700+            return { ok: true };682701           },683702           onMessage: { addListener: () => undefined, removeListener: () => undefined },684703         },@@ -735,15 +754,21 @@735754     mocks.createVault.mockResolvedValue({ vaultId });736755     mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));737756 738-    // Replace the chrome.runtime mock for this one test. The receiver739-    // auto-accepts the SAS, so no decision needs to be simulated.757+    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.740758     Object.defineProperty(globalThis, "chrome", {741759       configurable: true,742760       value: {743761         runtime: {744-          sendMessage(message: { type: string; payload?: unknown }) {762+          async sendMessage(message: { type: string; payload?: unknown }) {745763             mocks.pushes.push({ type: message.type, payload: message.payload });746-            return Promise.resolve({ ok: true });764+            if (message.type === "ADOPTION_SOURCE_SAS") {765+              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };766+              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {767+                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");768+                resolveReceiverSasConfirmation(p.id, p.sas, true);769+              }770+            }771+            return { ok: true };747772           },748773           onMessage: {749774             addListener: () => undefined,@@ -758,13 +783,12 @@758783 759784     await waitForPush("ADOPTION_SOURCE_RESULT");760785 761-    // The receiver displays its locally derived SAS without introducing a762-    // second confirmation gate.786+    // The receiver displays its locally derived SAS and requires confirmation.763787     expect(mocks.pushes).toContainEqual({764788       type: "ADOPTION_SOURCE_SAS",765789       payload: expect.objectContaining({766790         sas: expect.stringMatching(/^\d{8}$/),767-        displayOnly: true,791+        displayOnly: false,768792       }),769793     });770794 

view file

src/background/relay-adoption-bg.ts+27 −11
@@ -84,6 +84,7 @@8484 // session impossible to land on the current one even if both run on the8585 // same background worker.8686 const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();87+const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();8788 8889 export function isRelayAdoptionSourceInFlight(): boolean {8990   return inFlight;@@ -201,6 +202,14 @@201202   return true;202203 }203204 205+export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {206+  const pending = pendingReceiverSasConfirmation.get(id);207+  if (!pending) return false;208+  pendingReceiverSasConfirmation.delete(id);209+  pending.resolve(ok && constantTimeEqual(pending.sas, sas));210+  return true;211+}212+204213 async function runSourceSession(205214   socket: PairingSocket,206215   expectedToken: string,@@ -396,18 +405,10 @@396405     const adoptionResult = await requestAdoption(channel, {397406       deviceName,398407       generateDeviceId: () => crypto.randomUUID(),399-      // Auto-accept the SAS: both sides derive it from the same Noise400-      // handshake hash, so the codes match by construction. Requiring the user401-      // to confirm on both sides creates a race where the phone can time out402-      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The403-      // phone's onConsent is the single user gate; this side just proceeds.404-      // Still show the locally derived code for visual verification.405408       async confirmSas() {406-        push("ADOPTION_SOURCE_SAS", {407-          sas: derivePairingSAS(session.handshakeHash),408-          displayOnly: true,409-        });410-        return true;409+        const sas = derivePairingSAS(session.handshakeHash);410+        const id = crypto.randomUUID();411+        return waitForReceiverSasConfirmation(id, sas);411412       },412413       async createVaultFromRootKey(rootKeyHex) {413414         // Direct call, not chrome.runtime.sendMessage — a service worker@@ -587,6 +588,20 @@587588  * the deferred the moment it sees the SAS push, and a missing slot would588589  * drop that approval on the floor.589590  */591+function waitForReceiverSasConfirmation(id: string, sas: string): Promise<boolean> {592+  return new Promise<boolean>((resolve) => {593+    const timer = setTimeout(() => {594+      pendingReceiverSasConfirmation.delete(id);595+      resolve(false);596+    }, SOURCE_WINDOW_MS);597+    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {598+      clearTimeout(timer);599+      resolve(ok);600+    } });601+    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });602+  });603+}604+590605 function waitForSourceSasConfirmation(591606   id: string,592607   request: AdoptRequest,@@ -625,6 +640,7 @@625640   activeSocket = null;626641   inFlight = false;627642   pendingSourceSasConfirmation.clear();643+  pendingReceiverSasConfirmation.clear();628644 }629645 630646 function constantTimeEqual(a: string, b: string): boolean {

view file

src/popup/views/AdoptVaultView.tsx+26 −6
@@ -80,15 +80,28 @@8080     })();8181   });8282 83-  // The receiver auto-accepts the SAS, but still displays its locally derived84-  // code so the user can visually compare it with the phone. The phone remains85-  // the single confirmation gate — there is no second button on this screen.83+  const [receiverSasId, setReceiverSasId] = createSignal("");84+  const [receiverSasDecision, setReceiverSasDecision] = createSignal(false);85+86+  function confirmReceiverSas(): void {87+    if (!receiverSas() || !receiverSasId()) return;88+    void sendMessage({89+      type: "RECEIVER_SAS_DECISION",90+      payload: { id: receiverSasId(), sas: receiverSas(), ok: true },91+    });92+    setReceiverSasDecision(true);93+  }94+95+  // The receiver must explicitly confirm the displayed SAS before adoption.96+8697   if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {8798     const handler = (msg: { type?: string; payload?: unknown }) => {8899       if (msg?.type === "ADOPTION_SOURCE_SAS") {89-        const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;90-        if (p?.displayOnly === true && typeof p.sas === "string") {100+        const p = msg.payload as { id?: string; sas?: string; displayOnly?: boolean } | undefined;101+        if (p?.displayOnly === false && typeof p.sas === "string" && typeof (p as { id?: unknown }).id === "string") {91102           setReceiverSas(p.sas);103+          setReceiverSasId((p as { id: string }).id);104+          setReceiverSasDecision(false);92105         }93106       } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {94107         const p = msg.payload as {@@ -272,8 +285,15 @@272285                       <strong>{receiverSas()}</strong>273286                     </div>274287                     <p class="peach-adopt-confirmation-help">275-                      Confirm this code on your phone to finish pairing.288+                      Confirm this code matches the one on your phone.276289                     </p>290+                    <button291+                      class="peach-btn peach-btn--accent peach-btn--md"292+                      disabled={receiverSasDecision()}293+                      onClick={confirmReceiverSas}294+                    >295+                      {receiverSasDecision() ? "Code confirmed" : "Confirm code"}296+                    </button>277297                   </div>278298                 </Show>279299 

view file

src/shared/types.ts+1 −0
@@ -253,6 +253,7 @@253253   | "START_ADOPTION_RECEIVER"254254   | "CANCEL_ADOPTION_SOURCE"255255   | "SOURCE_SAS_DECISION"256+  | "RECEIVER_SAS_DECISION"256257   | "ADOPTION_SOURCE_SAS"257258   | "ADOPTION_SOURCE_RESULT"258259   | "TRIGGER_SYNC"

view file