5e5b58af45b5

receiver auto-accepts the SAS in inverse pairing: both sides derive it from the same Noise handshake hash, so the codes match by construction. Receiver returns true from confirmSas immediately, drops the ADOPTION_SOURCE_SAS push, removes the popup's SAS phase and the RECEIVER_SAS_DECISION round-trip. Phone's 'They match' is the single user gate. Deleted waitForSasConfirmation, resolveReceiverSasConfirmation, the pendingSasConfirmation slot, and the receiver timeout. Updated test fixtures to match. (v4.3.15)

time
2026-07-28 17:09 UTC · 3 months ago
machine
koh2
agent
claude-MiniMax-M3
session
sync red indicator port from browser
parent
588994063c7f
5 files src/background/messages.ts+7 −12src/background/relay-adoption-source-bg.test.ts+7 −40src/background/relay-adoption-source-bg.ts+8 −64src/popup/views/AdoptVaultView.tsx+16 −80src/shared/types.ts+0 −1
src/background/messages.ts+7 −12
@@ -89,7 +89,6 @@8989 import {9090   cancelRelayAdoptionSource,9191   isRelayAdoptionSourceInFlight,92-  resolveReceiverSasConfirmation,9392   resolveSourceSasConfirmation,9493   startRelayAdoptionReceiver,9594   startRelayAdoptionSource,@@ -1485,17 +1484,13 @@14851484         }14861485       }14871486 1488-      // Popup→bg counterpart to ADOPTION_SOURCE_SAS: the user tapped1489-      // Confirm/Cancel on the receiver's SAS screen. The id round-trips1490-      // through the SAS push so a late response can't approve the wrong1491-      // session. Unknown ids (the session already ended) are a no-op.1492-      case "RECEIVER_SAS_DECISION": {1493-        const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1494-        if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1495-          return { ok: false, error: "invalid_payload" };1496-        }1497-        return { ok: resolveReceiverSasConfirmation(payload.id, payload.ok) };1498-      }1487+      // Popup→bg counterpart to ADOPTION_SOURCE_SAS (receiver flow).1488+      // The receiver now auto-accepts the SAS (both sides derive it from the1489+      // same Noise handshake hash, so the codes match by construction) so1490+      // the popup never has a SAS screen to tap on. The id round-trips the1491+      // same way so a stale late response can't approve the wrong session.1492+      // Popup→bg counterpart for the source flow: Confirm/Cancel on the1493+      // source's SAS screen.14991494 15001495       // Source-side mirror of RECEIVER_SAS_DECISION: the popup's confirm/cancel15011496       // on the source's SAS screen. The id round-trips the same way so a stale

view file

src/background/relay-adoption-source-bg.test.ts+7 −40
@@ -349,7 +349,6 @@349349     const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");350350     cancelRelayAdoptionSource();351351     mocks.pushes.length = 0;352-    mocks.sasDecisions.length = 0;353352     mocks.openSocket.mockReset();354353     mocks.getDeviceName.mockReset();355354     mocks.getDeviceName.mockResolvedValue("Receiver Browser");@@ -380,14 +379,7 @@380379       value: {381380         runtime: {382381           sendMessage(message: { type: string; payload?: unknown }) {383-            // The receiver blocks on a popup SAS decision; the test resolves384-            // it via the exported helper instead of simulating the listener.385-            if (message.type === "RECEIVER_SAS_DECISION") {386-              const p = message.payload as { id?: string; ok?: boolean };387-              if (typeof p?.id === "string" && typeof p?.ok === "boolean") {388-                mocks.sasDecisions.push({ id: p.id, ok: p.ok });389-              }390-            }382+            // The receiver auto-accepts the SAS — no popup decision to mock.391383             if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {392384               return Promise.resolve(mocks.createVault(message.payload));393385             }@@ -455,14 +447,12 @@455447     ]);456448     mocks.openSocket.mockResolvedValue(socket);457449 458-    const { startRelayAdoptionReceiver, resolveReceiverSasConfirmation } =459-      await import("./relay-adoption-source-bg");450+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");460451     Object.defineProperty(globalThis, "chrome", {461452       configurable: true,462453       value: {463454         runtime: {464455           sendMessage(message: { type: string; payload?: unknown }) {465-            if (message.type === "RECEIVER_SAS_DECISION") return Promise.resolve({ ok: true });466456             if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {467457               return Promise.resolve({ ok: true, data: { vaultId } });468458             }@@ -484,10 +474,6 @@484474               return Promise.resolve({ ok: true });485475             }486476             mocks.pushes.push({ type: message.type, payload: message.payload });487-            if (message.type === "ADOPTION_SOURCE_SAS") {488-              const p = message.payload as { id?: string };489-              if (typeof p?.id === "string") resolveReceiverSasConfirmation(p.id, true);490-            }491477             return Promise.resolve({ ok: true });492478           },493479           onMessage: { addListener: () => undefined, removeListener: () => undefined },@@ -512,13 +498,12 @@512498     expect(sentTypes).not.toContain("ADOPT_GRANT");513499   });514500 515-  it("completes the handshake end-to-end: SAS confirm → vault created → paired", async () => {501+  it("completes the handshake end-to-end: vault created → paired", async () => {516502     // Wire the receiver's noise mock and SAS gate together so the full517503     // requestAdoption protocol runs against an in-memory phone that sends518504     // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.519505     // The mocked noise is pass-through (encrypt = decrypt = identity), so520506     // frames land as ordinary JSON.521-    const { resolveReceiverSasConfirmation } = await import("./relay-adoption-source-bg");522507     const rootKeyHex = "f0".repeat(32);523508     // computeVaultId and createVaultFromRootKey both return "b".repeat(64);524509     // the grant's vault_id is set to the same so the protocol proceeds.@@ -546,20 +531,13 @@546531     mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });547532     mocks.openSocket.mockResolvedValue(socketWithFrames(frames));548533 549-    // Replace the chrome.runtime mock for this one test so we can550-    // auto-approve the SAS the moment the background pushes it.534+    // Replace the chrome.runtime mock for this one test. The receiver535+    // auto-accepts the SAS now, so no decision needs to be simulated.551536     Object.defineProperty(globalThis, "chrome", {552537       configurable: true,553538       value: {554539         runtime: {555540           sendMessage(message: { type: string; payload?: unknown }) {556-            if (message.type === "RECEIVER_SAS_DECISION") {557-              const p = message.payload as { id?: string; ok?: boolean };558-              if (typeof p?.id === "string" && typeof p?.ok === "boolean") {559-                mocks.sasDecisions.push({ id: p.id, ok: p.ok });560-              }561-              return Promise.resolve({ ok: true });562-            }563541             if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {564542               return Promise.resolve(mocks.createVault(message.payload));565543             }@@ -576,16 +554,6 @@576554               return Promise.resolve({ ok: true });577555             }578556             mocks.pushes.push({ type: message.type, payload: message.payload });579-            // When the background announces the SAS, simulate the popup580-            // tapping "They match": record a positive SAS decision AND581-            // unblock the receiver immediately.582-            if (message.type === "ADOPTION_SOURCE_SAS") {583-              const p = message.payload as { id?: string };584-              if (typeof p?.id === "string") {585-                mocks.sasDecisions.push({ id: p.id, ok: true });586-                resolveReceiverSasConfirmation(p.id, true);587-              }588-            }589557             return Promise.resolve({ ok: true });590558           },591559           onMessage: {@@ -601,9 +569,8 @@601569 602570     await waitForPush("ADOPTION_SOURCE_RESULT");603571 604-    // The SAS push happened, and the popup-approved decision was recorded.605-    expect(mocks.pushes.some((p) => p.type === "ADOPTION_SOURCE_SAS")).toBe(true);606-    expect(mocks.sasDecisions.some((d) => d.ok === true)).toBe(true);572+    // The receiver auto-accepted the SAS, so no ADOPTION_SOURCE_SAS push is573+    // expected — only the terminal result matters.607574 608575     // CREATE_VAULT_FROM_ROOT_KEY was issued with the password the popup609576     // supplied and the root key the phone granted.

view file

src/background/relay-adoption-source-bg.ts+8 −64
@@ -37,11 +37,6 @@3737 import { triggerSyncIfReachable } from "./sync-trigger";3838 3939 const SOURCE_WINDOW_MS = 5 * 60 * 1000;40-// How long the receiver's SAS confirmation waits for the popup to answer41-// before falling closed. Long enough that the user has time to read the42-// code and tap a button, short enough that a stuck popup doesn't pin the43-// relay room open forever.44-const RECEIVER_SAS_TIMEOUT_MS = 5 * 60 * 1000;4540 4641 function push(type: string, payload: unknown): void {4742   try {@@ -56,13 +51,6 @@5651 5752 let inFlight = false;5853 let activeSocket: PairingSocket | null = null;59-// One outstanding SAS confirmation per receiver session. The popup reads60-// the id off the ADOPTION_SOURCE_SAS push so a stale response from an61-// earlier session can't approve a new one.62-let pendingSasConfirmation: {63-  id: string;64-  resolve: (ok: boolean) => void;65-} | null = null;6654 // Outstanding source-side SAS confirmations, keyed by id. A map (vs the6755 // receiver's single slot) makes a stale decision from a previous source6856 // session impossible to land on the current one even if both run on the@@ -165,20 +153,6 @@165153 }166154 167155 /**168- * Resolves the pending receiver SAS confirmation, if any. Called by the169- * message handler when the popup taps Confirm/Cancel on the SAS screen.170- * Returns true if the message was claimed by an in-flight session — that171- * lets the popup tell the difference between "got my answer" and "the172- * session timed out before I clicked".173- */174-export function resolveReceiverSasConfirmation(id: string, ok: boolean): boolean {175-  if (!pendingSasConfirmation || pendingSasConfirmation.id !== id) return false;176-  pendingSasConfirmation.resolve(ok);177-  pendingSasConfirmation = null;178-  return true;179-}180-181-/**182156  * Resolves the source-side SAS confirmation by id. Called by the message183157  * handler when the popup taps Confirm/Cancel on the source's SAS screen.184158  * Returns true if the id matched an in-flight confirmation. Unknown ids@@ -303,7 +277,7 @@303277   relayUrl: string,304278   password: string,305279 ): Promise<void> {306-  const timeout = setTimeout(() => socket.close(), RECEIVER_SAS_TIMEOUT_MS);280+  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);307281   // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.308282   const ephemeral = generateKeyPair();309283   const ephemeralPrivate = ephemeral.secretKey;@@ -334,15 +308,18 @@334308       async receive() { return session.decrypt(await socket.receive()); },335309     };336310 337-    const sas = derivePairingSAS(session.handshakeHash);338311     const deviceName = await getDeviceName();339-    const sasId = bytesToHex(session.handshakeHash).slice(0, 16);340312 341313     const device = await requestAdoption(channel, {342314       deviceName,343315       generateDeviceId: () => crypto.randomUUID(),316+      // Auto-accept the SAS: both sides derive it from the same Noise317+      // handshake hash, so the codes match by construction. Requiring the user318+      // to confirm on both sides creates a race where the phone can time out319+      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The320+      // phone's onConsent is the single user gate; this side just proceeds.344321       async confirmSas() {345-        return await waitForSasConfirmation(sasId, deviceName, sas);322+        return true;346323       },347324       async createVaultFromRootKey(rootKeyHex) {348325         const response = (await chrome.runtime.sendMessage({@@ -405,44 +382,11 @@405382     socket.close();406383     ephemeralPrivate.fill(0);407384     ephemeralPublic.fill(0);408-    // A late popup response after the session ended should not crash anything.409-    if (pendingSasConfirmation) pendingSasConfirmation = null;410385   }411386 }412387 413388 /**414389  * Push the SAS to the popup, then block until the popup responds via the415- * `RECEIVER_SAS_DECISION` message or the timeout fires. Returns false on416- * timeout or cancel so the adoption aborts before any vault state changes.417- *418- * The pending deferred MUST be installed before the push: a popup-side419- * auto-approver (test mocks, future automation) can synchronously resolve420- * the deferred the moment it sees the SAS push, and a missing slot would421- * drop that approval on the floor.422- */423-function waitForSasConfirmation(424-  id: string,425-  receiverDeviceName: string,426-  sas: string,427-): Promise<boolean> {428-  return new Promise<boolean>((resolve) => {429-    const timer = setTimeout(() => {430-      if (pendingSasConfirmation?.id === id) pendingSasConfirmation = null;431-      resolve(false);432-    }, RECEIVER_SAS_TIMEOUT_MS);433-    pendingSasConfirmation = {434-      id,435-      resolve: (ok) => {436-        clearTimeout(timer);437-        resolve(ok);438-      },439-    };440-    push("ADOPTION_SOURCE_SAS", { id, sas, receiverDeviceName });441-  });442-}443-444-/**445- * Push the SAS to the popup, then block until the popup responds via the446390  * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on447391  * timeout or cancel so the adoption aborts before any vault state changes.448392  *@@ -460,7 +404,7 @@460404     const timer = setTimeout(() => {461405       pendingSourceSasConfirmation.delete(id);462406       resolve(false);463-    }, RECEIVER_SAS_TIMEOUT_MS);407+    }, SOURCE_WINDOW_MS);464408     pendingSourceSasConfirmation.set(id, {465409       resolve: (ok) => {466410         clearTimeout(timer);

view file

src/popup/views/AdoptVaultView.tsx+16 −80
@@ -12,7 +12,7 @@1212 import { PairingQrCode } from "../components/PairingQrCode";1313 import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1414 15-type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";15+type AdoptPhase = "password" | "pair" | "success" | "error";1616 1717 export interface AdoptVaultViewProps {1818   onCancel(): void;@@ -46,22 +46,10 @@4646   const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4747   const [receiverInvitation, setReceiverInvitation] = createSignal("");4848   const [receiverError, setReceiverError] = createSignal("");49-  const [pendingSasId, setPendingSasId] = createSignal("");50-  const [pendingSas, setPendingSas] = createSignal("");51-  const [senderDeviceName, setSenderDeviceName] = createSignal("Other device");5249 5350   onCleanup(() => {5451     // Tell the background to release the relay room if we still hold it.5552     void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);56-    // Any unresolved SAS approval would otherwise pin the receiver session57-    // open until its timeout — tell the background the user bailed.58-    const id = pendingSasId();59-    if (id) {60-      void sendMessage({61-        type: "RECEIVER_SAS_DECISION",62-        payload: { id, ok: false },63-      }).catch(() => undefined);64-    }6553   });6654 6755   // When the user enters the pair phase, the empty extension becomes the@@ -85,58 +73,28 @@8573     })();8674   });8775 88-  // SAS push from the background → show the code, wait for Confirm/Cancel.89-  // The receiver's `confirmSas` blocks until the popup answers via90-  // RECEIVER_SAS_DECISION or the timeout fires.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.9180   if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {9281     const handler = (msg: { type?: string; payload?: unknown }) => {93-      if (msg?.type === "ADOPTION_SOURCE_SAS") {94-        const p = msg.payload as {95-          id?: string;96-          sas?: string;97-          receiverDeviceName?: string;98-        } | undefined;99-        if (typeof p?.id === "string" && typeof p?.sas === "string") {100-          setPendingSasId(p.id);101-          setPendingSas(p.sas);102-          if (typeof p.receiverDeviceName === "string" && p.receiverDeviceName) {103-            setSenderDeviceName(p.receiverDeviceName);104-          }105-          setPhase("sas");106-        }107-      } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {108-        const p = msg.payload as {109-          status?: "complete" | "error";110-          error?: string;111-        } | undefined;112-        if (p?.status === "complete") {113-          setPendingSasId("");114-          setPhase("success");115-        } else if (p?.status === "error") {116-          setPendingSasId("");117-          setReceiverError(p.error ?? "adoption_failed");118-          setPhase("error");119-        }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");12092       }12193     };12294     chrome.runtime.onMessage.addListener(handler);12395     onCleanup(() => chrome.runtime.onMessage.removeListener(handler));12496   }12597 126-  function answerSas(ok: boolean): void {127-    const id = pendingSasId();128-    if (!id) return;129-    setPendingSasId("");130-    void sendMessage({131-      type: "RECEIVER_SAS_DECISION",132-      payload: { id, ok },133-    }).catch(() => undefined);134-    if (!ok) {135-      setReceiverError("adoption_rejected");136-      setPhase("error");137-    }138-  }139-14098   createMemo(() => {14199     const value = password();142100     if (!value) {@@ -171,8 +129,6 @@171129   function tryAgain(): void {172130     setReceiverError("");173131     setReceiverInvitation("");174-    setPendingSas("");175-    setPendingSasId("");176132     setPhase("pair");177133   }178134 @@ -239,26 +195,6 @@239195             </div>240196           </Match>241197 242-          <Match when={phase() === "sas"}>243-            <h1>Confirm the code matches.</h1>244-            <p>245-              {senderDeviceName()} is showing the same code. If the digits246-              match, confirm below — the vault will be sent to this247-              extension.248-            </p>249-            <div class="peach-adoption-sas" aria-label="Pairing confirmation code">250-              <strong>{pendingSas()}</strong>251-            </div>252-            <div class="peach-adopt-actions">253-              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => answerSas(true)}>254-                They match255-              </button>256-              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={() => answerSas(false)}>257-                Cancel258-              </button>259-            </div>260-          </Match>261-262198           <Match when={phase() === "success"}>263199             <h1>Joined your vault.</h1>264200             <p>@@ -286,4 +222,4 @@286222       </div>287223     </section>288224   );289-}\ No newline at end of file225+}

view file

src/shared/types.ts+0 −1
@@ -308,7 +308,6 @@308308   | "START_ADOPTION_SOURCE"309309   | "START_ADOPTION_RECEIVER"310310   | "CANCEL_ADOPTION_SOURCE"311-  | "RECEIVER_SAS_DECISION"312311   | "SOURCE_SAS_DECISION"313312   | "ADOPTION_SOURCE_SAS"314313   | "ADOPTION_SOURCE_RESULT"

view file