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
↓ peach-browser-a4750a51.tar.gzatlas at this save
files changed5 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 −0src/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": {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+}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 filesrc/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 filesrc/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"