relay-adoption-source-bg.ts

17.4 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;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.70const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();7172export function isRelayAdoptionSourceInFlight(): boolean {73  return inFlight;74}7576/**77 * Occupy the non-buffering room before returning the invitation to the UI.78 * All long-term key material remains in the background service worker.79 */80export async function startRelayAdoptionSource(): Promise<{81  invitation: string;82}> {83  if (inFlight) throw new Error("adoption_source_in_progress");84  if (!isUnlocked()) throw new Error("vault_locked");85  inFlight = true;8687  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;88  try {89    const settings = await getSyncSettings();90    const relayUrl = relayInForce(settings);91    local = await getLocalPairingIdentity();92    const token = generatePairingRendezvousToken();93    // Source side has the vault — caller checked isUnlocked — so the phone94    // can read the invitation and know it should receive the vault.95    const encoded = createPairingInvitation(relayUrl, token, "populated");96    const invitation = parsePairingInvitation(encoded);97    if (!invitation) throw new Error("invitation_generation_failed");9899    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));100    activeSocket = socket;101    const readyLocal = local;102    void runSourceSession(103      socket,104      token,105      relayUrl,106      readyLocal,107    ).finally(() => {108      readyLocal.privateKey.fill(0);109      readyLocal.publicKey.fill(0);110      clearState();111    });112    return { invitation: encoded };113  } catch (error) {114    local?.privateKey.fill(0);115    local?.publicKey.fill(0);116    clearState();117    throw error;118  }119}120121export function cancelRelayAdoptionSource(): void {122  activeSocket?.close();123  clearState();124}125126/**127 * Onboarding pairing request — the empty extension shows a QR and the populated128 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the129 * phone reads to switch into source mode and `grantAdoption` over the130 * established Noise channel. The receiver's `runReceiverSession` accepts the131 * root key and creates a local vault wrapped with the popup-supplied password.132 *133 * `password` is the local-only password the user picked on the "Set a password134 * for this device" screen; it wraps the adopted root key at rest and has no135 * relationship to the source device's password.136 */137export async function startRelayAdoptionReceiver(138  password: string,139): Promise<{ invitation: string }> {140  if (inFlight) throw new Error("adoption_receiver_in_progress");141  if (typeof password !== "string" || password.length === 0) {142    throw new Error("ADOPT_INVALID_PASSWORD");143  }144  inFlight = true;145  try {146    const settings = await getSyncSettings();147    const relayUrl = relayInForce(settings);148    const token = generatePairingRendezvousToken();149    const encoded = createPairingInvitation(relayUrl, token, "empty");150    const invitation = parsePairingInvitation(encoded);151    if (!invitation) throw new Error("invitation_generation_failed");152    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));153    activeSocket = socket;154    void runReceiverSession(155      socket,156      token,157      relayUrl,158      password,159    ).finally(clearState);160    return { invitation: encoded };161  } catch (error) {162    clearState();163    throw error;164  }165}166167/**168 * Resolves the pending receiver SAS confirmation, if any. Called by the169 * message handler when the popup taps Confirm/Cancel on the SAS screen.170 * Returns true if the message was claimed by an in-flight session — that171 * lets the popup tell the difference between "got my answer" and "the172 * session timed out before I clicked".173 */174export function resolveReceiverSasConfirmation(id: string, ok: boolean): boolean {175  if (!pendingSasConfirmation || pendingSasConfirmation.id !== id) return false;176  pendingSasConfirmation.resolve(ok);177  pendingSasConfirmation = null;178  return true;179}180181/**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 */188export 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}195196async function runSourceSession(197  socket: PairingSocket,198  expectedToken: string,199  relayUrl: string,200  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,201): Promise<void> {202  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);203  let noise: NoiseNKSession | undefined;204  try {205    const hello = decodeJson<{ type?: unknown; token?: unknown }>(206      await socket.receive(),207    );208    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||209        !constantTimeEqual(hello.token, expectedToken)) {210      throw new Error("invalid_invitation");211    }212    socket.send(encodeJson({213      type: "PEACH_HELLO_ACK",214      ephemeralPublicKeyHex: bytesToHex(local.publicKey),215    }));216217    const session = await noiseNKResponder(218      local.privateKey,219      local.publicKey,220      async (bytes) => socket.send(bytes),221      () => socket.receive(),222    );223    noise = session;224    const channel: PairingTransport = {225      async send(plaintext) { socket.send(session.encrypt(plaintext)); },226      async receive() { return session.decrypt(await socket.receive()); },227    };228    const request = await receiveFrame(channel);229    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");230231    const device = await grantAdoption(channel, request, {232      sourceIdentity: local.identity,233      pairState: {234        rendezvousSecretHex: bytesToHex(235          deriveRendezvousSecret(session.handshakeHash),236        ),237        relayUrl,238      },239      async rootKeyHex() {240        // grantAdoption calls this only after local password approval and the241        // receiver's encrypted ADOPT_CONFIRM(true).242        const value = getRootKeyHex();243        if (!value) throw new Error("vault_locked");244        return value;245      },246      async vaultId() {247        const value = getRootKeyHex();248        if (!value) throw new Error("vault_locked");249        const bytes = hexToBytes(value);250        try {251          return await computeVaultId(bytes);252        } finally {253          bytes.fill(0);254        }255      },256      async license() {257        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;258      },259      onConsent: (adoptRequest) => requestConsent(260        adoptRequest,261        derivePairingSAS(session.handshakeHash),262        crypto.randomUUID(),263      ),264      upsertPairedDevice,265    });266    if (!device) {267      push("ADOPTION_SOURCE_RESULT", {268        status: "error",269        error: "adoption_rejected",270      });271      return;272    }273    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });274    // The Android receiver starts its relay listener as soon as its source275    // record is durable. Try once now and once after service startup settles so276    // the newly adopted empty vault receives its first sync without another277    // user action. Both calls are best-effort and contain no user data.278    void triggerSyncIfReachable({ reason: "manual", force: true });279    setTimeout(() => {280      void triggerSyncIfReachable({ reason: "manual", force: true });281    }, 1_000);282  } catch {283    push("ADOPTION_SOURCE_RESULT", {284      status: "error",285      error: "adoption_failed",286    });287  } finally {288    clearTimeout(timeout);289    noise?.close();290    socket.close();291    pendingSourceSasConfirmation.clear();292  }293}294295/**296 * Browser receives the vault from the phone. The protocol is the mirror of297 * the source side: phone is the Noise initiator and the source of truth for298 * the SAS gate, browser is the responder that creates the vault locally.299 */300async function runReceiverSession(301  socket: PairingSocket,302  expectedToken: string,303  relayUrl: string,304  password: string,305): Promise<void> {306  const timeout = setTimeout(() => socket.close(), RECEIVER_SAS_TIMEOUT_MS);307  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.308  const ephemeral = generateKeyPair();309  const ephemeralPrivate = ephemeral.secretKey;310  const ephemeralPublic = ephemeral.publicKey;311  let noise: NoiseNKSession | undefined;312  try {313    const hello = decodeJson<{ type?: unknown; token?: unknown }>(314      await socket.receive(),315    );316    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||317        !constantTimeEqual(hello.token, expectedToken)) {318      throw new Error("invalid_invitation");319    }320    socket.send(encodeJson({321      type: "PEACH_HELLO_ACK",322      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),323    }));324325    const session = await noiseNKResponder(326      ephemeralPrivate,327      ephemeralPublic,328      async (bytes) => socket.send(bytes),329      () => socket.receive(),330    );331    noise = session;332    const channel: PairingTransport = {333      async send(plaintext) { socket.send(session.encrypt(plaintext)); },334      async receive() { return session.decrypt(await socket.receive()); },335    };336337    const sas = derivePairingSAS(session.handshakeHash);338    const deviceName = await getDeviceName();339    const sasId = bytesToHex(session.handshakeHash).slice(0, 16);340341    const device = await requestAdoption(channel, {342      deviceName,343      generateDeviceId: () => crypto.randomUUID(),344      async confirmSas() {345        return await waitForSasConfirmation(sasId, deviceName, sas);346      },347      async createVaultFromRootKey(rootKeyHex) {348        const response = (await chrome.runtime.sendMessage({349          type: "CREATE_VAULT_FROM_ROOT_KEY",350          payload: { password, rootKeyHex },351        })) as { ok: boolean; data?: { vaultId?: string }; error?: string };352        if (!response.ok) {353          throw new Error(response.error ?? "create_vault_failed");354        }355        return { vaultId: response.data?.vaultId ?? "" };356      },357      async applyLicense(license) {358        const response = (await chrome.runtime.sendMessage({359          type: "ACTIVATE_LICENSE",360          payload: { key: license },361        })) as { ok: boolean; error?: string };362        if (!response.ok) throw new Error(response.error ?? "license_activation_failed");363      },364      async deriveLocalIdentity() {365        const response = (await chrome.runtime.sendMessage({366          type: "GET_LOCAL_IDENTITY",367        })) as { ok: boolean; data?: Record<string, unknown>; error?: string };368        if (!response.ok) throw new Error(response.error ?? "identity_unavailable");369        return response.data as never;370      },371      async upsertPairedDevice(paired) {372        const response = (await chrome.runtime.sendMessage({373          type: "COMPLETE_PAIRING",374          payload: { paired },375        })) as { ok: boolean; error?: string };376        if (!response.ok) throw new Error(response.error ?? "complete_pairing_failed");377      },378      pairState: {379        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),380        relayUrl,381      },382      onError(error) {383        push("ADOPTION_SOURCE_RESULT", { status: "error", error });384      },385    });386387    if (!device) {388      push("ADOPTION_SOURCE_RESULT", {389        status: "error",390        error: "adoption_rejected",391      });392      return;393    }394    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });395    void triggerSyncIfReachable({ reason: "manual", force: true });396  } catch {397    push("ADOPTION_SOURCE_RESULT", {398      status: "error",399      error: "adoption_failed",400    });401  } finally {402    clearTimeout(timeout);403    noise?.close();404    socket.close();405    ephemeralPrivate.fill(0);406    ephemeralPublic.fill(0);407    // A late popup response after the session ended should not crash anything.408    if (pendingSasConfirmation) pendingSasConfirmation = null;409  }410}411412/**413 * Push the SAS to the popup, then block until the popup responds via the414 * `RECEIVER_SAS_DECISION` message or the timeout fires. Returns false on415 * timeout or cancel so the adoption aborts before any vault state changes.416 *417 * The pending deferred MUST be installed before the push: a popup-side418 * auto-approver (test mocks, future automation) can synchronously resolve419 * the deferred the moment it sees the SAS push, and a missing slot would420 * drop that approval on the floor.421 */422function waitForSasConfirmation(423  id: string,424  receiverDeviceName: string,425  sas: string,426): Promise<boolean> {427  return new Promise<boolean>((resolve) => {428    const timer = setTimeout(() => {429      if (pendingSasConfirmation?.id === id) pendingSasConfirmation = null;430      resolve(false);431    }, RECEIVER_SAS_TIMEOUT_MS);432    pendingSasConfirmation = {433      id,434      resolve: (ok) => {435        clearTimeout(timer);436        resolve(ok);437      },438    };439    push("ADOPTION_SOURCE_SAS", { id, sas, receiverDeviceName });440  });441}442443/**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 */453function 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}476477/**478 * Opening Add Device in an unlocked extension is the source-side authorization.479 * Announce the SAS for comparison, then let the scanning device's explicit480 * confirmation be the single commit gate. grantAdoption still refuses to send481 * the root key until the receiver returns ADOPT_CONFIRM(true).482 */483function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {484  return waitForSourceSasConfirmation(id, request, sas);485}486487function clearState(): void {488  activeSocket = null;489  inFlight = false;490  pendingSourceSasConfirmation.clear();491}492493function constantTimeEqual(a: string, b: string): boolean {494  if (a.length !== b.length) return false;495  let diff = 0;496  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);497  return diff === 0;498}499500function encodeJson(value: unknown): Uint8Array {501  return new TextEncoder().encode(JSON.stringify(value));502}503504function decodeJson<T>(value: Uint8Array): T {505  return JSON.parse(new TextDecoder().decode(value)) as T;506}507508function hexToBytes(hex: string): Uint8Array {509  const out = new Uint8Array(hex.length / 2);510  for (let i = 0; i < out.length; i++) {511    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);512  }513  return out;514}