relay-adoption-source-bg.ts

16.8 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 { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";32import { getSyncSettings, relayInForce } from "./sync-settings";33import { activateLicense, getLicenseKey, isProUser } from "./license";34import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } 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      async applyLicense(license) {234        await applyTransferredLicense(license);235      },236      onConsent: (adoptRequest) => requestConsent(237        adoptRequest,238        derivePairingSAS(session.handshakeHash),239        crypto.randomUUID(),240      ),241      upsertPairedDevice,242    });243    if (!device) {244      push("ADOPTION_SOURCE_RESULT", {245        status: "error",246        error: "adoption_rejected",247      });248      return;249    }250    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });251    // The Android receiver starts its relay listener as soon as its source252    // record is durable. Try once now and once after service startup settles so253    // the newly adopted empty vault receives its first sync without another254    // user action. Both calls are best-effort and contain no user data.255    void triggerSyncIfReachable({ reason: "manual", force: true });256    setTimeout(() => {257      void triggerSyncIfReachable({ reason: "manual", force: true });258    }, 1_000);259  } catch (error) {260    push("ADOPTION_SOURCE_RESULT", {261      status: "error",262      error: error instanceof Error ? error.message : "adoption_failed",263    });264  } finally {265    clearTimeout(timeout);266    noise?.close();267    socket.close();268    pendingSourceSasConfirmation.clear();269  }270}271272/**273 * Browser receives the vault from the phone. The protocol is the mirror of274 * the source side: phone is the Noise initiator and the source of truth for275 * the SAS gate, browser is the responder that creates the vault locally.276 */277async function runReceiverSession(278  socket: PairingSocket,279  expectedToken: string,280  relayUrl: string,281  password: string,282): Promise<void> {283  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);284  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.285  const ephemeral = generateKeyPair();286  const ephemeralPrivate = ephemeral.secretKey;287  const ephemeralPublic = ephemeral.publicKey;288  let noise: NoiseNKSession | undefined;289  let createdVault = false;290  let protocolError: string | undefined;291  try {292    const hello = decodeJson<{ type?: unknown; token?: unknown }>(293      await socket.receive(),294    );295    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||296        !constantTimeEqual(hello.token, expectedToken)) {297      throw new Error("invalid_invitation");298    }299    socket.send(encodeJson({300      type: "PEACH_HELLO_ACK",301      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),302    }));303304    const session = await noiseNKResponder(305      ephemeralPrivate,306      ephemeralPublic,307      async (bytes) => socket.send(bytes),308      () => socket.receive(),309    );310    noise = session;311    const channel: PairingTransport = {312      async send(plaintext) { socket.send(session.encrypt(plaintext)); },313      async receive() { return session.decrypt(await socket.receive()); },314    };315316    const deviceName = await getDeviceName();317318    const device = await requestAdoption(channel, {319      deviceName,320      generateDeviceId: () => crypto.randomUUID(),321      // Auto-accept the SAS: both sides derive it from the same Noise322      // handshake hash, so the codes match by construction. Requiring the user323      // to confirm on both sides creates a race where the phone can time out324      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The325      // phone's onConsent is the single user gate; this side just proceeds.326      // Still show the locally derived code for visual verification.327      async confirmSas() {328        push("ADOPTION_SOURCE_SAS", {329          sas: derivePairingSAS(session.handshakeHash),330          displayOnly: true,331        });332        return true;333      },334      async createVaultFromRootKey(rootKeyHex) {335        // Direct call, not chrome.runtime.sendMessage — a service worker336        // calling sendMessage does not reach its own listener (returns337        // undefined), and the receiver already lives in the same SW context338        // as the vault module. Round-tripping through the message bus was339        // the root cause of "create_vault_no_response".340        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);341        createdVault = true;342        return { vaultId };343      },344      async applyLicense(license) {345        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self346        // issue as createVaultFromRootKey. SW can't reach its own listener.347        await applyTransferredLicense(license);348      },349      async license() {350        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;351      },352      async deriveLocalIdentity() {353        // Direct call for the same reason as applyLicense above.354        const masterKeyHex = getRootKeyHex();355        if (!masterKeyHex) throw new Error("identity_unavailable");356        const masterKey = hexToBytes(masterKeyHex);357        try {358          return await buildLocalIdentity(masterKey);359        } finally {360          masterKey.fill(0);361        }362      },363      async upsertPairedDevice(paired) {364        // Direct call for the same reason as applyLicense above.365        await upsertPairedDevice(paired);366      },367      pairState: {368        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),369        relayUrl,370      },371      onError(error) {372        protocolError = error;373      },374    });375376    if (!device) {377      if (createdVault) {378        await rollbackAdoptedVault();379        createdVault = false;380      }381      push("ADOPTION_SOURCE_RESULT", {382        status: "error",383        error: protocolError ?? "adoption_rejected",384      });385      return;386    }387    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });388    createdVault = false;389    void triggerSyncIfReachable({ reason: "manual", force: true });390  } catch (error) {391    if (createdVault) {392      try {393        await rollbackAdoptedVault();394        createdVault = false;395      } catch (rollbackError) {396        console.error("[Peach] rollback failed:", rollbackError);397      }398    }399    console.error("[Peach] receiver session failed:", error);400    push("ADOPTION_SOURCE_RESULT", {401      status: "error",402      error: error instanceof Error ? error.message : "adoption_failed",403    });404  } finally {405    clearTimeout(timeout);406    noise?.close();407    socket.close();408    ephemeralPrivate.fill(0);409    ephemeralPublic.fill(0);410  }411}412413/**414 * A transferred entitlement is committed only when activation succeeds and415 * the stored value can immediately be re-read as Pro. activateLicense returns416 * a normal Free result for invalid input, so callers must not ignore it.417 */418async function applyTransferredLicense(license: string): Promise<void> {419  const activated = await activateLicense(license);420  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {421    throw new Error("license_transfer_failed");422  }423}424425/**426 * Push the SAS to the popup, then block until the popup responds via the427 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on428 * timeout or cancel so the adoption aborts before any vault state changes.429 *430 * The pending deferred MUST be installed before the push: a popup-side431 * auto-approver (test mocks, future automation) can synchronously resolve432 * the deferred the moment it sees the SAS push, and a missing slot would433 * drop that approval on the floor.434 */435function waitForSourceSasConfirmation(436  id: string,437  request: AdoptRequest,438  sas: string,439): Promise<boolean> {440  return new Promise<boolean>((resolve) => {441    const timer = setTimeout(() => {442      pendingSourceSasConfirmation.delete(id);443      resolve(false);444    }, SOURCE_WINDOW_MS);445    pendingSourceSasConfirmation.set(id, {446      resolve: (ok) => {447        clearTimeout(timer);448        resolve(ok);449      },450    });451    push("ADOPTION_SOURCE_SAS", {452      id,453      sas,454      receiverDeviceName: request.device_name,455    });456  });457}458459/**460 * Opening Add Device in an unlocked extension is the source-side authorization.461 * Announce the SAS for comparison, then let the scanning device's explicit462 * confirmation be the single commit gate. grantAdoption still refuses to send463 * the root key until the receiver returns ADOPT_CONFIRM(true).464 */465function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {466  return waitForSourceSasConfirmation(id, request, sas);467}468469function clearState(): void {470  activeSocket = null;471  inFlight = false;472  pendingSourceSasConfirmation.clear();473}474475function constantTimeEqual(a: string, b: string): boolean {476  if (a.length !== b.length) return false;477  let diff = 0;478  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);479  return diff === 0;480}481482function encodeJson(value: unknown): Uint8Array {483  return new TextEncoder().encode(JSON.stringify(value));484}485486function decodeJson<T>(value: Uint8Array): T {487  return JSON.parse(new TextDecoder().decode(value)) as T;488}489490function hexToBytes(hex: string): Uint8Array {491  const out = new Uint8Array(hex.length / 2);492  for (let i = 0; i < out.length; i++) {493    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);494  }495  return out;496}