relay-adoption-bg.ts

24.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  isAdoptDeviceManifestFrame,18  isAdoptRequest,19  isAdoptSnapshotApplied,20  receiveFrame,21  receiveVaultSnapshot,22  requestAdoption,23  sendFrame,24  sendVaultSnapshot,25  type AdoptRequest,26} from "../shared/vault-adopt";27import {28  derivePairingSAS,29  type PairingTransport,30} from "../shared/pairing-handshake";31import type { PairedDevice } from "../shared/sync-types";32import {33  bytesToHex,34  deriveRendezvousSecret,35  generatePairingRendezvousToken,36} from "../shared/rendezvous";37import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";38import { getSyncSettings, relayInForce } from "./sync-settings";39import { activateLicense, getLicenseKey, isProUser } from "./license";40import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";41import { computeVaultId } from "../shared/sync-identity";42import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";43import { storageRemove, storageSet } from "../shared/storage";44import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";45import { isValidPairSecret, setPairSecret } from "./pair-secrets";46import { createSyncVaultAdapter } from "./sync-vault-adapter";47import { triggerSyncIfReachable } from "./sync-trigger";48import { schedulePostAdoptionSync } from "./sync-alarm";4950const SOURCE_WINDOW_MS = 5 * 60 * 1000;5152function push(type: string, payload: unknown): void {53  try {54    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;55    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {56      (maybe as Promise<unknown>).catch(() => undefined);57    }58  } catch {59    // The popup may have closed. The pending approval fails closed on cleanup.60  }61}6263/** Race a promise against a wall-clock deadline; rejects with "timeout". */64function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {65  return new Promise<T>((resolve, reject) => {66    const timer = setTimeout(() => reject(new Error("timeout")), ms);67    promise.then(68      (value) => {69        clearTimeout(timer);70        resolve(value);71      },72      (error) => {73        clearTimeout(timer);74        reject(error);75      },76    );77  });78}7980let inFlight = false;81let activeSocket: PairingSocket | null = null;82// Outstanding source-side SAS confirmations, keyed by id. A map (vs the83// receiver's single slot) makes a stale decision from a previous source84// session impossible to land on the current one even if both run on the85// same background worker.86const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();87const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();8889export function isRelayAdoptionSourceInFlight(): boolean {90  return inFlight;91}9293/**94 * Occupy the non-buffering room before returning the invitation to the UI.95 * All long-term key material remains in the background service worker.96 */97export async function startRelayAdoptionSource(): Promise<{98  invitation: string;99}> {100  if (inFlight) throw new Error("adoption_source_in_progress");101  if (!isUnlocked()) throw new Error("vault_locked");102  inFlight = true;103104  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;105  try {106    const settings = await getSyncSettings();107    const relayUrl = relayInForce(settings);108    local = await getLocalPairingIdentity();109    const token = generatePairingRendezvousToken();110    // Source side has the vault — caller checked isUnlocked — so the phone111    // can read the invitation and know it should receive the vault.112    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));113    const invitation = parsePairingInvitation(encoded);114    if (!invitation) throw new Error("invitation_generation_failed");115116    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));117    activeSocket = socket;118    const readyLocal = local;119    void runSourceSession(120      socket,121      token,122      relayUrl,123      readyLocal,124    ).finally(() => {125      readyLocal.privateKey.fill(0);126      readyLocal.publicKey.fill(0);127      clearState();128    });129    return { invitation: encoded };130  } catch (error) {131    local?.privateKey.fill(0);132    local?.publicKey.fill(0);133    clearState();134    throw error;135  }136}137138export function cancelRelayAdoptionSource(): void {139  activeSocket?.close();140  clearState();141}142143/**144 * Onboarding pairing request — the empty extension shows a QR and the populated145 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the146 * phone reads to switch into source mode and `grantAdoption` over the147 * established Noise channel. The receiver's `runReceiverSession` accepts the148 * root key and creates a local vault wrapped with the popup-supplied password.149 *150 * `password` is the local-only password the user picked on the "Set a password151 * for this device" screen; it wraps the adopted root key at rest and has no152 * relationship to the source device's password.153 */154export async function startRelayAdoptionReceiver(155  password: string,156): Promise<{ invitation: string }> {157  if (inFlight) throw new Error("adoption_receiver_in_progress");158  if (typeof password !== "string" || password.length === 0) {159    throw new Error("ADOPT_INVALID_PASSWORD");160  }161  inFlight = true;162  try {163    const settings = await getSyncSettings();164    const relayUrl = relayInForce(settings);165    const token = generatePairingRendezvousToken();166    const ephemeral = generateKeyPair();167    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));168    const invitation = parsePairingInvitation(encoded);169    if (!invitation) throw new Error("invitation_generation_failed");170    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));171    activeSocket = socket;172    void runReceiverSession(173      socket,174      token,175      relayUrl,176      password,177      ephemeral,178    ).finally(() => {179      ephemeral.secretKey.fill(0);180      ephemeral.publicKey.fill(0);181      clearState();182    });183    return { invitation: encoded };184  } catch (error) {185    clearState();186    throw error;187  }188}189190/**191 * Resolves the source-side SAS confirmation by id. Called by the message192 * handler when the popup taps Confirm/Cancel on the source's SAS screen.193 * Returns true if the id matched an in-flight confirmation. Unknown ids194 * (already resolved, timed out, or never existed) are a no-op so a late195 * popup click cannot crash anything.196 */197export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {198  const pending = pendingSourceSasConfirmation.get(id);199  if (!pending) return false;200  pendingSourceSasConfirmation.delete(id);201  pending.resolve(ok);202  return true;203}204205export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {206  const pending = pendingReceiverSasConfirmation.get(id);207  if (!pending) return false;208  pendingReceiverSasConfirmation.delete(id);209  pending.resolve(ok && constantTimeEqual(pending.sas, sas));210  return true;211}212213async function runSourceSession(214  socket: PairingSocket,215  expectedToken: string,216  relayUrl: string,217  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,218): Promise<void> {219  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);220  let noise: NoiseNKSession | undefined;221  try {222    const hello = decodeJson<{ type?: unknown; token?: unknown }>(223      await socket.receive(),224    );225    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||226        !constantTimeEqual(hello.token, expectedToken)) {227      throw new Error("invalid_invitation");228    }229    socket.send(encodeJson({230      type: "PEACH_HELLO_ACK",231      ephemeralPublicKeyHex: bytesToHex(local.publicKey),232    }));233234    const session = await noiseNKResponder(235      local.privateKey,236      local.publicKey,237      async (bytes) => socket.send(bytes),238      () => socket.receive(),239    );240    noise = session;241    const channel: PairingTransport = {242      async send(plaintext) { socket.send(session.encrypt(plaintext)); },243      async receive() { return session.decrypt(await socket.receive()); },244    };245    const request = await receiveFrame(channel);246    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");247248    const device = await grantAdoption(channel, request, {249      sourceIdentity: local.identity,250      pairState: {251        rendezvousSecretHex: bytesToHex(252          deriveRendezvousSecret(session.handshakeHash),253        ),254        relayUrl,255      },256      async rootKeyHex() {257        // grantAdoption calls this only after local password approval and the258        // receiver's encrypted ADOPT_CONFIRM(true).259        const value = getRootKeyHex();260        if (!value) throw new Error("vault_locked");261        return value;262      },263      async vaultId() {264        const value = getRootKeyHex();265        if (!value) throw new Error("vault_locked");266        const bytes = hexToBytes(value);267        try {268          return await computeVaultId(bytes);269        } finally {270          bytes.fill(0);271        }272      },273      async license() {274        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;275      },276      async applyLicense(license) {277        await applyTransferredLicense(license);278      },279      onConsent: (adoptRequest) => requestConsent(280        adoptRequest,281        derivePairingSAS(session.handshakeHash),282        crypto.randomUUID(),283      ),284      upsertPairedDevice: storeAdoptedDevice,285    });286    if (!device) {287      push("ADOPTION_SOURCE_RESULT", {288        status: "error",289        error: "adoption_rejected",290      });291      return;292    }293    // If the receiver advertised inline-snapshot support, send the vault now294    // over the same authenticated channel. Best-effort: any failure leaves the295    // receiver to fall back to the sync pipeline triggered below.296    if (request.snapshot_capable === true) {297      try {298        const adapter = createSyncVaultAdapter();299        const entryManifest = await adapter.buildEntryManifest();300        const entryIds = Object.keys(entryManifest);301        const entries =302          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};303        const tombstones = await adapter.buildTombstoneManifest();304        let duressSlot: string | null = null;305        try {306          duressSlot = await exportDuressSlotForAdoption();307        } catch {308          // Duress omission must not fail the whole snapshot.309        }310        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);311312        const applied = await withTimeout(receiveFrame(channel), 15_000);313        if (isAdoptSnapshotApplied(applied)) {314          console.info(315            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,316          );317        }318319        if (adapter.buildOutgoingDeviceManifest) {320          const manifest = await adapter.buildOutgoingDeviceManifest();321          if (manifest) {322            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });323            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);324          }325        }326      } catch (snapshotError) {327        if (328          snapshotError instanceof Error &&329          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"330        ) {331          throw snapshotError;332        }333        console.info(334          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",335          snapshotError,336        );337      }338    }339    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });340    // The Android receiver starts its relay listener as soon as its source341    // record is durable. Immediate best-effort trigger; alarm-based retries342    // cover MV3 service-worker death (setTimeout does not survive it).343    void triggerSyncIfReachable({ reason: "manual", force: true });344    schedulePostAdoptionSync();345  } catch (error) {346    push("ADOPTION_SOURCE_RESULT", {347      status: "error",348      error: error instanceof Error ? error.message : "adoption_failed",349    });350  } finally {351    clearTimeout(timeout);352    noise?.close();353    socket.close();354    pendingSourceSasConfirmation.clear();355  }356}357358/**359 * Browser receives the vault from the phone. The protocol is the mirror of360 * the source side: phone is the Noise initiator and the source of truth for361 * the SAS gate, browser is the responder that creates the vault locally.362 */363async function runReceiverSession(364  socket: PairingSocket,365  expectedToken: string,366  relayUrl: string,367  password: string,368  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },369): Promise<void> {370  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);371  // The invitation binds the responder static so the initiator can verify the ACK.372  const ephemeral = invitationEphemeral;373  const ephemeralPrivate = ephemeral.secretKey;374  const ephemeralPublic = ephemeral.publicKey;375  let noise: NoiseNKSession | undefined;376  let createdVault = false;377  let protocolError: string | undefined;378  try {379    const hello = decodeJson<{ type?: unknown; token?: unknown }>(380      await socket.receive(),381    );382    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||383        !constantTimeEqual(hello.token, expectedToken)) {384      throw new Error("invalid_invitation");385    }386    socket.send(encodeJson({387      type: "PEACH_HELLO_ACK",388      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),389    }));390391    const session = await noiseNKResponder(392      ephemeralPrivate,393      ephemeralPublic,394      async (bytes) => socket.send(bytes),395      () => socket.receive(),396    );397    noise = session;398    const channel: PairingTransport = {399      async send(plaintext) { socket.send(session.encrypt(plaintext)); },400      async receive() { return session.decrypt(await socket.receive()); },401    };402403    const deviceName = await getDeviceName();404405    const adoptionResult = await requestAdoption(channel, {406      deviceName,407      generateDeviceId: () => crypto.randomUUID(),408      async confirmSas() {409        const sas = derivePairingSAS(session.handshakeHash);410        const id = crypto.randomUUID();411        return waitForReceiverSasConfirmation(id, sas);412      },413      async createVaultFromRootKey(rootKeyHex) {414        // Direct call, not chrome.runtime.sendMessage — a service worker415        // calling sendMessage does not reach its own listener (returns416        // undefined), and the receiver already lives in the same SW context417        // as the vault module. Round-tripping through the message bus was418        // the root cause of "create_vault_no_response".419        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);420        createdVault = true;421        return { vaultId };422      },423      async applyLicense(license) {424        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self425        // issue as createVaultFromRootKey. SW can't reach its own listener.426        await applyTransferredLicense(license);427      },428      async license() {429        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;430      },431      async deriveLocalIdentity() {432        // Direct call for the same reason as applyLicense above.433        const masterKeyHex = getRootKeyHex();434        if (!masterKeyHex) throw new Error("identity_unavailable");435        const masterKey = hexToBytes(masterKeyHex);436        try {437          return await buildLocalIdentity(masterKey);438        } finally {439          masterKey.fill(0);440        }441      },442      async upsertPairedDevice(paired) {443        // Direct call for the same reason as applyLicense above.444        await storeAdoptedDevice(paired);445      },446      pairState: {447        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),448        relayUrl,449      },450      onError(error) {451        protocolError = error;452      },453    });454455    if (!adoptionResult) {456      if (createdVault) {457        await rollbackAdoptedVault();458        createdVault = false;459      }460      push("ADOPTION_SOURCE_RESULT", {461        status: "error",462        error: protocolError ?? "adoption_rejected",463      });464      return;465    }466    const { device, snapshotCapable } = adoptionResult;467    // The pairing channel is still open and authenticated — try to pull the468    // vault snapshot inline so the popup transitions immediately instead of469    // waiting on a second sync round. Skip the wait entirely when the source470    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);471    // fall through to the existing sync pipeline below.472    let snapshotApplied = false;473    if (snapshotCapable) try {474      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);475      const adapter = createSyncVaultAdapter();476      await adapter.applyEntries(snapshot.entries);477      await adapter.applyTombstones(snapshot.tombstones);478      // Duress install is isolated: a failure here must NOT demote an479      // otherwise-successful entry snapshot to the sync fallback.480      if (snapshot.duressSlot) {481        try {482          await installAdoptedDuressSlot(snapshot.duressSlot);483        } catch (duressError) {484          console.info(485            "[Peach] adopted duress slot install failed (non-fatal):",486            duressError,487          );488        }489      }490      await sendFrame(channel, {491        type: "ADOPT_SNAPSHOT_APPLIED",492        entries_received: Object.keys(snapshot.entries).length,493        tombstones_received: Object.keys(snapshot.tombstones).length,494      });495496      // Optional device graph. Non-fatal and isolated so a manifest timeout or497      // unsupported source cannot demote an otherwise-successful entry snapshot.498      try {499        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);500        if (501          isAdoptDeviceManifestFrame(manifestFrame) &&502          adapter.applyIncomingDeviceManifest &&503          typeof device.longTermPublicKeyHex === "string" &&504          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)505        ) {506          await adapter.applyIncomingDeviceManifest(507            manifestFrame.manifest,508            hexToBytes(device.longTermPublicKeyHex),509          );510          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });511        }512      } catch {513        // The graph arrives via incremental sync instead.514      }515516      await markLastSyncedAt(device.deviceId, Date.now());517      snapshotApplied = true;518    } catch (snapshotError) {519      console.info(520        "[Peach] inline snapshot failed, falling back to sync pipeline:",521        snapshotError,522      );523    }524525    // Adoption has committed identity and vault-key material, but entries have526    // not necessarily crossed yet. Persist the exact source row before telling527    // the popup pairing is complete so a popup close/reopen cannot reveal the528    // temporarily empty receiver vault. The popup clears this only after that529    // row records a successful sync.530    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(531      () => undefined,532    );533    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });534    createdVault = false;535536    if (snapshotApplied) {537      // The snapshot populated the vault and marked the source synced, so the538      // pending marker's job is done — clear it instead of making the popup wait.539      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);540    } else {541      // Fallback: the existing sync pipeline fills the vault. Immediate542      // best-effort trigger plus alarm-based retries that survive SW death.543      void triggerSyncIfReachable({ reason: "manual", force: true });544      schedulePostAdoptionSync();545    }546  } catch (error) {547    if (createdVault) {548      try {549        await rollbackAdoptedVault();550        createdVault = false;551      } catch (rollbackError) {552        console.error("[Peach] rollback failed:", rollbackError);553      }554    }555    console.error("[Peach] receiver session failed:", error);556    push("ADOPTION_SOURCE_RESULT", {557      status: "error",558      error: error instanceof Error ? error.message : "adoption_failed",559    });560  } finally {561    clearTimeout(timeout);562    noise?.close();563    socket.close();564    ephemeralPrivate.fill(0);565    ephemeralPublic.fill(0);566  }567}568569/**570 * A transferred entitlement is committed only when activation succeeds and571 * the stored value can immediately be re-read as Pro. activateLicense returns572 * a normal Free result for invalid input, so callers must not ignore it.573 */574async function applyTransferredLicense(license: string): Promise<void> {575  const activated = await activateLicense(license);576  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {577    throw new Error("license_transfer_failed");578  }579}580581/**582 * Push the SAS to the popup, then block until the popup responds via the583 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on584 * timeout or cancel so the adoption aborts before any vault state changes.585 *586 * The pending deferred MUST be installed before the push: a popup-side587 * auto-approver (test mocks, future automation) can synchronously resolve588 * the deferred the moment it sees the SAS push, and a missing slot would589 * drop that approval on the floor.590 */591function waitForReceiverSasConfirmation(id: string, sas: string): Promise<boolean> {592  return new Promise<boolean>((resolve) => {593    const timer = setTimeout(() => {594      pendingReceiverSasConfirmation.delete(id);595      resolve(false);596    }, SOURCE_WINDOW_MS);597    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {598      clearTimeout(timer);599      resolve(ok);600    } });601    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });602  });603}604605function waitForSourceSasConfirmation(606  id: string,607  request: AdoptRequest,608  sas: string,609): Promise<boolean> {610  return new Promise<boolean>((resolve) => {611    const timer = setTimeout(() => {612      pendingSourceSasConfirmation.delete(id);613      resolve(false);614    }, SOURCE_WINDOW_MS);615    pendingSourceSasConfirmation.set(id, {616      resolve: (ok) => {617        clearTimeout(timer);618        resolve(ok);619      },620    });621    push("ADOPTION_SOURCE_SAS", {622      id,623      sas,624      receiverDeviceName: request.device_name,625    });626  });627}628629/**630 * Opening Add Device in an unlocked extension is the source-side authorization.631 * Announce the SAS for comparison, then let the scanning device's explicit632 * confirmation be the single commit gate. grantAdoption still refuses to send633 * the root key until the receiver returns ADOPT_CONFIRM(true).634 */635function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {636  return waitForSourceSasConfirmation(id, request, sas);637}638639function clearState(): void {640  activeSocket = null;641  inFlight = false;642  pendingSourceSasConfirmation.clear();643  pendingReceiverSasConfirmation.clear();644}645646function constantTimeEqual(a: string, b: string): boolean {647  if (a.length !== b.length) return false;648  let diff = 0;649  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);650  return diff === 0;651}652653function encodeJson(value: unknown): Uint8Array {654  return new TextEncoder().encode(JSON.stringify(value));655}656657function decodeJson<T>(value: Uint8Array): T {658  return JSON.parse(new TextDecoder().decode(value)) as T;659}660661/**662 * PB-26-213: an adopted device's secret bypasses the public registry into663 * the encrypted pair-secret record. The registry row stays secretless; when664 * the vault is somehow locked here the row still lands and the next unlock665 * cannot recover this secret — re-pair is the fail-closed path.666 */667async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {668  const secret = paired.rendezvousSecretHex;669  const pub = { ...paired };670  delete pub.rendezvousSecretHex;671  await upsertPairedDevice(pub);672  if (!isValidPairSecret(secret)) return;673  const masterKeyHex = getRootKeyHex();674  if (!masterKeyHex) return;675  const masterKey = hexToBytes(masterKeyHex);676  try {677    await setPairSecret(paired.deviceId, secret, masterKey);678  } finally {679    masterKey.fill(0);680  }681}682683function hexToBytes(hex: string): Uint8Array {684  const out = new Uint8Array(hex.length / 2);685  for (let i = 0; i < out.length; i++) {686    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);687  }688  return out;689}