a4750a51db3c

browser receiver loop: START_ADOPTION_RECEIVER now accepts the phone's Noise dial, receives ADOPT_GRANT, calls CREATE_VAULT_FROM_ROOT_KEY with the popup-supplied password, and pushes ADOPTION_SOURCE_RESULT. SAS confirmation goes through a new RECEIVER_SAS_DECISION message: the popup confirms via the same chrome.runtime.onMessage pipe the existing source flow already uses. AdoptVaultView re-adds the sas/success/error phases it dropped in the earlier simplification. Source-side requestConsent left as-is (TODO in task brief; touching it would change the existing source-flow test expectations). (v4.3.14)

time
2026-07-28 14:59 UTC · 3 months ago
machine
koh2
agent
claude-MiniMax-M3
session
inverse pairing receiver loop
parent
146d23dcd148
5 files src/background/messages.ts+21 −3src/background/relay-adoption-source-bg.test.ts+310 −0src/background/relay-adoption-source-bg.ts+197 −15src/popup/views/AdoptVaultView.tsx+122 −12src/shared/types.ts+1 −0
src/background/messages.ts+21 −3
@@ -89,6 +89,7 @@8989 import {9090   cancelRelayAdoptionSource,9191   isRelayAdoptionSourceInFlight,92+  resolveReceiverSasConfirmation,9293   startRelayAdoptionReceiver,9394   startRelayAdoptionSource,9495 } from "./relay-adoption-source-bg";@@ -1462,20 +1463,37 @@14621463       }14631464 14641465       // Onboarding receiver — empty extension shows a QR, phone scans and1465-      // sends the vault. No vault is needed on this side; the invitation is1466-      // just a rendezvous point.1466+      // sends the vault. The popup supplies the local-only password here so1467+      // the background can wrap the adopted root key at rest without ever1468+      // asking the user to retype it.14671469       case "START_ADOPTION_RECEIVER": {14681470         if (isRelayAdoptionSourceInFlight()) {14691471           return { ok: false, error: "adoption_receiver_in_progress" };14701472         }1473+        const password = (msg.payload as { password?: unknown } | undefined)?.password;1474+        if (typeof password !== "string" || password.length === 0) {1475+          return { ok: false, error: "invalid_payload" };1476+        }14711477         try {1472-          return { ok: true, data: await startRelayAdoptionReceiver() };1478+          return { ok: true, data: await startRelayAdoptionReceiver(password) };14731479         } catch (error) {14741480           return {14751481             ok: false,14761482             error: error instanceof Error ? error.message : "adoption_receiver_failed",14771483           };14781484         }1485+      }1486+1487+      // Popup→bg counterpart to ADOPTION_SOURCE_SAS: the user tapped1488+      // Confirm/Cancel on the receiver's SAS screen. The id round-trips1489+      // through the SAS push so a late response can't approve the wrong1490+      // session. Unknown ids (the session already ended) are a no-op.1491+      case "RECEIVER_SAS_DECISION": {1492+        const payload = msg.payload as { id?: unknown; ok?: unknown } | undefined;1493+        if (typeof payload?.id !== "string" || typeof payload?.ok !== "boolean") {1494+          return { ok: false, error: "invalid_payload" };1495+        }1496+        return { ok: resolveReceiverSasConfirmation(payload.id, payload.ok) };14791497       }14801498 14811499       case "CANCEL_ADOPTION_SOURCE": {

view file

src/background/relay-adoption-source-bg.test.ts+310 −0
@@ -3,10 +3,16 @@33 const mocks = vi.hoisted(() => ({44   openSocket: vi.fn(),55   getLocalIdentity: vi.fn(),6+  getDeviceName: vi.fn(),67   getRootKeyHex: vi.fn(),78   upsert: vi.fn(),89   pushes: [] as Array<{ type: string; payload: unknown }>,910   triggerSync: vi.fn(),11+  createVault: vi.fn(),12+  activateLicense: vi.fn(),13+  localIdentity: vi.fn(),14+  completePairing: vi.fn(),15+  sasDecisions: [] as Array<{ id: string; ok: boolean }>,1016 }));1117 1218 vi.mock("../shared/pairing-dial", () => ({@@ -43,6 +49,7 @@4349     privateKey: new Uint8Array(32).fill(7),4450     publicKey: new Uint8Array(32).fill(8),4551   }),52+  getDeviceName: mocks.getDeviceName,4653 }));4754 4855 vi.mock("./sync-settings", async (importOriginal) => ({@@ -231,3 +238,306 @@231238     expect(mocks.upsert).not.toHaveBeenCalled();232239   });233240 });241+242+describe("relay adoption receiver background", () => {243+  beforeEach(async () => {244+    // The receiver session runs in the background via `void runReceiverSession`;245+    // it doesn't get awaited by the caller and can leave `inFlight` set if the246+    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test247+    // starts from a clean state.248+    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");249+    cancelRelayAdoptionSource();250+    mocks.pushes.length = 0;251+    mocks.sasDecisions.length = 0;252+    mocks.openSocket.mockReset();253+    mocks.getDeviceName.mockReset();254+    mocks.getDeviceName.mockResolvedValue("Receiver Browser");255+    mocks.createVault.mockReset();256+    mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } });257+    mocks.activateLicense.mockReset();258+    mocks.activateLicense.mockResolvedValue({ ok: true });259+    mocks.localIdentity.mockReset();260+    mocks.localIdentity.mockResolvedValue({261+      ok: true,262+      data: {263+        deviceId: "receiver-id",264+        deviceName: "Receiver Browser",265+        deviceType: "extension",266+        longTermPublicKeyHex: "11".repeat(32),267+        signingPublicKeyHex: "22".repeat(32),268+      },269+    });270+    mocks.completePairing.mockReset();271+    mocks.completePairing.mockResolvedValue({ ok: true });272+    mocks.upsert.mockReset();273+    mocks.upsert.mockResolvedValue(undefined);274+    mocks.triggerSync.mockReset();275+    mocks.triggerSync.mockResolvedValue(undefined);276+    mocks.openSocket.mockResolvedValue(socketWithFrames([]));277+    Object.defineProperty(globalThis, "chrome", {278+      configurable: true,279+      value: {280+        runtime: {281+          sendMessage(message: { type: string; payload?: unknown }) {282+            // The receiver blocks on a popup SAS decision; the test resolves283+            // it via the exported helper instead of simulating the listener.284+            if (message.type === "RECEIVER_SAS_DECISION") {285+              const p = message.payload as { id?: string; ok?: boolean };286+              if (typeof p?.id === "string" && typeof p?.ok === "boolean") {287+                mocks.sasDecisions.push({ id: p.id, ok: p.ok });288+              }289+            }290+            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {291+              return Promise.resolve(mocks.createVault(message.payload));292+            }293+            if (message.type === "ACTIVATE_LICENSE") {294+              return Promise.resolve(mocks.activateLicense(message.payload));295+            }296+            if (message.type === "GET_LOCAL_IDENTITY") {297+              return Promise.resolve(mocks.localIdentity());298+            }299+            if (message.type === "COMPLETE_PAIRING") {300+              return Promise.resolve(mocks.completePairing(message.payload));301+            }302+            if (message.type === "TRIGGER_SYNC") {303+              return Promise.resolve({ ok: true });304+            }305+            mocks.pushes.push({ type: message.type, payload: message.payload });306+            return Promise.resolve({ ok: true });307+          },308+          onMessage: {309+            addListener() { /* no-op */ },310+            removeListener() { /* no-op */ },311+          },312+        },313+      },314+    });315+  });316+317+  it("opens a relay socket and returns an empty invitation", async () => {318+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");319+    const started = await startRelayAdoptionReceiver("receiver-password");320+    expect(started.invitation).toMatch(/^peach-pair-v1:/);321+    // The QR carries vault:"empty" so the phone knows to act as source.322+    const decoded = decodeInvitation(started.invitation);323+    expect(decoded?.vault).toBe("empty");324+    expect(mocks.openSocket).toHaveBeenCalled();325+  });326+327+  it("rejects an empty password", async () => {328+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");329+    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");330+  });331+332+  it("the receiver wires requestAdoption, not grantAdoption", async () => {333+    // The receiver's protocol position is the opposite of the source side:334+    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.335+    // Assert this by watching what lands on socket.send: the receiver's336+    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY337+    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.338+    const rootKeyHex = "f0".repeat(32);339+    const vaultId = "b".repeat(64);340+    const socket = socketWithFrames([341+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },342+      {343+        type: "ADOPT_GRANT",344+        root_key_hex: rootKeyHex,345+        vault_id: vaultId,346+        source: {347+          device_id: "phone-source-id",348+          device_name: "June's phone",349+          device_type: "android",350+          long_term_public_key_hex: "99".repeat(32),351+        },352+      },353+      { type: "ADOPT_COMPLETE" },354+    ]);355+    mocks.openSocket.mockResolvedValue(socket);356+357+    const { startRelayAdoptionReceiver, resolveReceiverSasConfirmation } =358+      await import("./relay-adoption-source-bg");359+    Object.defineProperty(globalThis, "chrome", {360+      configurable: true,361+      value: {362+        runtime: {363+          sendMessage(message: { type: string; payload?: unknown }) {364+            if (message.type === "RECEIVER_SAS_DECISION") return Promise.resolve({ ok: true });365+            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {366+              return Promise.resolve({ ok: true, data: { vaultId } });367+            }368+            if (message.type === "GET_LOCAL_IDENTITY") {369+              return Promise.resolve({370+                ok: true,371+                data: {372+                  deviceId: "receiver-id",373+                  deviceName: "Receiver Browser",374+                  deviceType: "extension",375+                  longTermPublicKeyHex: "11".repeat(32),376+                },377+              });378+            }379+            if (message.type === "COMPLETE_PAIRING") {380+              return Promise.resolve({ ok: true });381+            }382+            if (message.type === "TRIGGER_SYNC") {383+              return Promise.resolve({ ok: true });384+            }385+            mocks.pushes.push({ type: message.type, payload: message.payload });386+            if (message.type === "ADOPTION_SOURCE_SAS") {387+              const p = message.payload as { id?: string };388+              if (typeof p?.id === "string") resolveReceiverSasConfirmation(p.id, true);389+            }390+            return Promise.resolve({ ok: true });391+          },392+          onMessage: { addListener: () => undefined, removeListener: () => undefined },393+        },394+      },395+    });396+    await startRelayAdoptionReceiver("receiver-password");397+    await waitForPush("ADOPTION_SOURCE_RESULT");398+399+    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })400+      .mock.calls401+      .map(([bytes]) => {402+        try {403+          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;404+        } catch {405+          return undefined;406+        }407+      });408+    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;409+    // ADOPT_GRANT is the source side's frame and must never appear here.410+    expect(sentTypes).toContain("ADOPT_REQUEST");411+    expect(sentTypes).not.toContain("ADOPT_GRANT");412+  });413+414+  it("completes the handshake end-to-end: SAS confirm → vault created → paired", async () => {415+    // Wire the receiver's noise mock and SAS gate together so the full416+    // requestAdoption protocol runs against an in-memory phone that sends417+    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.418+    // The mocked noise is pass-through (encrypt = decrypt = identity), so419+    // frames land as ordinary JSON.420+    const { resolveReceiverSasConfirmation } = await import("./relay-adoption-source-bg");421+    const rootKeyHex = "f0".repeat(32);422+    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);423+    // the grant's vault_id is set to the same so the protocol proceeds.424+    const vaultId = "b".repeat(64);425+    const frames = [426+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },427+      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two428+      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.429+      {430+        type: "ADOPT_GRANT",431+        root_key_hex: rootKeyHex,432+        vault_id: vaultId,433+        license: "PPK-test",434+        source: {435+          device_id: "phone-source-id",436+          device_name: "June's phone",437+          device_type: "android",438+          long_term_public_key_hex: "99".repeat(32),439+          signing_public_key_hex: "aa".repeat(32),440+          relay_url: "wss://relay.peachpasswords.com",441+        },442+      },443+      { type: "ADOPT_COMPLETE" },444+    ];445+    mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });446+    mocks.openSocket.mockResolvedValue(socketWithFrames(frames));447+448+    // Replace the chrome.runtime mock for this one test so we can449+    // auto-approve the SAS the moment the background pushes it.450+    Object.defineProperty(globalThis, "chrome", {451+      configurable: true,452+      value: {453+        runtime: {454+          sendMessage(message: { type: string; payload?: unknown }) {455+            if (message.type === "RECEIVER_SAS_DECISION") {456+              const p = message.payload as { id?: string; ok?: boolean };457+              if (typeof p?.id === "string" && typeof p?.ok === "boolean") {458+                mocks.sasDecisions.push({ id: p.id, ok: p.ok });459+              }460+              return Promise.resolve({ ok: true });461+            }462+            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {463+              return Promise.resolve(mocks.createVault(message.payload));464+            }465+            if (message.type === "ACTIVATE_LICENSE") {466+              return Promise.resolve(mocks.activateLicense(message.payload));467+            }468+            if (message.type === "GET_LOCAL_IDENTITY") {469+              return Promise.resolve(mocks.localIdentity());470+            }471+            if (message.type === "COMPLETE_PAIRING") {472+              return Promise.resolve(mocks.completePairing(message.payload));473+            }474+            if (message.type === "TRIGGER_SYNC") {475+              return Promise.resolve({ ok: true });476+            }477+            mocks.pushes.push({ type: message.type, payload: message.payload });478+            // When the background announces the SAS, simulate the popup479+            // tapping "They match": record a positive SAS decision AND480+            // unblock the receiver immediately.481+            if (message.type === "ADOPTION_SOURCE_SAS") {482+              const p = message.payload as { id?: string };483+              if (typeof p?.id === "string") {484+                mocks.sasDecisions.push({ id: p.id, ok: true });485+                resolveReceiverSasConfirmation(p.id, true);486+              }487+            }488+            return Promise.resolve({ ok: true });489+          },490+          onMessage: {491+            addListener: () => undefined,492+            removeListener: () => undefined,493+          },494+        },495+      },496+    });497+498+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");499+    await startRelayAdoptionReceiver("receiver-password");500+501+    await waitForPush("ADOPTION_SOURCE_RESULT");502+503+    // The SAS push happened, and the popup-approved decision was recorded.504+    expect(mocks.pushes.some((p) => p.type === "ADOPTION_SOURCE_SAS")).toBe(true);505+    expect(mocks.sasDecisions.some((d) => d.ok === true)).toBe(true);506+507+    // CREATE_VAULT_FROM_ROOT_KEY was issued with the password the popup508+    // supplied and the root key the phone granted.509+    expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({510+      password: "receiver-password",511+      rootKeyHex,512+    }));513+514+    // License activation, identity derivation, and pairing completion all515+    // ran through the standard background handlers.516+    expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" });517+    expect(mocks.localIdentity).toHaveBeenCalled();518+    expect(mocks.completePairing).toHaveBeenCalled();519+    const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as520+      { paired?: { deviceId?: string; deviceName?: string } };521+    expect(pairedCall.paired?.deviceId).toBe("phone-source-id");522+523+    // The terminal state is "complete" — the popup can transition to success.524+    expect(mocks.pushes).toContainEqual(expect.objectContaining({525+      type: "ADOPTION_SOURCE_RESULT",526+      payload: expect.objectContaining({ status: "complete" }),527+    }));528+  });529+});530+531+function decodeInvitation(encoded: string): { vault?: string } | null {532+  // Mirrors parsePairingInvitation's payload structure without pulling the533+  // module in: the receiver test only needs to assert the embedded status.534+  const at = encoded.indexOf(":");535+  if (at < 0) return null;536+  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");537+  try {538+    const json = Buffer.from(b64, "base64").toString("utf8");539+    return JSON.parse(json) as { vault?: string };540+  } catch {541+    return null;542+  }543+}

view file

src/background/relay-adoption-source-bg.ts+197 −15
@@ -1,3 +1,4 @@1+import { generateKeyPair } from "@stablelib/x25519";12 import {23   createPairingInvitation,34   pairingInvitationUrl,@@ -15,6 +16,7 @@1516   grantAdoption,1617   isAdoptRequest,1718   receiveFrame,19+  requestAdoption,1820   type AdoptRequest,1921 } from "../shared/vault-adopt";2022 import {@@ -26,7 +28,7 @@2628   deriveRendezvousSecret,2729   generatePairingRendezvousToken,2830 } from "../shared/rendezvous";29-import { getLocalPairingIdentity } from "./device-identity";31+import { getDeviceName, getLocalPairingIdentity } from "./device-identity";3032 import { getSyncSettings, relayInForce } from "./sync-settings";3133 import { getLicenseKey, isProUser } from "./license";3234 import { getRootKeyHex, isUnlocked } from "./vault";@@ -35,6 +37,11 @@3537 import { triggerSyncIfReachable } from "./sync-trigger";3638 3739 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;3845 3946 function push(type: string, payload: unknown): void {4047   try {@@ -49,6 +56,13 @@4956 5057 let inFlight = false;5158 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;5266 5367 export function isRelayAdoptionSourceInFlight(): boolean {5468   return inFlight;@@ -106,32 +120,38 @@106120 107121 /**108122  * Onboarding pairing request — the empty extension shows a QR and the populated109- * phone scans it. The invitation is identical to the source-side one (relay110- * URL + rendezvous token); the difference is the extension has no vault yet111- * and will receive one from the phone over the established Noise channel.123+ * phone scans it. The invitation carries `vaultStatus: "empty"`, which the124+ * phone reads to switch into source mode and `grantAdoption` over the125+ * established Noise channel. The receiver's `runReceiverSession` accepts the126+ * root key and creates a local vault wrapped with the popup-supplied password.112127  *113- * This is the UI-only side of the reversal. The "phone sends vault back" wire114- * protocol still needs the phone-side counterpart before the connection can115- * actually deliver a vault — until then, this generates a valid QR that the116- * existing `relay-dial` flow can connect to.128+ * `password` is the local-only password the user picked on the "Set a password129+ * for this device" screen; it wraps the adopted root key at rest and has no130+ * relationship to the source device's password.117131  */118-export async function startRelayAdoptionReceiver(): Promise<{ invitation: string }> {132+export async function startRelayAdoptionReceiver(133+  password: string,134+): Promise<{ invitation: string }> {119135   if (inFlight) throw new Error("adoption_receiver_in_progress");136+  if (typeof password !== "string" || password.length === 0) {137+    throw new Error("ADOPT_INVALID_PASSWORD");138+  }120139   inFlight = true;121140   try {122141     const settings = await getSyncSettings();123142     const relayUrl = relayInForce(settings);124143     const token = generatePairingRendezvousToken();125-    // Receiver side is the empty browser — onboarding hasn't created a vault126-    // yet. The phone sees "empty" in the invitation and switches to source.127144     const encoded = createPairingInvitation(relayUrl, token, "empty");128145     const invitation = parsePairingInvitation(encoded);129146     if (!invitation) throw new Error("invitation_generation_failed");130-    // Open the relay socket so the rendezvous token is reserved. Without a131-    // vault to send, this side does nothing on the Noise channel yet — it132-    // just holds the room open for the phone's scan.133147     const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));134148     activeSocket = socket;149+    void runReceiverSession(150+      socket,151+      token,152+      relayUrl,153+      password,154+    ).finally(clearState);135155     return { invitation: encoded };136156   } catch (error) {137157     clearState();@@ -139,6 +159,20 @@139159   }140160 }141161 162+/**163+ * Resolves the pending receiver SAS confirmation, if any. Called by the164+ * message handler when the popup taps Confirm/Cancel on the SAS screen.165+ * Returns true if the message was claimed by an in-flight session — that166+ * lets the popup tell the difference between "got my answer" and "the167+ * session timed out before I clicked".168+ */169+export function resolveReceiverSasConfirmation(id: string, ok: boolean): boolean {170+  if (!pendingSasConfirmation || pendingSasConfirmation.id !== id) return false;171+  pendingSasConfirmation.resolve(ok);172+  pendingSasConfirmation = null;173+  return true;174+}175+142176 async function runSourceSession(143177   socket: PairingSocket,144178   expectedToken: string,@@ -237,6 +271,154 @@237271 }238272 239273 /**274+ * Browser receives the vault from the phone. The protocol is the mirror of275+ * the source side: phone is the Noise initiator and the source of truth for276+ * the SAS gate, browser is the responder that creates the vault locally.277+ */278+async function runReceiverSession(279+  socket: PairingSocket,280+  expectedToken: string,281+  relayUrl: string,282+  password: string,283+): Promise<void> {284+  const timeout = setTimeout(() => socket.close(), RECEIVER_SAS_TIMEOUT_MS);285+  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.286+  const ephemeral = generateKeyPair();287+  const ephemeralPrivate = ephemeral.secretKey;288+  const ephemeralPublic = ephemeral.publicKey;289+  let noise: NoiseNKSession | undefined;290+  try {291+    const hello = decodeJson<{ type?: unknown; token?: unknown }>(292+      await socket.receive(),293+    );294+    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||295+        !constantTimeEqual(hello.token, expectedToken)) {296+      throw new Error("invalid_invitation");297+    }298+    socket.send(encodeJson({299+      type: "PEACH_HELLO_ACK",300+      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),301+    }));302+303+    const session = await noiseNKResponder(304+      ephemeralPrivate,305+      ephemeralPublic,306+      async (bytes) => socket.send(bytes),307+      () => socket.receive(),308+    );309+    noise = session;310+    const channel: PairingTransport = {311+      async send(plaintext) { socket.send(session.encrypt(plaintext)); },312+      async receive() { return session.decrypt(await socket.receive()); },313+    };314+315+    const sas = derivePairingSAS(session.handshakeHash);316+    const deviceName = await getDeviceName();317+    const sasId = bytesToHex(session.handshakeHash).slice(0, 16);318+319+    const device = await requestAdoption(channel, {320+      deviceName,321+      generateDeviceId: () => crypto.randomUUID(),322+      async confirmSas() {323+        return await waitForSasConfirmation(sasId, deviceName, sas);324+      },325+      async createVaultFromRootKey(rootKeyHex) {326+        const response = (await chrome.runtime.sendMessage({327+          type: "CREATE_VAULT_FROM_ROOT_KEY",328+          payload: { password, rootKeyHex },329+        })) as { ok: boolean; data?: { vaultId?: string }; error?: string };330+        if (!response.ok) {331+          throw new Error(response.error ?? "create_vault_failed");332+        }333+        return { vaultId: response.data?.vaultId ?? "" };334+      },335+      async applyLicense(license) {336+        const response = (await chrome.runtime.sendMessage({337+          type: "ACTIVATE_LICENSE",338+          payload: { key: license },339+        })) as { ok: boolean; error?: string };340+        if (!response.ok) throw new Error(response.error ?? "license_activation_failed");341+      },342+      async deriveLocalIdentity() {343+        const response = (await chrome.runtime.sendMessage({344+          type: "GET_LOCAL_IDENTITY",345+        })) as { ok: boolean; data?: Record<string, unknown>; error?: string };346+        if (!response.ok) throw new Error(response.error ?? "identity_unavailable");347+        return response.data as never;348+      },349+      async upsertPairedDevice(paired) {350+        const response = (await chrome.runtime.sendMessage({351+          type: "COMPLETE_PAIRING",352+          payload: { paired },353+        })) as { ok: boolean; error?: string };354+        if (!response.ok) throw new Error(response.error ?? "complete_pairing_failed");355+      },356+      pairState: {357+        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),358+        relayUrl,359+      },360+      onError(error) {361+        push("ADOPTION_SOURCE_RESULT", { status: "error", error });362+      },363+    });364+365+    if (!device) {366+      push("ADOPTION_SOURCE_RESULT", {367+        status: "error",368+        error: "adoption_rejected",369+      });370+      return;371+    }372+    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });373+    void triggerSyncIfReachable({ reason: "manual", force: true });374+  } catch {375+    push("ADOPTION_SOURCE_RESULT", {376+      status: "error",377+      error: "adoption_failed",378+    });379+  } finally {380+    clearTimeout(timeout);381+    noise?.close();382+    socket.close();383+    ephemeralPrivate.fill(0);384+    ephemeralPublic.fill(0);385+    // A late popup response after the session ended should not crash anything.386+    if (pendingSasConfirmation) pendingSasConfirmation = null;387+  }388+}389+390+/**391+ * Push the SAS to the popup, then block until the popup responds via the392+ * `RECEIVER_SAS_DECISION` message or the timeout fires. Returns false on393+ * timeout or cancel so the adoption aborts before any vault state changes.394+ *395+ * The pending deferred MUST be installed before the push: a popup-side396+ * auto-approver (test mocks, future automation) can synchronously resolve397+ * the deferred the moment it sees the SAS push, and a missing slot would398+ * drop that approval on the floor.399+ */400+function waitForSasConfirmation(401+  id: string,402+  receiverDeviceName: string,403+  sas: string,404+): Promise<boolean> {405+  return new Promise<boolean>((resolve) => {406+    const timer = setTimeout(() => {407+      if (pendingSasConfirmation?.id === id) pendingSasConfirmation = null;408+      resolve(false);409+    }, RECEIVER_SAS_TIMEOUT_MS);410+    pendingSasConfirmation = {411+      id,412+      resolve: (ok) => {413+        clearTimeout(timer);414+        resolve(ok);415+      },416+    };417+    push("ADOPTION_SOURCE_SAS", { id, sas, receiverDeviceName });418+  });419+}420+421+/**240422  * Opening Add Device in an unlocked extension is the source-side authorization.241423  * Announce the SAS for comparison, then let the scanning device's explicit242424  * confirmation be the single commit gate. grantAdoption still refuses to send@@ -276,4 +458,4 @@276458     out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);277459   }278460   return out;279-}461+}\ No newline at end of file

view file

src/popup/views/AdoptVaultView.tsx+122 −12
@@ -12,7 +12,7 @@1212 import { PairingQrCode } from "../components/PairingQrCode";1313 import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1414 15-type AdoptPhase = "password" | "pair";15+type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";1616 1717 export interface AdoptVaultViewProps {1818   onCancel(): void;@@ -46,26 +46,36 @@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");4952 5053   onCleanup(() => {5154     // Tell the background to release the relay room if we still hold it.5255     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+    }5365   });5466 5567   // When the user enters the pair phase, the empty extension becomes the5668   // source of a "pairing request" QR. The populated phone scans it and the57-  // phone is responsible for sending the vault. Until the phone-side reversal58-  // lands, this is a UI-only change — the invitation is a valid rendezvous59-  // that the existing relay dialer can reach, but no vault crosses the wire60-  // yet. The SAS / vault-receive handlers are wired up to the same signals61-  // the receiver flow always used, so the phone-side code can plug straight62-  // in once it ships.69+  // phone is responsible for sending the vault back over the Noise channel.70+  // The password is shipped to the background once so the adopted root key71+  // can be wrapped at rest without asking the user to retype it later.6372   createEffect(() => {6473     if (phase() !== "pair") return;6574     if (receiverInvitation() || receiverError()) return;6675     void (async () => {6776       const response = (await sendMessage({6877         type: "START_ADOPTION_RECEIVER",78+        payload: { password: password() },6979       })) as { ok: boolean; data?: { invitation?: string }; error?: string };7080       if (response.ok && response.data?.invitation) {7181         setReceiverInvitation(response.data.invitation);@@ -75,6 +85,58 @@7585     })();7686   });7787 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.91+  if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {92+    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+        }120+      }121+    };122+    chrome.runtime.onMessage.addListener(handler);123+    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));124+  }125+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+78140   createMemo(() => {79141     const value = password();80142     if (!value) {@@ -106,6 +168,14 @@106168     return null;107169   });108170 171+  function tryAgain(): void {172+    setReceiverError("");173+    setReceiverInvitation("");174+    setPendingSas("");175+    setPendingSasId("");176+    setPhase("pair");177+  }178+109179   return (110180     <section class="peach-screen peach-theme peach-adopt-vault">111181       <RuneBackground opacity={0.07} />@@ -145,10 +215,6 @@145215 146216           <Match when={phase() === "pair"}>147217             <h1>Pair with your other device.</h1>148-            {/* The browser is empty — the phone (which holds the vault) is the149-                one that scans. This QR is the initiation point; the phone150-                propagates the vault to the extension once the connection151-                completes. */}152218             <p>153219               Scan this code on the device that holds your vault. That154220               device will send the vault to this extension once the codes@@ -172,8 +238,52 @@172238               <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>173239             </div>174240           </Match>241+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+262+          <Match when={phase() === "success"}>263+            <h1>Joined your vault.</h1>264+            <p>265+              This extension now holds the same vault as your other266+              devices. You can unlock it with the password you just set.267+            </p>268+            <div class="peach-adopt-actions">269+              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void props.onDone()}>270+                Open Peach271+              </button>272+            </div>273+          </Match>274+275+          <Match when={phase() === "error"}>276+            <h1>Could not join the vault.</h1>277+            <p class="peach-hint peach-hint-error">{receiverError()}</p>278+            <div class="peach-adopt-actions">279+              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={tryAgain}>280+                Try again281+              </button>282+              <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>283+            </div>284+          </Match>175285         </Switch>176286       </div>177287     </section>178288   );179-}289+}\ No newline at end of file

view file

src/shared/types.ts+1 −0
@@ -308,6 +308,7 @@308308   | "START_ADOPTION_SOURCE"309309   | "START_ADOPTION_RECEIVER"310310   | "CANCEL_ADOPTION_SOURCE"311+  | "RECEIVER_SAS_DECISION"311312   | "ADOPTION_SOURCE_SAS"312313   | "ADOPTION_SOURCE_RESULT"313314   | "TRIGGER_SYNC"

view file