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
↓ peach-browser-c6821512.tar.gzatlas at this save
files changed9 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 −0manifest.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": {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",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",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",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") {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 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 {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 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"