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
↓ peach-browser-ba610b7f.tar.gzatlas at this save
files changed7 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 −14manifest.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": {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",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",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.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) {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 });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">