relay-adoption-source-bg.ts

21.7 KB · raw

1import { generateKeyPair } from "@stablelib/x25519";2import {3  createPairingInvitation,4  pairingInvitationUrl,5  parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8  openRelayPairingSocket,9  type PairingSocket,10} from "../shared/pairing-dial";11import {12  noiseNKResponder,13  type NoiseNKSession,14} from "../shared/noise-js";15import {16  grantAdoption,17  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, getRootKeyHex, 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";4647const SOURCE_WINDOW_MS = 5 * 60 * 1000;4849function push(type: string, payload: unknown): void {50  try {51    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;52    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {53      (maybe as Promise<unknown>).catch(() => undefined);54    }55  } catch {56    // The popup may have closed. The pending approval fails closed on cleanup.57  }58}5960/** Race a promise against a wall-clock deadline; rejects with "timeout". */61function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {62  return new Promise<T>((resolve, reject) => {63    const timer = setTimeout(() => reject(new Error("timeout")), ms);64    promise.then(65      (value) => {66        clearTimeout(timer);67        resolve(value);68      },69      (error) => {70        clearTimeout(timer);71        reject(error);72      },73    );74  });75}7677let inFlight = false;78let activeSocket: PairingSocket | null = null;79// Outstanding source-side SAS confirmations, keyed by id. A map (vs the80// receiver's single slot) makes a stale decision from a previous source81// session impossible to land on the current one even if both run on the82// same background worker.83const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();8485export function isRelayAdoptionSourceInFlight(): boolean {86  return inFlight;87}8889/**90 * Occupy the non-buffering room before returning the invitation to the UI.91 * All long-term key material remains in the background service worker.92 */93export async function startRelayAdoptionSource(): Promise<{94  invitation: string;95}> {96  if (inFlight) throw new Error("adoption_source_in_progress");97  if (!isUnlocked()) throw new Error("vault_locked");98  inFlight = true;99100  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;101  try {102    const settings = await getSyncSettings();103    const relayUrl = relayInForce(settings);104    local = await getLocalPairingIdentity();105    const token = generatePairingRendezvousToken();106    // Source side has the vault — caller checked isUnlocked — so the phone107    // can read the invitation and know it should receive the vault.108    const encoded = createPairingInvitation(relayUrl, token, "populated");109    const invitation = parsePairingInvitation(encoded);110    if (!invitation) throw new Error("invitation_generation_failed");111112    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));113    activeSocket = socket;114    const readyLocal = local;115    void runSourceSession(116      socket,117      token,118      relayUrl,119      readyLocal,120    ).finally(() => {121      readyLocal.privateKey.fill(0);122      readyLocal.publicKey.fill(0);123      clearState();124    });125    return { invitation: encoded };126  } catch (error) {127    local?.privateKey.fill(0);128    local?.publicKey.fill(0);129    clearState();130    throw error;131  }132}133134export function cancelRelayAdoptionSource(): void {135  activeSocket?.close();136  clearState();137}138139/**140 * Onboarding pairing request — the empty extension shows a QR and the populated141 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the142 * phone reads to switch into source mode and `grantAdoption` over the143 * established Noise channel. The receiver's `runReceiverSession` accepts the144 * root key and creates a local vault wrapped with the popup-supplied password.145 *146 * `password` is the local-only password the user picked on the "Set a password147 * for this device" screen; it wraps the adopted root key at rest and has no148 * relationship to the source device's password.149 */150export async function startRelayAdoptionReceiver(151  password: string,152): Promise<{ invitation: string }> {153  if (inFlight) throw new Error("adoption_receiver_in_progress");154  if (typeof password !== "string" || password.length === 0) {155    throw new Error("ADOPT_INVALID_PASSWORD");156  }157  inFlight = true;158  try {159    const settings = await getSyncSettings();160    const relayUrl = relayInForce(settings);161    const token = generatePairingRendezvousToken();162    const encoded = createPairingInvitation(relayUrl, token, "empty");163    const invitation = parsePairingInvitation(encoded);164    if (!invitation) throw new Error("invitation_generation_failed");165    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));166    activeSocket = socket;167    void runReceiverSession(168      socket,169      token,170      relayUrl,171      password,172    ).finally(clearState);173    return { invitation: encoded };174  } catch (error) {175    clearState();176    throw error;177  }178}179180/**181 * Resolves the source-side SAS confirmation by id. Called by the message182 * handler when the popup taps Confirm/Cancel on the source's SAS screen.183 * Returns true if the id matched an in-flight confirmation. Unknown ids184 * (already resolved, timed out, or never existed) are a no-op so a late185 * popup click cannot crash anything.186 */187export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {188  const pending = pendingSourceSasConfirmation.get(id);189  if (!pending) return false;190  pendingSourceSasConfirmation.delete(id);191  pending.resolve(ok);192  return true;193}194195async function runSourceSession(196  socket: PairingSocket,197  expectedToken: string,198  relayUrl: string,199  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,200): Promise<void> {201  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);202  let noise: NoiseNKSession | undefined;203  try {204    const hello = decodeJson<{ type?: unknown; token?: unknown }>(205      await socket.receive(),206    );207    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||208        !constantTimeEqual(hello.token, expectedToken)) {209      throw new Error("invalid_invitation");210    }211    socket.send(encodeJson({212      type: "PEACH_HELLO_ACK",213      ephemeralPublicKeyHex: bytesToHex(local.publicKey),214    }));215216    const session = await noiseNKResponder(217      local.privateKey,218      local.publicKey,219      async (bytes) => socket.send(bytes),220      () => socket.receive(),221    );222    noise = session;223    const channel: PairingTransport = {224      async send(plaintext) { socket.send(session.encrypt(plaintext)); },225      async receive() { return session.decrypt(await socket.receive()); },226    };227    const request = await receiveFrame(channel);228    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");229230    const device = await grantAdoption(channel, request, {231      sourceIdentity: local.identity,232      pairState: {233        rendezvousSecretHex: bytesToHex(234          deriveRendezvousSecret(session.handshakeHash),235        ),236        relayUrl,237      },238      async rootKeyHex() {239        // grantAdoption calls this only after local password approval and the240        // receiver's encrypted ADOPT_CONFIRM(true).241        const value = getRootKeyHex();242        if (!value) throw new Error("vault_locked");243        return value;244      },245      async vaultId() {246        const value = getRootKeyHex();247        if (!value) throw new Error("vault_locked");248        const bytes = hexToBytes(value);249        try {250          return await computeVaultId(bytes);251        } finally {252          bytes.fill(0);253        }254      },255      async license() {256        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;257      },258      async applyLicense(license) {259        await applyTransferredLicense(license);260      },261      onConsent: (adoptRequest) => requestConsent(262        adoptRequest,263        derivePairingSAS(session.handshakeHash),264        crypto.randomUUID(),265      ),266      upsertPairedDevice,267    });268    if (!device) {269      push("ADOPTION_SOURCE_RESULT", {270        status: "error",271        error: "adoption_rejected",272      });273      return;274    }275    // If the receiver advertised inline-snapshot support, send the vault now276    // over the same authenticated channel. Best-effort: any failure leaves the277    // receiver to fall back to the sync pipeline triggered below.278    if (request.snapshot_capable === true) {279      try {280        const adapter = createSyncVaultAdapter();281        const entryManifest = await adapter.buildEntryManifest();282        const entryIds = Object.keys(entryManifest);283        const entries =284          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};285        const tombstones = await adapter.buildTombstoneManifest();286        await sendVaultSnapshot(channel, entries, tombstones);287288        const applied = await withTimeout(receiveFrame(channel), 15_000);289        if (isAdoptSnapshotApplied(applied)) {290          console.info(291            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,292          );293        }294295        if (adapter.buildOutgoingDeviceManifest) {296          const manifest = await adapter.buildOutgoingDeviceManifest();297          if (manifest) {298            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });299            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);300          }301        }302      } catch (snapshotError) {303        console.info(304          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",305          snapshotError,306        );307      }308    }309    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });310    // The Android receiver starts its relay listener as soon as its source311    // record is durable. Try once now and once after service startup settles so312    // the newly adopted empty vault receives its first sync without another313    // user action. Both calls are best-effort and contain no user data.314    void triggerSyncIfReachable({ reason: "manual", force: true });315    setTimeout(() => {316      void triggerSyncIfReachable({ reason: "manual", force: true });317    }, 1_000);318  } catch (error) {319    push("ADOPTION_SOURCE_RESULT", {320      status: "error",321      error: error instanceof Error ? error.message : "adoption_failed",322    });323  } finally {324    clearTimeout(timeout);325    noise?.close();326    socket.close();327    pendingSourceSasConfirmation.clear();328  }329}330331/**332 * Browser receives the vault from the phone. The protocol is the mirror of333 * the source side: phone is the Noise initiator and the source of truth for334 * the SAS gate, browser is the responder that creates the vault locally.335 */336async function runReceiverSession(337  socket: PairingSocket,338  expectedToken: string,339  relayUrl: string,340  password: string,341): Promise<void> {342  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);343  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.344  const ephemeral = generateKeyPair();345  const ephemeralPrivate = ephemeral.secretKey;346  const ephemeralPublic = ephemeral.publicKey;347  let noise: NoiseNKSession | undefined;348  let createdVault = false;349  let protocolError: string | undefined;350  try {351    const hello = decodeJson<{ type?: unknown; token?: unknown }>(352      await socket.receive(),353    );354    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||355        !constantTimeEqual(hello.token, expectedToken)) {356      throw new Error("invalid_invitation");357    }358    socket.send(encodeJson({359      type: "PEACH_HELLO_ACK",360      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),361    }));362363    const session = await noiseNKResponder(364      ephemeralPrivate,365      ephemeralPublic,366      async (bytes) => socket.send(bytes),367      () => socket.receive(),368    );369    noise = session;370    const channel: PairingTransport = {371      async send(plaintext) { socket.send(session.encrypt(plaintext)); },372      async receive() { return session.decrypt(await socket.receive()); },373    };374375    const deviceName = await getDeviceName();376377    const device = await requestAdoption(channel, {378      deviceName,379      generateDeviceId: () => crypto.randomUUID(),380      // Auto-accept the SAS: both sides derive it from the same Noise381      // handshake hash, so the codes match by construction. Requiring the user382      // to confirm on both sides creates a race where the phone can time out383      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The384      // phone's onConsent is the single user gate; this side just proceeds.385      // Still show the locally derived code for visual verification.386      async confirmSas() {387        push("ADOPTION_SOURCE_SAS", {388          sas: derivePairingSAS(session.handshakeHash),389          displayOnly: true,390        });391        return true;392      },393      async createVaultFromRootKey(rootKeyHex) {394        // Direct call, not chrome.runtime.sendMessage — a service worker395        // calling sendMessage does not reach its own listener (returns396        // undefined), and the receiver already lives in the same SW context397        // as the vault module. Round-tripping through the message bus was398        // the root cause of "create_vault_no_response".399        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);400        createdVault = true;401        return { vaultId };402      },403      async applyLicense(license) {404        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self405        // issue as createVaultFromRootKey. SW can't reach its own listener.406        await applyTransferredLicense(license);407      },408      async license() {409        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;410      },411      async deriveLocalIdentity() {412        // Direct call for the same reason as applyLicense above.413        const masterKeyHex = getRootKeyHex();414        if (!masterKeyHex) throw new Error("identity_unavailable");415        const masterKey = hexToBytes(masterKeyHex);416        try {417          return await buildLocalIdentity(masterKey);418        } finally {419          masterKey.fill(0);420        }421      },422      async upsertPairedDevice(paired) {423        // Direct call for the same reason as applyLicense above.424        await upsertPairedDevice(paired);425      },426      pairState: {427        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),428        relayUrl,429      },430      onError(error) {431        protocolError = error;432      },433    });434435    if (!device) {436      if (createdVault) {437        await rollbackAdoptedVault();438        createdVault = false;439      }440      push("ADOPTION_SOURCE_RESULT", {441        status: "error",442        error: protocolError ?? "adoption_rejected",443      });444      return;445    }446    // The pairing channel is still open and authenticated — try to pull the447    // vault snapshot inline so the popup transitions immediately instead of448    // waiting on a second sync round. Any failure (older source, timeout,449    // corrupt chunk) falls through to the existing sync pipeline below.450    let snapshotApplied = false;451    try {452      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 15_000);453      const adapter = createSyncVaultAdapter();454      await adapter.applyEntries(snapshot.entries);455      await adapter.applyTombstones(snapshot.tombstones);456      await sendFrame(channel, {457        type: "ADOPT_SNAPSHOT_APPLIED",458        entries_received: Object.keys(snapshot.entries).length,459        tombstones_received: Object.keys(snapshot.tombstones).length,460      });461462      // Optional device graph. Non-fatal and isolated so a manifest timeout or463      // unsupported source cannot demote an otherwise-successful entry snapshot.464      try {465        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);466        if (467          isAdoptDeviceManifestFrame(manifestFrame) &&468          adapter.applyIncomingDeviceManifest &&469          typeof device.longTermPublicKeyHex === "string" &&470          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)471        ) {472          await adapter.applyIncomingDeviceManifest(473            manifestFrame.manifest,474            hexToBytes(device.longTermPublicKeyHex),475          );476          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });477        }478      } catch {479        // The graph arrives via incremental sync instead.480      }481482      await markLastSyncedAt(device.deviceId, Date.now());483      snapshotApplied = true;484    } catch (snapshotError) {485      console.info(486        "[Peach] inline snapshot failed, falling back to sync pipeline:",487        snapshotError,488      );489    }490491    // Adoption has committed identity and vault-key material, but entries have492    // not necessarily crossed yet. Persist the exact source row before telling493    // the popup pairing is complete so a popup close/reopen cannot reveal the494    // temporarily empty receiver vault. The popup clears this only after that495    // row records a successful sync.496    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(497      () => undefined,498    );499    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });500    createdVault = false;501502    if (snapshotApplied) {503      // The snapshot populated the vault and marked the source synced, so the504      // pending marker's job is done — clear it instead of making the popup wait.505      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);506    } else {507      // Fallback: the existing sync pipeline fills the vault.508      void triggerSyncIfReachable({ reason: "manual", force: true });509    }510  } catch (error) {511    if (createdVault) {512      try {513        await rollbackAdoptedVault();514        createdVault = false;515      } catch (rollbackError) {516        console.error("[Peach] rollback failed:", rollbackError);517      }518    }519    console.error("[Peach] receiver session failed:", error);520    push("ADOPTION_SOURCE_RESULT", {521      status: "error",522      error: error instanceof Error ? error.message : "adoption_failed",523    });524  } finally {525    clearTimeout(timeout);526    noise?.close();527    socket.close();528    ephemeralPrivate.fill(0);529    ephemeralPublic.fill(0);530  }531}532533/**534 * A transferred entitlement is committed only when activation succeeds and535 * the stored value can immediately be re-read as Pro. activateLicense returns536 * a normal Free result for invalid input, so callers must not ignore it.537 */538async function applyTransferredLicense(license: string): Promise<void> {539  const activated = await activateLicense(license);540  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {541    throw new Error("license_transfer_failed");542  }543}544545/**546 * Push the SAS to the popup, then block until the popup responds via the547 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on548 * timeout or cancel so the adoption aborts before any vault state changes.549 *550 * The pending deferred MUST be installed before the push: a popup-side551 * auto-approver (test mocks, future automation) can synchronously resolve552 * the deferred the moment it sees the SAS push, and a missing slot would553 * drop that approval on the floor.554 */555function waitForSourceSasConfirmation(556  id: string,557  request: AdoptRequest,558  sas: string,559): Promise<boolean> {560  return new Promise<boolean>((resolve) => {561    const timer = setTimeout(() => {562      pendingSourceSasConfirmation.delete(id);563      resolve(false);564    }, SOURCE_WINDOW_MS);565    pendingSourceSasConfirmation.set(id, {566      resolve: (ok) => {567        clearTimeout(timer);568        resolve(ok);569      },570    });571    push("ADOPTION_SOURCE_SAS", {572      id,573      sas,574      receiverDeviceName: request.device_name,575    });576  });577}578579/**580 * Opening Add Device in an unlocked extension is the source-side authorization.581 * Announce the SAS for comparison, then let the scanning device's explicit582 * confirmation be the single commit gate. grantAdoption still refuses to send583 * the root key until the receiver returns ADOPT_CONFIRM(true).584 */585function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {586  return waitForSourceSasConfirmation(id, request, sas);587}588589function clearState(): void {590  activeSocket = null;591  inFlight = false;592  pendingSourceSasConfirmation.clear();593}594595function constantTimeEqual(a: string, b: string): boolean {596  if (a.length !== b.length) return false;597  let diff = 0;598  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);599  return diff === 0;600}601602function encodeJson(value: unknown): Uint8Array {603  return new TextEncoder().encode(JSON.stringify(value));604}605606function decodeJson<T>(value: Uint8Array): T {607  return JSON.parse(new TextDecoder().decode(value)) as T;608}609610function hexToBytes(hex: string): Uint8Array {611  const out = new Uint8Array(hex.length / 2);612  for (let i = 0; i < out.length; i++) {613    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);614  }615  return out;616}