relay-adoption-bg.ts

23.0 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 {32  bytesToHex,33  deriveRendezvousSecret,34  generatePairingRendezvousToken,35} from "../shared/rendezvous";36import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";37import { getSyncSettings, relayInForce } from "./sync-settings";38import { activateLicense, getLicenseKey, isProUser } from "./license";39import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";40import { computeVaultId } from "../shared/sync-identity";41import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";42import { storageRemove, storageSet } from "../shared/storage";43import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";44import { createSyncVaultAdapter } from "./sync-vault-adapter";45import { triggerSyncIfReachable } from "./sync-trigger";46import { schedulePostAdoptionSync } from "./sync-alarm";4748const SOURCE_WINDOW_MS = 5 * 60 * 1000;4950function push(type: string, payload: unknown): void {51  try {52    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;53    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {54      (maybe as Promise<unknown>).catch(() => undefined);55    }56  } catch {57    // The popup may have closed. The pending approval fails closed on cleanup.58  }59}6061/** Race a promise against a wall-clock deadline; rejects with "timeout". */62function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {63  return new Promise<T>((resolve, reject) => {64    const timer = setTimeout(() => reject(new Error("timeout")), ms);65    promise.then(66      (value) => {67        clearTimeout(timer);68        resolve(value);69      },70      (error) => {71        clearTimeout(timer);72        reject(error);73      },74    );75  });76}7778let inFlight = false;79let activeSocket: PairingSocket | null = null;80// Outstanding source-side SAS confirmations, keyed by id. A map (vs the81// receiver's single slot) makes a stale decision from a previous source82// session impossible to land on the current one even if both run on the83// same background worker.84const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();8586export function isRelayAdoptionSourceInFlight(): boolean {87  return inFlight;88}8990/**91 * Occupy the non-buffering room before returning the invitation to the UI.92 * All long-term key material remains in the background service worker.93 */94export async function startRelayAdoptionSource(): Promise<{95  invitation: string;96}> {97  if (inFlight) throw new Error("adoption_source_in_progress");98  if (!isUnlocked()) throw new Error("vault_locked");99  inFlight = true;100101  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;102  try {103    const settings = await getSyncSettings();104    const relayUrl = relayInForce(settings);105    local = await getLocalPairingIdentity();106    const token = generatePairingRendezvousToken();107    // Source side has the vault — caller checked isUnlocked — so the phone108    // can read the invitation and know it should receive the vault.109    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));110    const invitation = parsePairingInvitation(encoded);111    if (!invitation) throw new Error("invitation_generation_failed");112113    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));114    activeSocket = socket;115    const readyLocal = local;116    void runSourceSession(117      socket,118      token,119      relayUrl,120      readyLocal,121    ).finally(() => {122      readyLocal.privateKey.fill(0);123      readyLocal.publicKey.fill(0);124      clearState();125    });126    return { invitation: encoded };127  } catch (error) {128    local?.privateKey.fill(0);129    local?.publicKey.fill(0);130    clearState();131    throw error;132  }133}134135export function cancelRelayAdoptionSource(): void {136  activeSocket?.close();137  clearState();138}139140/**141 * Onboarding pairing request — the empty extension shows a QR and the populated142 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the143 * phone reads to switch into source mode and `grantAdoption` over the144 * established Noise channel. The receiver's `runReceiverSession` accepts the145 * root key and creates a local vault wrapped with the popup-supplied password.146 *147 * `password` is the local-only password the user picked on the "Set a password148 * for this device" screen; it wraps the adopted root key at rest and has no149 * relationship to the source device's password.150 */151export async function startRelayAdoptionReceiver(152  password: string,153): Promise<{ invitation: string }> {154  if (inFlight) throw new Error("adoption_receiver_in_progress");155  if (typeof password !== "string" || password.length === 0) {156    throw new Error("ADOPT_INVALID_PASSWORD");157  }158  inFlight = true;159  try {160    const settings = await getSyncSettings();161    const relayUrl = relayInForce(settings);162    const token = generatePairingRendezvousToken();163    const ephemeral = generateKeyPair();164    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));165    const invitation = parsePairingInvitation(encoded);166    if (!invitation) throw new Error("invitation_generation_failed");167    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));168    activeSocket = socket;169    void runReceiverSession(170      socket,171      token,172      relayUrl,173      password,174      ephemeral,175    ).finally(() => {176      ephemeral.secretKey.fill(0);177      ephemeral.publicKey.fill(0);178      clearState();179    });180    return { invitation: encoded };181  } catch (error) {182    clearState();183    throw error;184  }185}186187/**188 * Resolves the source-side SAS confirmation by id. Called by the message189 * handler when the popup taps Confirm/Cancel on the source's SAS screen.190 * Returns true if the id matched an in-flight confirmation. Unknown ids191 * (already resolved, timed out, or never existed) are a no-op so a late192 * popup click cannot crash anything.193 */194export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {195  const pending = pendingSourceSasConfirmation.get(id);196  if (!pending) return false;197  pendingSourceSasConfirmation.delete(id);198  pending.resolve(ok);199  return true;200}201202async function runSourceSession(203  socket: PairingSocket,204  expectedToken: string,205  relayUrl: string,206  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,207): Promise<void> {208  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);209  let noise: NoiseNKSession | undefined;210  try {211    const hello = decodeJson<{ type?: unknown; token?: unknown }>(212      await socket.receive(),213    );214    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||215        !constantTimeEqual(hello.token, expectedToken)) {216      throw new Error("invalid_invitation");217    }218    socket.send(encodeJson({219      type: "PEACH_HELLO_ACK",220      ephemeralPublicKeyHex: bytesToHex(local.publicKey),221    }));222223    const session = await noiseNKResponder(224      local.privateKey,225      local.publicKey,226      async (bytes) => socket.send(bytes),227      () => socket.receive(),228    );229    noise = session;230    const channel: PairingTransport = {231      async send(plaintext) { socket.send(session.encrypt(plaintext)); },232      async receive() { return session.decrypt(await socket.receive()); },233    };234    const request = await receiveFrame(channel);235    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");236237    const device = await grantAdoption(channel, request, {238      sourceIdentity: local.identity,239      pairState: {240        rendezvousSecretHex: bytesToHex(241          deriveRendezvousSecret(session.handshakeHash),242        ),243        relayUrl,244      },245      async rootKeyHex() {246        // grantAdoption calls this only after local password approval and the247        // receiver's encrypted ADOPT_CONFIRM(true).248        const value = getRootKeyHex();249        if (!value) throw new Error("vault_locked");250        return value;251      },252      async vaultId() {253        const value = getRootKeyHex();254        if (!value) throw new Error("vault_locked");255        const bytes = hexToBytes(value);256        try {257          return await computeVaultId(bytes);258        } finally {259          bytes.fill(0);260        }261      },262      async license() {263        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;264      },265      async applyLicense(license) {266        await applyTransferredLicense(license);267      },268      onConsent: (adoptRequest) => requestConsent(269        adoptRequest,270        derivePairingSAS(session.handshakeHash),271        crypto.randomUUID(),272      ),273      upsertPairedDevice,274    });275    if (!device) {276      push("ADOPTION_SOURCE_RESULT", {277        status: "error",278        error: "adoption_rejected",279      });280      return;281    }282    // If the receiver advertised inline-snapshot support, send the vault now283    // over the same authenticated channel. Best-effort: any failure leaves the284    // receiver to fall back to the sync pipeline triggered below.285    if (request.snapshot_capable === true) {286      try {287        const adapter = createSyncVaultAdapter();288        const entryManifest = await adapter.buildEntryManifest();289        const entryIds = Object.keys(entryManifest);290        const entries =291          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};292        const tombstones = await adapter.buildTombstoneManifest();293        let duressSlot: string | null = null;294        try {295          duressSlot = await exportDuressSlotForAdoption();296        } catch {297          // Duress omission must not fail the whole snapshot.298        }299        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);300301        const applied = await withTimeout(receiveFrame(channel), 15_000);302        if (isAdoptSnapshotApplied(applied)) {303          console.info(304            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,305          );306        }307308        if (adapter.buildOutgoingDeviceManifest) {309          const manifest = await adapter.buildOutgoingDeviceManifest();310          if (manifest) {311            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });312            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);313          }314        }315      } catch (snapshotError) {316        if (317          snapshotError instanceof Error &&318          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"319        ) {320          throw snapshotError;321        }322        console.info(323          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",324          snapshotError,325        );326      }327    }328    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });329    // The Android receiver starts its relay listener as soon as its source330    // record is durable. Immediate best-effort trigger; alarm-based retries331    // cover MV3 service-worker death (setTimeout does not survive it).332    void triggerSyncIfReachable({ reason: "manual", force: true });333    schedulePostAdoptionSync();334  } catch (error) {335    push("ADOPTION_SOURCE_RESULT", {336      status: "error",337      error: error instanceof Error ? error.message : "adoption_failed",338    });339  } finally {340    clearTimeout(timeout);341    noise?.close();342    socket.close();343    pendingSourceSasConfirmation.clear();344  }345}346347/**348 * Browser receives the vault from the phone. The protocol is the mirror of349 * the source side: phone is the Noise initiator and the source of truth for350 * the SAS gate, browser is the responder that creates the vault locally.351 */352async function runReceiverSession(353  socket: PairingSocket,354  expectedToken: string,355  relayUrl: string,356  password: string,357  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },358): Promise<void> {359  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);360  // The invitation binds the responder static so the initiator can verify the ACK.361  const ephemeral = invitationEphemeral;362  const ephemeralPrivate = ephemeral.secretKey;363  const ephemeralPublic = ephemeral.publicKey;364  let noise: NoiseNKSession | undefined;365  let createdVault = false;366  let protocolError: string | undefined;367  try {368    const hello = decodeJson<{ type?: unknown; token?: unknown }>(369      await socket.receive(),370    );371    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||372        !constantTimeEqual(hello.token, expectedToken)) {373      throw new Error("invalid_invitation");374    }375    socket.send(encodeJson({376      type: "PEACH_HELLO_ACK",377      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),378    }));379380    const session = await noiseNKResponder(381      ephemeralPrivate,382      ephemeralPublic,383      async (bytes) => socket.send(bytes),384      () => socket.receive(),385    );386    noise = session;387    const channel: PairingTransport = {388      async send(plaintext) { socket.send(session.encrypt(plaintext)); },389      async receive() { return session.decrypt(await socket.receive()); },390    };391392    const deviceName = await getDeviceName();393394    const adoptionResult = await requestAdoption(channel, {395      deviceName,396      generateDeviceId: () => crypto.randomUUID(),397      // Auto-accept the SAS: both sides derive it from the same Noise398      // handshake hash, so the codes match by construction. Requiring the user399      // to confirm on both sides creates a race where the phone can time out400      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The401      // phone's onConsent is the single user gate; this side just proceeds.402      // Still show the locally derived code for visual verification.403      async confirmSas() {404        push("ADOPTION_SOURCE_SAS", {405          sas: derivePairingSAS(session.handshakeHash),406          displayOnly: true,407        });408        return true;409      },410      async createVaultFromRootKey(rootKeyHex) {411        // Direct call, not chrome.runtime.sendMessage — a service worker412        // calling sendMessage does not reach its own listener (returns413        // undefined), and the receiver already lives in the same SW context414        // as the vault module. Round-tripping through the message bus was415        // the root cause of "create_vault_no_response".416        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);417        createdVault = true;418        return { vaultId };419      },420      async applyLicense(license) {421        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self422        // issue as createVaultFromRootKey. SW can't reach its own listener.423        await applyTransferredLicense(license);424      },425      async license() {426        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;427      },428      async deriveLocalIdentity() {429        // Direct call for the same reason as applyLicense above.430        const masterKeyHex = getRootKeyHex();431        if (!masterKeyHex) throw new Error("identity_unavailable");432        const masterKey = hexToBytes(masterKeyHex);433        try {434          return await buildLocalIdentity(masterKey);435        } finally {436          masterKey.fill(0);437        }438      },439      async upsertPairedDevice(paired) {440        // Direct call for the same reason as applyLicense above.441        await upsertPairedDevice(paired);442      },443      pairState: {444        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),445        relayUrl,446      },447      onError(error) {448        protocolError = error;449      },450    });451452    if (!adoptionResult) {453      if (createdVault) {454        await rollbackAdoptedVault();455        createdVault = false;456      }457      push("ADOPTION_SOURCE_RESULT", {458        status: "error",459        error: protocolError ?? "adoption_rejected",460      });461      return;462    }463    const { device, snapshotCapable } = adoptionResult;464    // The pairing channel is still open and authenticated — try to pull the465    // vault snapshot inline so the popup transitions immediately instead of466    // waiting on a second sync round. Skip the wait entirely when the source467    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);468    // fall through to the existing sync pipeline below.469    let snapshotApplied = false;470    if (snapshotCapable) try {471      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);472      const adapter = createSyncVaultAdapter();473      await adapter.applyEntries(snapshot.entries);474      await adapter.applyTombstones(snapshot.tombstones);475      // Duress install is isolated: a failure here must NOT demote an476      // otherwise-successful entry snapshot to the sync fallback.477      if (snapshot.duressSlot) {478        try {479          await installAdoptedDuressSlot(snapshot.duressSlot);480        } catch (duressError) {481          console.info(482            "[Peach] adopted duress slot install failed (non-fatal):",483            duressError,484          );485        }486      }487      await sendFrame(channel, {488        type: "ADOPT_SNAPSHOT_APPLIED",489        entries_received: Object.keys(snapshot.entries).length,490        tombstones_received: Object.keys(snapshot.tombstones).length,491      });492493      // Optional device graph. Non-fatal and isolated so a manifest timeout or494      // unsupported source cannot demote an otherwise-successful entry snapshot.495      try {496        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);497        if (498          isAdoptDeviceManifestFrame(manifestFrame) &&499          adapter.applyIncomingDeviceManifest &&500          typeof device.longTermPublicKeyHex === "string" &&501          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)502        ) {503          await adapter.applyIncomingDeviceManifest(504            manifestFrame.manifest,505            hexToBytes(device.longTermPublicKeyHex),506          );507          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });508        }509      } catch {510        // The graph arrives via incremental sync instead.511      }512513      await markLastSyncedAt(device.deviceId, Date.now());514      snapshotApplied = true;515    } catch (snapshotError) {516      console.info(517        "[Peach] inline snapshot failed, falling back to sync pipeline:",518        snapshotError,519      );520    }521522    // Adoption has committed identity and vault-key material, but entries have523    // not necessarily crossed yet. Persist the exact source row before telling524    // the popup pairing is complete so a popup close/reopen cannot reveal the525    // temporarily empty receiver vault. The popup clears this only after that526    // row records a successful sync.527    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(528      () => undefined,529    );530    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });531    createdVault = false;532533    if (snapshotApplied) {534      // The snapshot populated the vault and marked the source synced, so the535      // pending marker's job is done — clear it instead of making the popup wait.536      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);537    } else {538      // Fallback: the existing sync pipeline fills the vault. Immediate539      // best-effort trigger plus alarm-based retries that survive SW death.540      void triggerSyncIfReachable({ reason: "manual", force: true });541      schedulePostAdoptionSync();542    }543  } catch (error) {544    if (createdVault) {545      try {546        await rollbackAdoptedVault();547        createdVault = false;548      } catch (rollbackError) {549        console.error("[Peach] rollback failed:", rollbackError);550      }551    }552    console.error("[Peach] receiver session failed:", error);553    push("ADOPTION_SOURCE_RESULT", {554      status: "error",555      error: error instanceof Error ? error.message : "adoption_failed",556    });557  } finally {558    clearTimeout(timeout);559    noise?.close();560    socket.close();561    ephemeralPrivate.fill(0);562    ephemeralPublic.fill(0);563  }564}565566/**567 * A transferred entitlement is committed only when activation succeeds and568 * the stored value can immediately be re-read as Pro. activateLicense returns569 * a normal Free result for invalid input, so callers must not ignore it.570 */571async function applyTransferredLicense(license: string): Promise<void> {572  const activated = await activateLicense(license);573  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {574    throw new Error("license_transfer_failed");575  }576}577578/**579 * Push the SAS to the popup, then block until the popup responds via the580 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on581 * timeout or cancel so the adoption aborts before any vault state changes.582 *583 * The pending deferred MUST be installed before the push: a popup-side584 * auto-approver (test mocks, future automation) can synchronously resolve585 * the deferred the moment it sees the SAS push, and a missing slot would586 * drop that approval on the floor.587 */588function waitForSourceSasConfirmation(589  id: string,590  request: AdoptRequest,591  sas: string,592): Promise<boolean> {593  return new Promise<boolean>((resolve) => {594    const timer = setTimeout(() => {595      pendingSourceSasConfirmation.delete(id);596      resolve(false);597    }, SOURCE_WINDOW_MS);598    pendingSourceSasConfirmation.set(id, {599      resolve: (ok) => {600        clearTimeout(timer);601        resolve(ok);602      },603    });604    push("ADOPTION_SOURCE_SAS", {605      id,606      sas,607      receiverDeviceName: request.device_name,608    });609  });610}611612/**613 * Opening Add Device in an unlocked extension is the source-side authorization.614 * Announce the SAS for comparison, then let the scanning device's explicit615 * confirmation be the single commit gate. grantAdoption still refuses to send616 * the root key until the receiver returns ADOPT_CONFIRM(true).617 */618function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {619  return waitForSourceSasConfirmation(id, request, sas);620}621622function clearState(): void {623  activeSocket = null;624  inFlight = false;625  pendingSourceSasConfirmation.clear();626}627628function constantTimeEqual(a: string, b: string): boolean {629  if (a.length !== b.length) return false;630  let diff = 0;631  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);632  return diff === 0;633}634635function encodeJson(value: unknown): Uint8Array {636  return new TextEncoder().encode(JSON.stringify(value));637}638639function decodeJson<T>(value: Uint8Array): T {640  return JSON.parse(new TextDecoder().decode(value)) as T;641}642643function hexToBytes(hex: string): Uint8Array {644  const out = new Uint8Array(hex.length / 2);645  for (let i = 0; i < out.length; i++) {646    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);647  }648  return out;649}