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
↓ peach-browser-5e5b58af.tar.gzatlas at this save
files changed5 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 −1src/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 stalesrc/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.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);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+}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"