relay-adoption-source-bg.ts

15.7 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;4041function push(type: string, payload: unknown): void {42  try {43    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;44    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {45      (maybe as Promise<unknown>).catch(() => undefined);46    }47  } catch {48    // The popup may have closed. The pending approval fails closed on cleanup.49  }50}5152let inFlight = false;53let activeSocket: PairingSocket | null = null;54// Outstanding source-side SAS confirmations, keyed by id. A map (vs the55// receiver's single slot) makes a stale decision from a previous source56// session impossible to land on the current one even if both run on the57// same background worker.58const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();5960export function isRelayAdoptionSourceInFlight(): boolean {61  return inFlight;62}6364/**65 * Occupy the non-buffering room before returning the invitation to the UI.66 * All long-term key material remains in the background service worker.67 */68export async function startRelayAdoptionSource(): Promise<{69  invitation: string;70}> {71  if (inFlight) throw new Error("adoption_source_in_progress");72  if (!isUnlocked()) throw new Error("vault_locked");73  inFlight = true;7475  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;76  try {77    const settings = await getSyncSettings();78    const relayUrl = relayInForce(settings);79    local = await getLocalPairingIdentity();80    const token = generatePairingRendezvousToken();81    // Source side has the vault — caller checked isUnlocked — so the phone82    // can read the invitation and know it should receive the vault.83    const encoded = createPairingInvitation(relayUrl, token, "populated");84    const invitation = parsePairingInvitation(encoded);85    if (!invitation) throw new Error("invitation_generation_failed");8687    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));88    activeSocket = socket;89    const readyLocal = local;90    void runSourceSession(91      socket,92      token,93      relayUrl,94      readyLocal,95    ).finally(() => {96      readyLocal.privateKey.fill(0);97      readyLocal.publicKey.fill(0);98      clearState();99    });100    return { invitation: encoded };101  } catch (error) {102    local?.privateKey.fill(0);103    local?.publicKey.fill(0);104    clearState();105    throw error;106  }107}108109export function cancelRelayAdoptionSource(): void {110  activeSocket?.close();111  clearState();112}113114/**115 * Onboarding pairing request — the empty extension shows a QR and the populated116 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the117 * phone reads to switch into source mode and `grantAdoption` over the118 * established Noise channel. The receiver's `runReceiverSession` accepts the119 * root key and creates a local vault wrapped with the popup-supplied password.120 *121 * `password` is the local-only password the user picked on the "Set a password122 * for this device" screen; it wraps the adopted root key at rest and has no123 * relationship to the source device's password.124 */125export async function startRelayAdoptionReceiver(126  password: string,127): Promise<{ invitation: string }> {128  if (inFlight) throw new Error("adoption_receiver_in_progress");129  if (typeof password !== "string" || password.length === 0) {130    throw new Error("ADOPT_INVALID_PASSWORD");131  }132  inFlight = true;133  try {134    const settings = await getSyncSettings();135    const relayUrl = relayInForce(settings);136    const token = generatePairingRendezvousToken();137    const encoded = createPairingInvitation(relayUrl, token, "empty");138    const invitation = parsePairingInvitation(encoded);139    if (!invitation) throw new Error("invitation_generation_failed");140    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));141    activeSocket = socket;142    void runReceiverSession(143      socket,144      token,145      relayUrl,146      password,147    ).finally(clearState);148    return { invitation: encoded };149  } catch (error) {150    clearState();151    throw error;152  }153}154155/**156 * Resolves the source-side SAS confirmation by id. Called by the message157 * handler when the popup taps Confirm/Cancel on the source's SAS screen.158 * Returns true if the id matched an in-flight confirmation. Unknown ids159 * (already resolved, timed out, or never existed) are a no-op so a late160 * popup click cannot crash anything.161 */162export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {163  const pending = pendingSourceSasConfirmation.get(id);164  if (!pending) return false;165  pendingSourceSasConfirmation.delete(id);166  pending.resolve(ok);167  return true;168}169170async function runSourceSession(171  socket: PairingSocket,172  expectedToken: string,173  relayUrl: string,174  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,175): Promise<void> {176  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);177  let noise: NoiseNKSession | undefined;178  try {179    const hello = decodeJson<{ type?: unknown; token?: unknown }>(180      await socket.receive(),181    );182    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||183        !constantTimeEqual(hello.token, expectedToken)) {184      throw new Error("invalid_invitation");185    }186    socket.send(encodeJson({187      type: "PEACH_HELLO_ACK",188      ephemeralPublicKeyHex: bytesToHex(local.publicKey),189    }));190191    const session = await noiseNKResponder(192      local.privateKey,193      local.publicKey,194      async (bytes) => socket.send(bytes),195      () => socket.receive(),196    );197    noise = session;198    const channel: PairingTransport = {199      async send(plaintext) { socket.send(session.encrypt(plaintext)); },200      async receive() { return session.decrypt(await socket.receive()); },201    };202    const request = await receiveFrame(channel);203    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");204205    const device = await grantAdoption(channel, request, {206      sourceIdentity: local.identity,207      pairState: {208        rendezvousSecretHex: bytesToHex(209          deriveRendezvousSecret(session.handshakeHash),210        ),211        relayUrl,212      },213      async rootKeyHex() {214        // grantAdoption calls this only after local password approval and the215        // receiver's encrypted ADOPT_CONFIRM(true).216        const value = getRootKeyHex();217        if (!value) throw new Error("vault_locked");218        return value;219      },220      async vaultId() {221        const value = getRootKeyHex();222        if (!value) throw new Error("vault_locked");223        const bytes = hexToBytes(value);224        try {225          return await computeVaultId(bytes);226        } finally {227          bytes.fill(0);228        }229      },230      async license() {231        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;232      },233      onConsent: (adoptRequest) => requestConsent(234        adoptRequest,235        derivePairingSAS(session.handshakeHash),236        crypto.randomUUID(),237      ),238      upsertPairedDevice,239    });240    if (!device) {241      push("ADOPTION_SOURCE_RESULT", {242        status: "error",243        error: "adoption_rejected",244      });245      return;246    }247    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });248    // The Android receiver starts its relay listener as soon as its source249    // record is durable. Try once now and once after service startup settles so250    // the newly adopted empty vault receives its first sync without another251    // user action. Both calls are best-effort and contain no user data.252    void triggerSyncIfReachable({ reason: "manual", force: true });253    setTimeout(() => {254      void triggerSyncIfReachable({ reason: "manual", force: true });255    }, 1_000);256  } catch {257    push("ADOPTION_SOURCE_RESULT", {258      status: "error",259      error: "adoption_failed",260    });261  } finally {262    clearTimeout(timeout);263    noise?.close();264    socket.close();265    pendingSourceSasConfirmation.clear();266  }267}268269/**270 * Browser receives the vault from the phone. The protocol is the mirror of271 * the source side: phone is the Noise initiator and the source of truth for272 * the SAS gate, browser is the responder that creates the vault locally.273 */274async function runReceiverSession(275  socket: PairingSocket,276  expectedToken: string,277  relayUrl: string,278  password: string,279): Promise<void> {280  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);281  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.282  const ephemeral = generateKeyPair();283  const ephemeralPrivate = ephemeral.secretKey;284  const ephemeralPublic = ephemeral.publicKey;285  let noise: NoiseNKSession | undefined;286  try {287    const hello = decodeJson<{ type?: unknown; token?: unknown }>(288      await socket.receive(),289    );290    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||291        !constantTimeEqual(hello.token, expectedToken)) {292      throw new Error("invalid_invitation");293    }294    socket.send(encodeJson({295      type: "PEACH_HELLO_ACK",296      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),297    }));298299    const session = await noiseNKResponder(300      ephemeralPrivate,301      ephemeralPublic,302      async (bytes) => socket.send(bytes),303      () => socket.receive(),304    );305    noise = session;306    const channel: PairingTransport = {307      async send(plaintext) { socket.send(session.encrypt(plaintext)); },308      async receive() { return session.decrypt(await socket.receive()); },309    };310311    const deviceName = await getDeviceName();312313    const device = await requestAdoption(channel, {314      deviceName,315      generateDeviceId: () => crypto.randomUUID(),316      // Auto-accept the SAS: both sides derive it from the same Noise317      // handshake hash, so the codes match by construction. Requiring the user318      // to confirm on both sides creates a race where the phone can time out319      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The320      // phone's onConsent is the single user gate; this side just proceeds.321      async confirmSas() {322        return true;323      },324      async createVaultFromRootKey(rootKeyHex) {325        console.log("[Peach diag] sendMessage CREATE_VAULT_FROM_ROOT_KEY starting");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 } | undefined;330        console.log("[Peach diag] sendMessage CREATE_VAULT_FROM_ROOT_KEY returned:", response);331        if (!response) {332          throw new Error("create_vault_no_response");333        }334        if (!response.ok) {335          throw new Error(response.error ?? "create_vault_failed");336        }337        return { vaultId: response.data?.vaultId ?? "" };338      },339      async applyLicense(license) {340        const response = (await chrome.runtime.sendMessage({341          type: "ACTIVATE_LICENSE",342          payload: { key: license },343        })) as { ok: boolean; error?: string };344        if (!response.ok) throw new Error(response.error ?? "license_activation_failed");345      },346      async deriveLocalIdentity() {347        const response = (await chrome.runtime.sendMessage({348          type: "GET_LOCAL_IDENTITY",349        })) as { ok: boolean; data?: Record<string, unknown>; error?: string };350        if (!response.ok) throw new Error(response.error ?? "identity_unavailable");351        return response.data as never;352      },353      async upsertPairedDevice(paired) {354        const response = (await chrome.runtime.sendMessage({355          type: "COMPLETE_PAIRING",356          payload: { paired },357        })) as { ok: boolean; error?: string };358        if (!response.ok) throw new Error(response.error ?? "complete_pairing_failed");359      },360      pairState: {361        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),362        relayUrl,363      },364      onError(error) {365        push("ADOPTION_SOURCE_RESULT", { status: "error", error });366      },367    });368369    if (!device) {370      push("ADOPTION_SOURCE_RESULT", {371        status: "error",372        error: "adoption_rejected",373      });374      return;375    }376    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });377    void triggerSyncIfReachable({ reason: "manual", force: true });378  } catch (error) {379    console.error("[Peach] receiver session failed:", error);380    push("ADOPTION_SOURCE_RESULT", {381      status: "error",382      error: error instanceof Error ? error.message : "adoption_failed",383    });384  } finally {385    clearTimeout(timeout);386    noise?.close();387    socket.close();388    ephemeralPrivate.fill(0);389    ephemeralPublic.fill(0);390  }391}392393/**394 * Push the SAS to the popup, then block until the popup responds via the395 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on396 * timeout or cancel so the adoption aborts before any vault state changes.397 *398 * The pending deferred MUST be installed before the push: a popup-side399 * auto-approver (test mocks, future automation) can synchronously resolve400 * the deferred the moment it sees the SAS push, and a missing slot would401 * drop that approval on the floor.402 */403function waitForSourceSasConfirmation(404  id: string,405  request: AdoptRequest,406  sas: string,407): Promise<boolean> {408  return new Promise<boolean>((resolve) => {409    const timer = setTimeout(() => {410      pendingSourceSasConfirmation.delete(id);411      resolve(false);412    }, SOURCE_WINDOW_MS);413    pendingSourceSasConfirmation.set(id, {414      resolve: (ok) => {415        clearTimeout(timer);416        resolve(ok);417      },418    });419    push("ADOPTION_SOURCE_SAS", {420      id,421      sas,422      receiverDeviceName: request.device_name,423    });424  });425}426427/**428 * Opening Add Device in an unlocked extension is the source-side authorization.429 * Announce the SAS for comparison, then let the scanning device's explicit430 * confirmation be the single commit gate. grantAdoption still refuses to send431 * the root key until the receiver returns ADOPT_CONFIRM(true).432 */433function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {434  return waitForSourceSasConfirmation(id, request, sas);435}436437function clearState(): void {438  activeSocket = null;439  inFlight = false;440  pendingSourceSasConfirmation.clear();441}442443function constantTimeEqual(a: string, b: string): boolean {444  if (a.length !== b.length) return false;445  let diff = 0;446  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);447  return diff === 0;448}449450function encodeJson(value: unknown): Uint8Array {451  return new TextEncoder().encode(JSON.stringify(value));452}453454function decodeJson<T>(value: Uint8Array): T {455  return JSON.parse(new TextDecoder().decode(value)) as T;456}457458function hexToBytes(hex: string): Uint8Array {459  const out = new Uint8Array(hex.length / 2);460  for (let i = 0; i < out.length; i++) {461    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);462  }463  return out;464}