relay-adoption-source-bg.ts

15.5 KB · raw

1import { generateKeyPair } from "@stablelib/x25519";2import {3  createPairingInvitation,4  pairingInvitationUrl,5  parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8  openRelayPairingSocket,9  type PairingSocket,10} from "../shared/pairing-dial";11import {12  noiseNKResponder,13  type NoiseNKSession,14} from "../shared/noise-js";15import {16  grantAdoption,17  isAdoptRequest,18  receiveFrame,19  requestAdoption,20  type AdoptRequest,21} from "../shared/vault-adopt";22import {23  derivePairingSAS,24  type PairingTransport,25} from "../shared/pairing-handshake";26import {27  bytesToHex,28  deriveRendezvousSecret,29  generatePairingRendezvousToken,30} from "../shared/rendezvous";31import { getDeviceName, getLocalPairingIdentity } from "./device-identity";32import { getSyncSettings, relayInForce } from "./sync-settings";33import { getLicenseKey, isProUser } from "./license";34import { getRootKeyHex, isUnlocked } from "./vault";35import { computeVaultId } from "../shared/sync-identity";36import { upsertPairedDevice } from "./paired-devices";37import { triggerSyncIfReachable } from "./sync-trigger";3839const 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.44const RECEIVER_SAS_TIMEOUT_MS = 5 * 60 * 1000;4546function push(type: string, payload: unknown): void {47  try {48    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;49    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {50      (maybe as Promise<unknown>).catch(() => undefined);51    }52  } catch {53    // The popup may have closed. The pending approval fails closed on cleanup.54  }55}5657let inFlight = false;58let 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.62let pendingSasConfirmation: {63  id: string;64  resolve: (ok: boolean) => void;65} | null = null;6667export function isRelayAdoptionSourceInFlight(): boolean {68  return inFlight;69}7071/**72 * Occupy the non-buffering room before returning the invitation to the UI.73 * All long-term key material remains in the background service worker.74 */75export async function startRelayAdoptionSource(): Promise<{76  invitation: string;77}> {78  if (inFlight) throw new Error("adoption_source_in_progress");79  if (!isUnlocked()) throw new Error("vault_locked");80  inFlight = true;8182  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;83  try {84    const settings = await getSyncSettings();85    const relayUrl = relayInForce(settings);86    local = await getLocalPairingIdentity();87    const token = generatePairingRendezvousToken();88    // Source side has the vault — caller checked isUnlocked — so the phone89    // can read the invitation and know it should receive the vault.90    const encoded = createPairingInvitation(relayUrl, token, "populated");91    const invitation = parsePairingInvitation(encoded);92    if (!invitation) throw new Error("invitation_generation_failed");9394    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));95    activeSocket = socket;96    const readyLocal = local;97    void runSourceSession(98      socket,99      token,100      relayUrl,101      readyLocal,102    ).finally(() => {103      readyLocal.privateKey.fill(0);104      readyLocal.publicKey.fill(0);105      clearState();106    });107    return { invitation: encoded };108  } catch (error) {109    local?.privateKey.fill(0);110    local?.publicKey.fill(0);111    clearState();112    throw error;113  }114}115116export function cancelRelayAdoptionSource(): void {117  activeSocket?.close();118  clearState();119}120121/**122 * Onboarding pairing request — the empty extension shows a QR and the populated123 * 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.127 *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.131 */132export async function startRelayAdoptionReceiver(133  password: string,134): Promise<{ invitation: string }> {135  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  }139  inFlight = true;140  try {141    const settings = await getSyncSettings();142    const relayUrl = relayInForce(settings);143    const token = generatePairingRendezvousToken();144    const encoded = createPairingInvitation(relayUrl, token, "empty");145    const invitation = parsePairingInvitation(encoded);146    if (!invitation) throw new Error("invitation_generation_failed");147    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));148    activeSocket = socket;149    void runReceiverSession(150      socket,151      token,152      relayUrl,153      password,154    ).finally(clearState);155    return { invitation: encoded };156  } catch (error) {157    clearState();158    throw error;159  }160}161162/**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 */169export 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}175176async function runSourceSession(177  socket: PairingSocket,178  expectedToken: string,179  relayUrl: string,180  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,181): Promise<void> {182  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);183  let noise: NoiseNKSession | undefined;184  try {185    const hello = decodeJson<{ type?: unknown; token?: unknown }>(186      await socket.receive(),187    );188    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||189        !constantTimeEqual(hello.token, expectedToken)) {190      throw new Error("invalid_invitation");191    }192    socket.send(encodeJson({193      type: "PEACH_HELLO_ACK",194      ephemeralPublicKeyHex: bytesToHex(local.publicKey),195    }));196197    const session = await noiseNKResponder(198      local.privateKey,199      local.publicKey,200      async (bytes) => socket.send(bytes),201      () => socket.receive(),202    );203    noise = session;204    const channel: PairingTransport = {205      async send(plaintext) { socket.send(session.encrypt(plaintext)); },206      async receive() { return session.decrypt(await socket.receive()); },207    };208    const request = await receiveFrame(channel);209    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");210211    const device = await grantAdoption(channel, request, {212      sourceIdentity: local.identity,213      pairState: {214        rendezvousSecretHex: bytesToHex(215          deriveRendezvousSecret(session.handshakeHash),216        ),217        relayUrl,218      },219      async rootKeyHex() {220        // grantAdoption calls this only after local password approval and the221        // receiver's encrypted ADOPT_CONFIRM(true).222        const value = getRootKeyHex();223        if (!value) throw new Error("vault_locked");224        return value;225      },226      async vaultId() {227        const value = getRootKeyHex();228        if (!value) throw new Error("vault_locked");229        const bytes = hexToBytes(value);230        try {231          return await computeVaultId(bytes);232        } finally {233          bytes.fill(0);234        }235      },236      async license() {237        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;238      },239      onConsent: (adoptRequest) => requestConsent(240        adoptRequest,241        derivePairingSAS(session.handshakeHash),242      ),243      upsertPairedDevice,244    });245    if (!device) {246      push("ADOPTION_SOURCE_RESULT", {247        status: "error",248        error: "adoption_rejected",249      });250      return;251    }252    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });253    // The Android receiver starts its relay listener as soon as its source254    // record is durable. Try once now and once after service startup settles so255    // the newly adopted empty vault receives its first sync without another256    // user action. Both calls are best-effort and contain no user data.257    void triggerSyncIfReachable({ reason: "manual", force: true });258    setTimeout(() => {259      void triggerSyncIfReachable({ reason: "manual", force: true });260    }, 1_000);261  } catch {262    push("ADOPTION_SOURCE_RESULT", {263      status: "error",264      error: "adoption_failed",265    });266  } finally {267    clearTimeout(timeout);268    noise?.close();269    socket.close();270  }271}272273/**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 */278async 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    }));302303    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    };314315    const sas = derivePairingSAS(session.handshakeHash);316    const deviceName = await getDeviceName();317    const sasId = bytesToHex(session.handshakeHash).slice(0, 16);318319    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    });364365    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}389390/**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 */400function 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}420421/**422 * Opening Add Device in an unlocked extension is the source-side authorization.423 * Announce the SAS for comparison, then let the scanning device's explicit424 * confirmation be the single commit gate. grantAdoption still refuses to send425 * the root key until the receiver returns ADOPT_CONFIRM(true).426 */427function 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);433}434435function clearState(): void {436  activeSocket = null;437  inFlight = false;438}439440function constantTimeEqual(a: string, b: string): boolean {441  if (a.length !== b.length) return false;442  let diff = 0;443  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);444  return diff === 0;445}446447function encodeJson(value: unknown): Uint8Array {448  return new TextEncoder().encode(JSON.stringify(value));449}450451function decodeJson<T>(value: Uint8Array): T {452  return JSON.parse(new TextDecoder().decode(value)) as T;453}454455function hexToBytes(hex: string): Uint8Array {456  const out = new Uint8Array(hex.length / 2);457  for (let i = 0; i < out.length; i++) {458    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);459  }460  return out;461}