ba610b7f083b

fix: show receiver safety code without a second gate (v4.3.20)

time
2026-07-28 21:30 UTC · 3 months ago
machine
koh2
agent
gpt-5
session
receiver SAS display
parent
651df90f45b2
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+10 −3src/background/relay-adoption-source-bg.ts+5 −0src/popup/views/AdoptVaultView.test.tsx+48 −0src/popup/views/AdoptVaultView.tsx+30 −14
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.3.19",4+  "version": "4.3.20",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.3.19",4+  "version": "4.3.20",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": "4.3.19",3+  "version": "4.3.20",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-source-bg.test.ts+10 −3
@@ -498,7 +498,7 @@498498     mocks.openSocket.mockResolvedValue(socketWithFrames(frames));499499 500500     // Replace the chrome.runtime mock for this one test. The receiver501-    // auto-accepts the SAS now, so no decision needs to be simulated.501+    // auto-accepts the SAS, so no decision needs to be simulated.502502     Object.defineProperty(globalThis, "chrome", {503503       configurable: true,504504       value: {@@ -520,8 +520,15 @@520520 521521     await waitForPush("ADOPTION_SOURCE_RESULT");522522 523-    // The receiver auto-accepted the SAS, so no ADOPTION_SOURCE_SAS push is524-    // expected — only the terminal result matters.523+    // The receiver displays its locally derived SAS without introducing a524+    // second confirmation gate.525+    expect(mocks.pushes).toContainEqual({526+      type: "ADOPTION_SOURCE_SAS",527+      payload: expect.objectContaining({528+        sas: expect.stringMatching(/^\d{8}$/),529+        displayOnly: true,530+      }),531+    });525532 526533     // The vault module was called directly with the popup-supplied password527534     // and the root key granted by the phone.

view file

src/background/relay-adoption-source-bg.ts+5 −0
@@ -319,7 +319,12 @@319319       // to confirm on both sides creates a race where the phone can time out320320       // waiting for ADOPT_CONFIRM before the browser's popup is reached. The321321       // phone's onConsent is the single user gate; this side just proceeds.322+      // Still show the locally derived code for visual verification.322323       async confirmSas() {324+        push("ADOPTION_SOURCE_SAS", {325+          sas: derivePairingSAS(session.handshakeHash),326+          displayOnly: true,327+        });323328         return true;324329       },325330       async createVaultFromRootKey(rootKeyHex) {

view file

src/popup/views/AdoptVaultView.test.tsx+48 −0
@@ -92,4 +92,52 @@9292     // The scan button from the old flow is gone — the phone is the scanner now.9393     expect(view.container.textContent).not.toContain("Enter invitation manually");9494   });95+96+  it("shows the receiver SAS without adding a second confirmation button", async () => {97+    type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;98+    const listeners: RuntimeListener[] = [];99+    Object.defineProperty(globalThis, "chrome", {100+      configurable: true,101+      value: {102+        runtime: {103+          lastError: undefined,104+          sendMessage(105+            message: { type?: string },106+            callback: (response: unknown) => void,107+          ) {108+            if (message.type === "START_ADOPTION_RECEIVER") {109+              callback({ ok: true, data: { invitation: "peach-pair-v1:test" } });110+            } else {111+              callback({ ok: true });112+            }113+          },114+          onMessage: {115+            addListener(fn: RuntimeListener) { listeners.push(fn); },116+            removeListener(fn: RuntimeListener) {117+              const index = listeners.indexOf(fn);118+              if (index >= 0) listeners.splice(index, 1);119+            },120+          },121+        },122+      },123+    });124+125+    const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} />);126+    await enterStrongPassword(view.container);127+    button(view.container, "Continue").click();128+    await flushView();129+130+    for (const listener of listeners) {131+      listener({132+        type: "ADOPTION_SOURCE_SAS",133+        payload: { sas: "48219371", displayOnly: true },134+      });135+    }136+    await flushView();137+138+    expect(view.container.textContent).toContain("48219371");139+    expect(view.container.textContent).toContain("Pairing continues when you confirm there");140+    expect(view.container.textContent).not.toContain("They match");141+    expect(view.container.textContent).not.toContain("They don't match");142+  });95143 });

view file

src/popup/views/AdoptVaultView.tsx+30 −14
@@ -45,6 +45,7 @@4545   const [confirmPassword, setConfirmPassword] = createSignal("");4646   const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4747   const [receiverInvitation, setReceiverInvitation] = createSignal("");48+  const [receiverSas, setReceiverSas] = createSignal("");4849   const [receiverError, setReceiverError] = createSignal("");4950 5051   onCleanup(() => {@@ -73,22 +74,27 @@7374     })();7475   });7576 76-  // The receiver auto-accepts the SAS on both sides (the codes match by77-  // construction because both derive from the same Noise handshake hash),78-  // so the popup only needs to listen for the terminal result. The phone79-  // is the single user-facing gate — they tap "They match" on the phone.77+  // The receiver auto-accepts the SAS, but still displays its locally derived78+  // code so the user can visually compare it with the phone. The phone remains79+  // the single confirmation gate — there is no second button on this screen.8080   if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {8181     const handler = (msg: { type?: string; payload?: unknown }) => {82-      if (msg?.type !== "ADOPTION_SOURCE_RESULT") return;83-      const p = msg.payload as {84-        status?: "complete" | "error";85-        error?: string;86-      } | undefined;87-      if (p?.status === "complete") {88-        setPhase("success");89-      } else if (p?.status === "error") {90-        setReceiverError(p.error ?? "adoption_failed");91-        setPhase("error");82+      if (msg?.type === "ADOPTION_SOURCE_SAS") {83+        const p = msg.payload as { sas?: string; displayOnly?: boolean } | undefined;84+        if (p?.displayOnly === true && typeof p.sas === "string") {85+          setReceiverSas(p.sas);86+        }87+      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {88+        const p = msg.payload as {89+          status?: "complete" | "error";90+          error?: string;91+        } | undefined;92+        if (p?.status === "complete") {93+          setPhase("success");94+        } else if (p?.status === "error") {95+          setReceiverError(p.error ?? "adoption_failed");96+          setPhase("error");97+        }9298       }9399     };94100     chrome.runtime.onMessage.addListener(handler);@@ -129,6 +135,7 @@129135   function tryAgain(): void {130136     setReceiverError("");131137     setReceiverInvitation("");138+    setReceiverSas("");132139     setPhase("pair");133140   }134141 @@ -190,6 +197,15 @@190197               }191198             >192199               <PairingQrCode value={receiverInvitation()} size={160} />200+            </Show>201+            <Show when={receiverSas()}>202+              <p class="peach-hint">203+                Make sure this matches the 8-digit safety code on your phone.204+                Pairing continues when you confirm there.205+              </p>206+              <div class="peach-adoption-sas" aria-label="Pairing confirmation code">207+                <strong>{receiverSas()}</strong>208+              </div>193209             </Show>194210             <p class="peach-hint peach-hint-relay">{RELAY_DISCLOSURE}</p>195211             <div class="peach-adopt-actions">

view file