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
↓ peach-browser-e97b03ea.tar.gzatlas at this save
files changed6 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 −0src/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": {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 () => {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 {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);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>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"