relay-adoption-bg.ts

24.6 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 { deferAutoLockForOperation } from "./alarms";42import { computeVaultId } from "../shared/sync-identity";43import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";44import { storageRemove, storageSet } from "../shared/storage";45import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";46import { isValidPairSecret, setPairSecret } from "./pair-secrets";47import { createSyncVaultAdapter } from "./sync-vault-adapter";48import { triggerSyncIfReachable } from "./sync-trigger";49import { schedulePostAdoptionSync } from "./sync-alarm";5051const SOURCE_WINDOW_MS = 5 * 60 * 1000;5253function push(type: string, payload: unknown): void {54  try {55    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;56    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {57      (maybe as Promise<unknown>).catch(() => undefined);58    }59  } catch {60    // The popup may have closed. The pending approval fails closed on cleanup.61  }62}6364/** Race a promise against a wall-clock deadline; rejects with "timeout". */65function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {66  return new Promise<T>((resolve, reject) => {67    const timer = setTimeout(() => reject(new Error("timeout")), ms);68    promise.then(69      (value) => {70        clearTimeout(timer);71        resolve(value);72      },73      (error) => {74        clearTimeout(timer);75        reject(error);76      },77    );78  });79}8081let inFlight = false;82let activeSocket: PairingSocket | null = null;83// Outstanding source-side SAS confirmations, keyed by id. A map (vs the84// receiver's single slot) makes a stale decision from a previous source85// session impossible to land on the current one even if both run on the86// same background worker.87const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();88const pendingReceiverSasConfirmation = new Map<string, { sas: string; resolve: (ok: boolean) => void }>();8990export function isRelayAdoptionSourceInFlight(): boolean {91  return inFlight;92}9394/**95 * Occupy the non-buffering room before returning the invitation to the UI.96 * All long-term key material remains in the background service worker.97 */98export async function startRelayAdoptionSource(): Promise<{99  invitation: string;100}> {101  if (inFlight) throw new Error("adoption_source_in_progress");102  if (!isUnlocked()) throw new Error("vault_locked");103  inFlight = true;104  const releaseAutoLock = deferAutoLockForOperation(SOURCE_WINDOW_MS);105106  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;107  try {108    const settings = await getSyncSettings();109    const relayUrl = relayInForce(settings);110    local = await getLocalPairingIdentity();111    const token = generatePairingRendezvousToken();112    // Source side has the vault — caller checked isUnlocked — so the phone113    // can read the invitation and know it should receive the vault.114    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));115    const invitation = parsePairingInvitation(encoded);116    if (!invitation) throw new Error("invitation_generation_failed");117118    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));119    activeSocket = socket;120    const readyLocal = local;121    void runSourceSession(122      socket,123      token,124      relayUrl,125      readyLocal,126    ).finally(() => {127      readyLocal.privateKey.fill(0);128      readyLocal.publicKey.fill(0);129      clearState();130      releaseAutoLock();131    });132    return { invitation: encoded };133  } catch (error) {134    local?.privateKey.fill(0);135    local?.publicKey.fill(0);136    clearState();137    releaseAutoLock();138    throw error;139  }140}141142export function cancelRelayAdoptionSource(): void {143  activeSocket?.close();144  clearState();145}146147/**148 * Onboarding pairing request — the empty extension shows a QR and the populated149 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the150 * phone reads to switch into source mode and `grantAdoption` over the151 * established Noise channel. The receiver's `runReceiverSession` accepts the152 * root key and creates a local vault wrapped with the popup-supplied password.153 *154 * `password` is the local-only password the user picked on the "Set a password155 * for this device" screen; it wraps the adopted root key at rest and has no156 * relationship to the source device's password.157 */158export async function startRelayAdoptionReceiver(159  password: string,160): Promise<{ invitation: string }> {161  if (inFlight) throw new Error("adoption_receiver_in_progress");162  if (typeof password !== "string" || password.length === 0) {163    throw new Error("ADOPT_INVALID_PASSWORD");164  }165  inFlight = true;166  try {167    const settings = await getSyncSettings();168    const relayUrl = relayInForce(settings);169    const token = generatePairingRendezvousToken();170    const ephemeral = generateKeyPair();171    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));172    const invitation = parsePairingInvitation(encoded);173    if (!invitation) throw new Error("invitation_generation_failed");174    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));175    activeSocket = socket;176    void runReceiverSession(177      socket,178      token,179      relayUrl,180      password,181      ephemeral,182    ).finally(() => {183      ephemeral.secretKey.fill(0);184      ephemeral.publicKey.fill(0);185      clearState();186    });187    return { invitation: encoded };188  } catch (error) {189    clearState();190    throw error;191  }192}193194/**195 * Resolves the source-side SAS confirmation by id. Called by the message196 * handler when the popup taps Confirm/Cancel on the source's SAS screen.197 * Returns true if the id matched an in-flight confirmation. Unknown ids198 * (already resolved, timed out, or never existed) are a no-op so a late199 * popup click cannot crash anything.200 */201export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {202  const pending = pendingSourceSasConfirmation.get(id);203  if (!pending) return false;204  pendingSourceSasConfirmation.delete(id);205  pending.resolve(ok);206  return true;207}208209export function resolveReceiverSasConfirmation(id: string, sas: string, ok: boolean): boolean {210  const pending = pendingReceiverSasConfirmation.get(id);211  if (!pending) return false;212  pendingReceiverSasConfirmation.delete(id);213  pending.resolve(ok && constantTimeEqual(pending.sas, sas));214  return true;215}216217async function runSourceSession(218  socket: PairingSocket,219  expectedToken: string,220  relayUrl: string,221  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,222): Promise<void> {223  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);224  let noise: NoiseNKSession | undefined;225  try {226    const hello = decodeJson<{ type?: unknown; token?: unknown }>(227      await socket.receive(),228    );229    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||230        !constantTimeEqual(hello.token, expectedToken)) {231      throw new Error("invalid_invitation");232    }233    socket.send(encodeJson({234      type: "PEACH_HELLO_ACK",235      ephemeralPublicKeyHex: bytesToHex(local.publicKey),236    }));237238    const session = await noiseNKResponder(239      local.privateKey,240      local.publicKey,241      async (bytes) => socket.send(bytes),242      () => socket.receive(),243    );244    noise = session;245    const channel: PairingTransport = {246      async send(plaintext) { socket.send(session.encrypt(plaintext)); },247      async receive() { return session.decrypt(await socket.receive()); },248    };249    const request = await receiveFrame(channel);250    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");251252    const device = await grantAdoption(channel, request, {253      sourceIdentity: local.identity,254      pairState: {255        rendezvousSecretHex: bytesToHex(256          deriveRendezvousSecret(session.handshakeHash),257        ),258        relayUrl,259      },260      async rootKeyHex() {261        // grantAdoption calls this only after local password approval and the262        // receiver's encrypted ADOPT_CONFIRM(true).263        const value = getRootKeyHex();264        if (!value) throw new Error("vault_locked");265        return value;266      },267      async vaultId() {268        const value = getRootKeyHex();269        if (!value) throw new Error("vault_locked");270        const bytes = hexToBytes(value);271        try {272          return await computeVaultId(bytes);273        } finally {274          bytes.fill(0);275        }276      },277      async license() {278        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;279      },280      async applyLicense(license) {281        await applyTransferredLicense(license);282      },283      onConsent: (adoptRequest) => requestConsent(284        adoptRequest,285        derivePairingSAS(session.handshakeHash),286        crypto.randomUUID(),287      ),288      upsertPairedDevice: storeAdoptedDevice,289    });290    if (!device) {291      push("ADOPTION_SOURCE_RESULT", {292        status: "error",293        error: "adoption_rejected",294      });295      return;296    }297    // If the receiver advertised inline-snapshot support, send the vault now298    // over the same authenticated channel. Best-effort: any failure leaves the299    // receiver to fall back to the sync pipeline triggered below.300    if (request.snapshot_capable === true) {301      try {302        const adapter = createSyncVaultAdapter();303        const entryManifest = await adapter.buildEntryManifest();304        const entryIds = Object.keys(entryManifest);305        const entries =306          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};307        const tombstones = await adapter.buildTombstoneManifest();308        let duressSlot: string | null = null;309        try {310          duressSlot = await exportDuressSlotForAdoption();311        } catch {312          // Duress omission must not fail the whole snapshot.313        }314        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);315316        const applied = await withTimeout(receiveFrame(channel), 15_000);317        if (isAdoptSnapshotApplied(applied)) {318          console.info(319            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,320          );321        }322323        if (adapter.buildOutgoingDeviceManifest) {324          const manifest = await adapter.buildOutgoingDeviceManifest();325          if (manifest) {326            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });327            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);328          }329        }330      } catch (snapshotError) {331        if (332          snapshotError instanceof Error &&333          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"334        ) {335          throw snapshotError;336        }337        console.info(338          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",339          snapshotError,340        );341      }342    }343    push("ADOPTION_SOURCE_RESULT", { status: "complete" });344    // The Android receiver starts its relay listener as soon as its source345    // record is durable. Immediate best-effort trigger; alarm-based retries346    // cover MV3 service-worker death (setTimeout does not survive it).347    void triggerSyncIfReachable({ reason: "manual", force: true });348    schedulePostAdoptionSync();349  } catch (error) {350    push("ADOPTION_SOURCE_RESULT", {351      status: "error",352      error: error instanceof Error ? error.message : "adoption_failed",353    });354  } finally {355    clearTimeout(timeout);356    noise?.close();357    socket.close();358    pendingSourceSasConfirmation.clear();359  }360}361362/**363 * Browser receives the vault from the phone. The protocol is the mirror of364 * the source side: phone is the Noise initiator and the source of truth for365 * the SAS gate, browser is the responder that creates the vault locally.366 */367async function runReceiverSession(368  socket: PairingSocket,369  expectedToken: string,370  relayUrl: string,371  password: string,372  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },373): Promise<void> {374  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);375  // The invitation binds the responder static so the initiator can verify the ACK.376  const ephemeral = invitationEphemeral;377  const ephemeralPrivate = ephemeral.secretKey;378  const ephemeralPublic = ephemeral.publicKey;379  let noise: NoiseNKSession | undefined;380  let createdVault = false;381  let protocolError: string | undefined;382  try {383    const hello = decodeJson<{ type?: unknown; token?: unknown }>(384      await socket.receive(),385    );386    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||387        !constantTimeEqual(hello.token, expectedToken)) {388      throw new Error("invalid_invitation");389    }390    socket.send(encodeJson({391      type: "PEACH_HELLO_ACK",392      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),393    }));394395    const session = await noiseNKResponder(396      ephemeralPrivate,397      ephemeralPublic,398      async (bytes) => socket.send(bytes),399      () => socket.receive(),400    );401    noise = session;402    const channel: PairingTransport = {403      async send(plaintext) { socket.send(session.encrypt(plaintext)); },404      async receive() { return session.decrypt(await socket.receive()); },405    };406407    const deviceName = await getDeviceName();408409    const adoptionResult = await requestAdoption(channel, {410      deviceName,411      generateDeviceId: () => crypto.randomUUID(),412      async confirmSas() {413        const sas = derivePairingSAS(session.handshakeHash);414        const id = crypto.randomUUID();415        return waitForReceiverSasConfirmation(id, sas);416      },417      async createVaultFromRootKey(rootKeyHex) {418        // Direct call, not chrome.runtime.sendMessage — a service worker419        // calling sendMessage does not reach its own listener (returns420        // undefined), and the receiver already lives in the same SW context421        // as the vault module. Round-tripping through the message bus was422        // the root cause of "create_vault_no_response".423        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);424        createdVault = true;425        return { vaultId };426      },427      async applyLicense(license) {428        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self429        // issue as createVaultFromRootKey. SW can't reach its own listener.430        await applyTransferredLicense(license);431      },432      async license() {433        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;434      },435      async deriveLocalIdentity() {436        // Direct call for the same reason as applyLicense above.437        const masterKeyHex = getRootKeyHex();438        if (!masterKeyHex) throw new Error("identity_unavailable");439        const masterKey = hexToBytes(masterKeyHex);440        try {441          return await buildLocalIdentity(masterKey);442        } finally {443          masterKey.fill(0);444        }445      },446      async upsertPairedDevice(paired) {447        // Direct call for the same reason as applyLicense above.448        await storeAdoptedDevice(paired);449      },450      pairState: {451        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),452        relayUrl,453      },454      onError(error) {455        protocolError = error;456      },457    });458459    if (!adoptionResult) {460      if (createdVault) {461        await rollbackAdoptedVault();462        createdVault = false;463      }464      push("ADOPTION_SOURCE_RESULT", {465        status: "error",466        error: protocolError ?? "adoption_rejected",467      });468      return;469    }470    const { device, snapshotCapable } = adoptionResult;471    // The pairing channel is still open and authenticated — try to pull the472    // vault snapshot inline so the popup transitions immediately instead of473    // waiting on a second sync round. Skip the wait entirely when the source474    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);475    // fall through to the existing sync pipeline below.476    let snapshotApplied = false;477    if (snapshotCapable) try {478      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);479      const adapter = createSyncVaultAdapter();480      await adapter.applyEntries(snapshot.entries);481      await adapter.applyTombstones(snapshot.tombstones);482      // Duress install is isolated: a failure here must NOT demote an483      // otherwise-successful entry snapshot to the sync fallback.484      if (snapshot.duressSlot) {485        try {486          await installAdoptedDuressSlot(snapshot.duressSlot);487        } catch (duressError) {488          console.info(489            "[Peach] adopted duress slot install failed (non-fatal):",490            duressError,491          );492        }493      }494      await sendFrame(channel, {495        type: "ADOPT_SNAPSHOT_APPLIED",496        entries_received: Object.keys(snapshot.entries).length,497        tombstones_received: Object.keys(snapshot.tombstones).length,498      });499500      // Optional device graph. Non-fatal and isolated so a manifest timeout or501      // unsupported source cannot demote an otherwise-successful entry snapshot.502      try {503        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);504        if (505          isAdoptDeviceManifestFrame(manifestFrame) &&506          adapter.applyIncomingDeviceManifest &&507          typeof device.longTermPublicKeyHex === "string" &&508          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)509        ) {510          await adapter.applyIncomingDeviceManifest(511            manifestFrame.manifest,512            hexToBytes(device.longTermPublicKeyHex),513          );514          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });515        }516      } catch {517        // The graph arrives via incremental sync instead.518      }519520      await markLastSyncedAt(device.deviceId, Date.now());521      snapshotApplied = true;522    } catch (snapshotError) {523      console.info(524        "[Peach] inline snapshot failed, falling back to sync pipeline:",525        snapshotError,526      );527    }528529    // Adoption has committed identity and vault-key material, but entries have530    // not necessarily crossed yet. Persist the exact source row before telling531    // the popup pairing is complete so a popup close/reopen cannot reveal the532    // temporarily empty receiver vault. The popup clears this only after that533    // row records a successful sync.534    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(535      () => undefined,536    );537    push("ADOPTION_SOURCE_RESULT", { status: "complete" });538    createdVault = false;539540    if (snapshotApplied) {541      // The snapshot populated the vault and marked the source synced, so the542      // pending marker's job is done — clear it instead of making the popup wait.543      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);544    } else {545      // Fallback: the existing sync pipeline fills the vault. Immediate546      // best-effort trigger plus alarm-based retries that survive SW death.547      void triggerSyncIfReachable({ reason: "manual", force: true });548      schedulePostAdoptionSync();549    }550  } catch (error) {551    if (createdVault) {552      try {553        await rollbackAdoptedVault();554        createdVault = false;555      } catch (rollbackError) {556        console.error("[Peach] rollback failed:", rollbackError);557      }558    }559    console.error("[Peach] receiver session failed:", error);560    push("ADOPTION_SOURCE_RESULT", {561      status: "error",562      error: error instanceof Error ? error.message : "adoption_failed",563    });564  } finally {565    clearTimeout(timeout);566    noise?.close();567    socket.close();568    ephemeralPrivate.fill(0);569    ephemeralPublic.fill(0);570  }571}572573/**574 * A transferred entitlement is committed only when activation succeeds and575 * the stored value can immediately be re-read as Pro. activateLicense returns576 * a normal Free result for invalid input, so callers must not ignore it.577 */578async function applyTransferredLicense(license: string): Promise<void> {579  const activated = await activateLicense(license);580  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {581    throw new Error("license_transfer_failed");582  }583}584585/**586 * Push the SAS to the popup, then block until the popup responds via the587 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on588 * timeout or cancel so the adoption aborts before any vault state changes.589 *590 * The pending deferred MUST be installed before the push: a popup-side591 * auto-approver (test mocks, future automation) can synchronously resolve592 * the deferred the moment it sees the SAS push, and a missing slot would593 * drop that approval on the floor.594 */595function waitForReceiverSasConfirmation(596  id: string,597  sas: string,598): Promise<boolean> {599  return new Promise<boolean>((resolve) => {600    const timer = setTimeout(() => {601      pendingReceiverSasConfirmation.delete(id);602      resolve(false);603    }, SOURCE_WINDOW_MS);604    pendingReceiverSasConfirmation.set(id, { sas, resolve: (ok) => {605      clearTimeout(timer);606      resolve(ok);607    } });608    push("ADOPTION_SOURCE_SAS", { id, sas, displayOnly: false });609  });610}611612function waitForSourceSasConfirmation(613  id: string,614  request: AdoptRequest,615  sas: string,616): Promise<boolean> {617  return new Promise<boolean>((resolve) => {618    const timer = setTimeout(() => {619      pendingSourceSasConfirmation.delete(id);620      resolve(false);621    }, SOURCE_WINDOW_MS);622    pendingSourceSasConfirmation.set(id, {623      resolve: (ok) => {624        clearTimeout(timer);625        resolve(ok);626      },627    });628    push("ADOPTION_SOURCE_SAS", {629      id,630      sas,631      receiverDeviceName: request.device_name,632    });633  });634}635636/**637 * Opening Add Device in an unlocked extension is the source-side authorization.638 * Announce the SAS for comparison, then let the scanning device's explicit639 * confirmation be the single commit gate. grantAdoption still refuses to send640 * the root key until the receiver returns ADOPT_CONFIRM(true).641 */642function requestConsent(643  request: AdoptRequest,644  sas: string,645  id: string,646): Promise<boolean> {647  return waitForSourceSasConfirmation(id, request, sas);648}649650function clearState(): void {651  activeSocket = null;652  inFlight = false;653  pendingSourceSasConfirmation.clear();654  pendingReceiverSasConfirmation.clear();655}656657function constantTimeEqual(a: string, b: string): boolean {658  if (a.length !== b.length) return false;659  let diff = 0;660  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);661  return diff === 0;662}663664function encodeJson(value: unknown): Uint8Array {665  return new TextEncoder().encode(JSON.stringify(value));666}667668function decodeJson<T>(value: Uint8Array): T {669  return JSON.parse(new TextDecoder().decode(value)) as T;670}671672/**673 * PB-26-213: an adopted device's secret bypasses the public registry into674 * the encrypted pair-secret record. The registry row stays secretless; when675 * the vault is somehow locked here the row still lands and the next unlock676 * cannot recover this secret — re-pair is the fail-closed path.677 */678async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {679  const secret = paired.rendezvousSecretHex;680  const pub = { ...paired };681  delete pub.rendezvousSecretHex;682  await upsertPairedDevice(pub);683  if (!isValidPairSecret(secret)) return;684  const masterKeyHex = getRootKeyHex();685  if (!masterKeyHex) return;686  const masterKey = hexToBytes(masterKeyHex);687  try {688    await setPairSecret(paired.deviceId, secret, masterKey);689  } finally {690    masterKey.fill(0);691  }692}693694function hexToBytes(hex: string): Uint8Array {695  const out = new Uint8Array(hex.length / 2);696  for (let i = 0; i < out.length; i++) {697    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);698  }699  return out;700}