e97b03ea682a

source flow SAS gate: START_ADOPTION_SOURCE now actually waits for the popup to confirm the 8-digit code instead of auto-approving. Mirrors the receiver's id-keyed deferred pattern (SOURCE_SAS_DECISION). Both pairing flows now have symmetric, real SAS gates. (v4.3.15)

time
2026-07-28 16:03 UTC · 3 months ago
machine
koh2
agent
claude-MiniMax-M3
session
sync red indicator port from browser
parent
ecedb447300f
6 files src/background/messages.ts+12 −0src/background/relay-adoption-source-bg.test.ts+104 −3src/background/relay-adoption-source-bg.ts+59 −6src/popup/views/PairDevice.test.tsx+20 −3src/popup/views/PairDevice.tsx+39 −4src/shared/types.ts+1 −0
src/background/messages.ts+12 −0
@@ -90,6 +90,7 @@9090   cancelRelayAdoptionSource,9191   isRelayAdoptionSourceInFlight,9292   resolveReceiverSasConfirmation,93+  resolveSourceSasConfirmation,9394   startRelayAdoptionReceiver,9495   startRelayAdoptionSource,9596 } from "./relay-adoption-source-bg";@@ -1494,6 +1495,17 @@14941495           return { ok: false, error: "invalid_payload" };14951496         }14961497         return { ok: resolveReceiverSasConfirmation(payload.id, payload.ok) };1498+      }1499+1500+      // Source-side mirror of RECEIVER_SAS_DECISION: the popup's confirm/cancel1501+      // on the source's SAS screen. The id round-trips the same way so a stale1502+      // late response can't approve the wrong session.1503+      case "SOURCE_SAS_DECISION": {1504+        const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1505+        if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1506+          return { ok: false, error: "invalid_payload" };1507+        }1508+        return { ok: resolveSourceSasConfirmation(payload.id, payload.ok) };14971509       }14981510 14991511       case "CANCEL_ADOPTION_SOURCE": {

view file

src/background/relay-adoption-source-bg.test.ts+104 −3
@@ -112,6 +112,7 @@112112 113113 beforeEach(() => {114114   mocks.pushes.length = 0;115+  mocks.sasDecisions.length = 0;115116   mocks.openSocket.mockReset();116117   mocks.getLocalIdentity.mockReset();117118   mocks.getRootKeyHex.mockReset();@@ -135,8 +136,20 @@135136     configurable: true,136137     value: {137138     runtime: {138-      sendMessage(message: { type: string; payload: unknown }) {139+      async sendMessage(message: { type: string; payload: unknown }) {139140         mocks.pushes.push(message);141+        // The source flow waits for the popup to send SOURCE_SAS_DECISION before142+        // forwarding the root key. The receiver test overrides this mock and143+        // resolves the decision itself; the source tests rely on this default144+        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.145+        if (message.type === "ADOPTION_SOURCE_SAS") {146+          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");147+          const p = message.payload as { id?: string };148+          if (typeof p?.id === "string") {149+            mocks.sasDecisions.push({ id: p.id, ok: true });150+            resolveSourceSasConfirmation(p.id, true);151+          }152+        }140153         return Promise.resolve();141154       },142155     },@@ -145,7 +158,7 @@145158 });146159 147160 describe("relay adoption source background boundary", () => {148-  it("uses the receiver SAS confirmation as the only root-key commit gate", async () => {161+  it("shows the SAS and waits for the popup's single confirmation", async () => {149162     const socket = socketWithFrames([150163       {151164         type: "PEACH_HELLO",@@ -169,9 +182,47 @@169182     ]);170183     mocks.openSocket.mockResolvedValue(socket);171184 185+    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");186+    const sasIds: string[] = [];187+    Object.defineProperty(globalThis, "chrome", {188+      configurable: true,189+      value: {190+        runtime: {191+          async sendMessage(message: { type: string; payload: unknown }) {192+            mocks.pushes.push(message);193+            if (message.type === "ADOPTION_SOURCE_SAS") {194+              const p = message.payload as { id?: string };195+              if (typeof p?.id === "string") {196+                // Hold the SAS gate open until the test explicitly approves197+                // it — this proves the source flow waits for the popup.198+                sasIds.push(p.id);199+              }200+              return Promise.resolve();201+            }202+            return Promise.resolve();203+          },204+        },205+      },206+    });207+    void resolveSourceSasConfirmation; // imported for the post-approval call below208+172209     const started = await startRelayAdoptionSource();173210     expect(started.invitation).toMatch(/^peach-pair-v1:/);174211     await waitForPush("ADOPTION_SOURCE_SAS");212+    // The adoption must NOT be complete yet — the popup's SAS decision is the213+    // prerequisite for forwarding the root key.214+    await vi.waitFor(() => {215+      expect(mocks.sasDecisions.length).toBe(0);216+    });217+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();218+    expect(mocks.upsert).not.toHaveBeenCalled();219+220+    // Approve the SAS; the adoption should now proceed and complete.221+    const id = sasIds[0];222+    expect(typeof id).toBe("string");223+    resolveSourceSasConfirmation(id, true);224+    mocks.sasDecisions.push({ id, ok: true });225+175226     await waitForPush("ADOPTION_SOURCE_RESULT");176227 177228     expect(mocks.getRootKeyHex).toHaveBeenCalled();@@ -198,7 +249,7 @@198249     expect(sentTypes).toContain("ADOPT_COMPLETE");199250   });200251 201-  it("fails closed when the scanning device rejects the SAS", async () => {252+  it("fails closed when the popup rejects the SAS", async () => {202253     const socket = socketWithFrames([203254       {204255         type: "PEACH_HELLO",@@ -214,8 +265,30 @@214265     ]);215266     mocks.openSocket.mockResolvedValue(socket);216267 268+    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");269+    let pendingId: string | undefined;270+    Object.defineProperty(globalThis, "chrome", {271+      configurable: true,272+      value: {273+        runtime: {274+          sendMessage(message: { type: string; payload: unknown }) {275+            mocks.pushes.push(message);276+            if (message.type === "ADOPTION_SOURCE_SAS") {277+              const p = message.payload as { id?: string };278+              if (typeof p?.id === "string") pendingId = p.id;279+            }280+            return Promise.resolve();281+          },282+        },283+      },284+    });285+217286     await startRelayAdoptionSource();218287     await waitForPush("ADOPTION_SOURCE_SAS");288+    expect(typeof pendingId).toBe("string");289+    resolveSourceSasConfirmation(pendingId!, false);290+    mocks.sasDecisions.push({ id: pendingId!, ok: false });291+219292     await waitForPush("ADOPTION_SOURCE_RESULT");220293 221294     expect(mocks.getRootKeyHex).not.toHaveBeenCalled();@@ -224,6 +297,34 @@224297       type: "ADOPTION_SOURCE_RESULT",225298       payload: { status: "error", error: "adoption_rejected" },226299     });300+  });301+302+  it("fails closed when the user never answers the SAS", async () => {303+    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they304+    // schedule real work), so we hold the SAS gate open by never resolving305+    // the deferred and then cancel the session. clearState must drop the306+    // pending SAS so it doesn't pin the relay room open for the next run.307+    const socket = socketWithFrames([]);308+    mocks.openSocket.mockResolvedValue(socket);309+310+    Object.defineProperty(globalThis, "chrome", {311+      configurable: true,312+      value: {313+        runtime: {314+          sendMessage(message: { type: string; payload: unknown }) {315+            mocks.pushes.push(message);316+            return Promise.resolve();317+          },318+        },319+      },320+    });321+322+    await startRelayAdoptionSource();323+    cancelRelayAdoptionSource();324+325+    expect(socket.close).toHaveBeenCalled();326+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();327+    expect(mocks.upsert).not.toHaveBeenCalled();227328   });228329 229330   it("cancelling the source screen closes the room without reading the root", async () => {

view file

src/background/relay-adoption-source-bg.ts+59 −6
@@ -63,6 +63,11 @@6363   id: string;6464   resolve: (ok: boolean) => void;6565 } | null = null;66+// Outstanding source-side SAS confirmations, keyed by id. A map (vs the67+// receiver's single slot) makes a stale decision from a previous source68+// session impossible to land on the current one even if both run on the69+// same background worker.70+const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();6671 6772 export function isRelayAdoptionSourceInFlight(): boolean {6873   return inFlight;@@ -173,6 +178,21 @@173178   return true;174179 }175180 181+/**182+ * Resolves the source-side SAS confirmation by id. Called by the message183+ * handler when the popup taps Confirm/Cancel on the source's SAS screen.184+ * Returns true if the id matched an in-flight confirmation. Unknown ids185+ * (already resolved, timed out, or never existed) are a no-op so a late186+ * popup click cannot crash anything.187+ */188+export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {189+  const pending = pendingSourceSasConfirmation.get(id);190+  if (!pending) return false;191+  pendingSourceSasConfirmation.delete(id);192+  pending.resolve(ok);193+  return true;194+}195+176196 async function runSourceSession(177197   socket: PairingSocket,178198   expectedToken: string,@@ -239,6 +259,7 @@239259       onConsent: (adoptRequest) => requestConsent(240260         adoptRequest,241261         derivePairingSAS(session.handshakeHash),262+        crypto.randomUUID(),242263       ),243264       upsertPairedDevice,244265     });@@ -267,6 +288,7 @@267288     clearTimeout(timeout);268289     noise?.close();269290     socket.close();291+    pendingSourceSasConfirmation.clear();270292   }271293 }272294 @@ -419,22 +441,53 @@419441 }420442 421443 /**444+ * Push the SAS to the popup, then block until the popup responds via the445+ * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on446+ * timeout or cancel so the adoption aborts before any vault state changes.447+ *448+ * The pending deferred MUST be installed before the push: a popup-side449+ * auto-approver (test mocks, future automation) can synchronously resolve450+ * the deferred the moment it sees the SAS push, and a missing slot would451+ * drop that approval on the floor.452+ */453+function waitForSourceSasConfirmation(454+  id: string,455+  request: AdoptRequest,456+  sas: string,457+): Promise<boolean> {458+  return new Promise<boolean>((resolve) => {459+    const timer = setTimeout(() => {460+      pendingSourceSasConfirmation.delete(id);461+      resolve(false);462+    }, RECEIVER_SAS_TIMEOUT_MS);463+    pendingSourceSasConfirmation.set(id, {464+      resolve: (ok) => {465+        clearTimeout(timer);466+        resolve(ok);467+      },468+    });469+    push("ADOPTION_SOURCE_SAS", {470+      id,471+      sas,472+      receiverDeviceName: request.device_name,473+    });474+  });475+}476+477+/**422478  * Opening Add Device in an unlocked extension is the source-side authorization.423479  * Announce the SAS for comparison, then let the scanning device's explicit424480  * confirmation be the single commit gate. grantAdoption still refuses to send425481  * the root key until the receiver returns ADOPT_CONFIRM(true).426482  */427-function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {428-  push("ADOPTION_SOURCE_SAS", {429-    sas,430-    receiverDeviceName: request.device_name,431-  });432-  return Promise.resolve(true);483+function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {484+  return waitForSourceSasConfirmation(id, request, sas);433485 }434486 435487 function clearState(): void {436488   activeSocket = null;437489   inFlight = false;490+  pendingSourceSasConfirmation.clear();438491 }439492 440493 function constantTimeEqual(a: string, b: string): boolean {

view file

src/popup/views/PairDevice.test.tsx+20 −3
@@ -136,25 +136,42 @@136136     });137137   });138138 139-  it("shows the SAS and waits for the phone's single confirmation", async () => {139+  it("shows the SAS and waits for the popup's single confirmation", async () => {140140     const { container } = mount();141141     await vi.waitFor(() => {142142       expect(container.textContent).toContain("This extension has the vault");143143     });144144 145145     pushFromBackground("ADOPTION_SOURCE_SAS", {146+      id: "test-sas-id",146147       sas: "48219371",147148       receiverDeviceName: "June's phone",148149     });149150     expect(container.textContent).toContain("June's phone");150151     expect(container.textContent).toContain("48219371");151152     expect(container.textContent).toContain("Compare the 8-digit safety code");152-    expect(container.textContent).toContain("tap “They match” there");153153     expect(container.querySelector("#adoption-consent-password")).toBeNull();154+    // The pop-up must NOT auto-approve; it waits for the user to tap a button.154155     expect(mockSendMessage).not.toHaveBeenCalledWith(155-      expect.objectContaining({ type: "ADOPTION_SOURCE_CONFIRM" }),156+      expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),156157     );158+    const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]159+      .find((b) => b.textContent?.trim() === "They match");160+    expect(matchButton).toBeTruthy();161+    const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]162+      .find((b) => b.textContent?.trim() === "They don't match");163+    expect(rejectButton).toBeTruthy();157164 165+    matchButton!.click();166+    await vi.waitFor(() => {167+      expect(mockSendMessage).toHaveBeenCalledWith({168+        type: "SOURCE_SAS_DECISION",169+        payload: { id: "test-sas-id", ok: true },170+      });171+    });172+173+    // After the user answers, the background completes the rest of the protocol174+    // and pushes the terminal result.158175     pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });159176     expect(container.textContent).toContain("Device added");160177     expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);

view file

src/popup/views/PairDevice.tsx+39 −4
@@ -39,6 +39,7 @@3939   const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");4040   const [invitation, setInvitation] = createSignal("");4141   const [sourceSas, setSourceSas] = createSignal("");42+  const [pendingSasId, setPendingSasId] = createSignal("");4243   const [receiverName, setReceiverName] = createSignal("New device");4344   const [shareError, setShareError] = createSignal("");4445   const [changingRelay, setChangingRelay] = createSignal(false);@@ -77,6 +78,20 @@7778     await startShareFlow();7879   }7980 81+  function answerSas(ok: boolean): void {82+    const id = pendingSasId();83+    if (!id) return;84+    setPendingSasId("");85+    void sendMessage({86+      type: "SOURCE_SAS_DECISION",87+      payload: { id, ok },88+    }).catch(() => undefined);89+    if (!ok) {90+      setShareError("adoption_rejected");91+      setSharePhase("error");92+    }93+  }94+8095   onMount(() => {8196     void startShareFlow();8297 @@ -87,11 +102,13 @@87102       const handler = (msg: { type?: string; payload?: unknown }) => {88103         if (msg?.type === "ADOPTION_SOURCE_SAS") {89104           const p = msg.payload as {105+            id?: string;90106             sas?: string;91107             receiverDeviceName?: string;92108           } | undefined;93109           if (typeof p?.sas === "string") {94110             setSourceSas(p.sas);111+            setPendingSasId(typeof p.id === "string" ? p.id : "");95112             setReceiverName(96113               typeof p.receiverDeviceName === "string" && p.receiverDeviceName97114                 ? p.receiverDeviceName@@ -104,8 +121,11 @@104121             status?: "complete" | "error";105122             error?: string;106123           } | undefined;107-          if (p?.status === "complete") setSharePhase("paired");108-          else if (p?.status === "error") {124+          if (p?.status === "complete") {125+            setPendingSasId("");126+            setSharePhase("paired");127+          } else if (p?.status === "error") {128+            setPendingSasId("");109129             setShareError(p.error ?? "adoption_failed");110130             setSharePhase("error");111131           }@@ -118,6 +138,13 @@118138     // Closing the popup mid-session must release the relay room, or the next119139     // attempt is refused with adoption_source_in_progress.120140     onCleanup(() => {141+      const id = pendingSasId();142+      if (id) {143+        void sendMessage({144+          type: "SOURCE_SAS_DECISION",145+          payload: { id, ok: false },146+        }).catch(() => undefined);147+      }121148       if (sharePhase() !== "paired") {122149         void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });123150       }@@ -240,11 +267,19 @@240267         <div class="peach-desktop-searching">241268           <div class="peach-pair-headline">Compare the 8-digit safety code</div>242269           <div class="peach-pair-sub">243-            If {receiverName()} shows the same code, tap “They match” there. Peach244-            will finish only after both devices save the pairing.270+            If {receiverName()} shows the same code, tap “They match” on that271+            device too. Peach will only finish after both devices confirm.245272           </div>246273           <div class="peach-adoption-sas" aria-label="Pairing confirmation code">247274             <strong>{sourceSas()}</strong>275+          </div>276+          <div class="peach-pair-action">277+            <button class="peach-btn peach-btn-ghost" onClick={() => answerSas(false)}>278+              They don't match279+            </button>280+            <button class="peach-btn peach-btn-primary" onClick={() => answerSas(true)}>281+              They match282+            </button>248283           </div>249284         </div>250285       </Show>

view file

src/shared/types.ts+1 −0
@@ -309,6 +309,7 @@309309   | "START_ADOPTION_RECEIVER"310310   | "CANCEL_ADOPTION_SOURCE"311311   | "RECEIVER_SAS_DECISION"312+  | "SOURCE_SAS_DECISION"312313   | "ADOPTION_SOURCE_SAS"313314   | "ADOPTION_SOURCE_RESULT"314315   | "TRIGGER_SYNC"

view file