relay-adoption-source-bg.ts

22.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 {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");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 encoded = createPairingInvitation(relayUrl, token, "empty");164    const invitation = parsePairingInvitation(encoded);165    if (!invitation) throw new Error("invitation_generation_failed");166    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));167    activeSocket = socket;168    void runReceiverSession(169      socket,170      token,171      relayUrl,172      password,173    ).finally(clearState);174    return { invitation: encoded };175  } catch (error) {176    clearState();177    throw error;178  }179}180181/**182 * Resolves the source-side SAS confirmation by id. Called by the message183 * handler when the popup taps Confirm/Cancel on the source's SAS screen.184 * Returns true if the id matched an in-flight confirmation. Unknown ids185 * (already resolved, timed out, or never existed) are a no-op so a late186 * popup click cannot crash anything.187 */188export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {189  const pending = pendingSourceSasConfirmation.get(id);190  if (!pending) return false;191  pendingSourceSasConfirmation.delete(id);192  pending.resolve(ok);193  return true;194}195196async function runSourceSession(197  socket: PairingSocket,198  expectedToken: string,199  relayUrl: string,200  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,201): Promise<void> {202  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);203  let noise: NoiseNKSession | undefined;204  try {205    const hello = decodeJson<{ type?: unknown; token?: unknown }>(206      await socket.receive(),207    );208    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||209        !constantTimeEqual(hello.token, expectedToken)) {210      throw new Error("invalid_invitation");211    }212    socket.send(encodeJson({213      type: "PEACH_HELLO_ACK",214      ephemeralPublicKeyHex: bytesToHex(local.publicKey),215    }));216217    const session = await noiseNKResponder(218      local.privateKey,219      local.publicKey,220      async (bytes) => socket.send(bytes),221      () => socket.receive(),222    );223    noise = session;224    const channel: PairingTransport = {225      async send(plaintext) { socket.send(session.encrypt(plaintext)); },226      async receive() { return session.decrypt(await socket.receive()); },227    };228    const request = await receiveFrame(channel);229    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");230231    const device = await grantAdoption(channel, request, {232      sourceIdentity: local.identity,233      pairState: {234        rendezvousSecretHex: bytesToHex(235          deriveRendezvousSecret(session.handshakeHash),236        ),237        relayUrl,238      },239      async rootKeyHex() {240        // grantAdoption calls this only after local password approval and the241        // receiver's encrypted ADOPT_CONFIRM(true).242        const value = getRootKeyHex();243        if (!value) throw new Error("vault_locked");244        return value;245      },246      async vaultId() {247        const value = getRootKeyHex();248        if (!value) throw new Error("vault_locked");249        const bytes = hexToBytes(value);250        try {251          return await computeVaultId(bytes);252        } finally {253          bytes.fill(0);254        }255      },256      async license() {257        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;258      },259      async applyLicense(license) {260        await applyTransferredLicense(license);261      },262      onConsent: (adoptRequest) => requestConsent(263        adoptRequest,264        derivePairingSAS(session.handshakeHash),265        crypto.randomUUID(),266      ),267      upsertPairedDevice,268    });269    if (!device) {270      push("ADOPTION_SOURCE_RESULT", {271        status: "error",272        error: "adoption_rejected",273      });274      return;275    }276    // If the receiver advertised inline-snapshot support, send the vault now277    // over the same authenticated channel. Best-effort: any failure leaves the278    // receiver to fall back to the sync pipeline triggered below.279    if (request.snapshot_capable === true) {280      try {281        const adapter = createSyncVaultAdapter();282        const entryManifest = await adapter.buildEntryManifest();283        const entryIds = Object.keys(entryManifest);284        const entries =285          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};286        const tombstones = await adapter.buildTombstoneManifest();287        let duressSlot: string | null = null;288        try {289          duressSlot = await exportDuressSlotForAdoption();290        } catch {291          // Duress omission must not fail the whole snapshot.292        }293        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);294295        const applied = await withTimeout(receiveFrame(channel), 15_000);296        if (isAdoptSnapshotApplied(applied)) {297          console.info(298            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,299          );300        }301302        if (adapter.buildOutgoingDeviceManifest) {303          const manifest = await adapter.buildOutgoingDeviceManifest();304          if (manifest) {305            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });306            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);307          }308        }309      } catch (snapshotError) {310        console.info(311          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",312          snapshotError,313        );314      }315    }316    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });317    // The Android receiver starts its relay listener as soon as its source318    // record is durable. Immediate best-effort trigger; alarm-based retries319    // cover MV3 service-worker death (setTimeout does not survive it).320    void triggerSyncIfReachable({ reason: "manual", force: true });321    schedulePostAdoptionSync();322  } catch (error) {323    push("ADOPTION_SOURCE_RESULT", {324      status: "error",325      error: error instanceof Error ? error.message : "adoption_failed",326    });327  } finally {328    clearTimeout(timeout);329    noise?.close();330    socket.close();331    pendingSourceSasConfirmation.clear();332  }333}334335/**336 * Browser receives the vault from the phone. The protocol is the mirror of337 * the source side: phone is the Noise initiator and the source of truth for338 * the SAS gate, browser is the responder that creates the vault locally.339 */340async function runReceiverSession(341  socket: PairingSocket,342  expectedToken: string,343  relayUrl: string,344  password: string,345): Promise<void> {346  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);347  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.348  const ephemeral = generateKeyPair();349  const ephemeralPrivate = ephemeral.secretKey;350  const ephemeralPublic = ephemeral.publicKey;351  let noise: NoiseNKSession | undefined;352  let createdVault = false;353  let protocolError: string | undefined;354  try {355    const hello = decodeJson<{ type?: unknown; token?: unknown }>(356      await socket.receive(),357    );358    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||359        !constantTimeEqual(hello.token, expectedToken)) {360      throw new Error("invalid_invitation");361    }362    socket.send(encodeJson({363      type: "PEACH_HELLO_ACK",364      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),365    }));366367    const session = await noiseNKResponder(368      ephemeralPrivate,369      ephemeralPublic,370      async (bytes) => socket.send(bytes),371      () => socket.receive(),372    );373    noise = session;374    const channel: PairingTransport = {375      async send(plaintext) { socket.send(session.encrypt(plaintext)); },376      async receive() { return session.decrypt(await socket.receive()); },377    };378379    const deviceName = await getDeviceName();380381    const device = await requestAdoption(channel, {382      deviceName,383      generateDeviceId: () => crypto.randomUUID(),384      // Auto-accept the SAS: both sides derive it from the same Noise385      // handshake hash, so the codes match by construction. Requiring the user386      // to confirm on both sides creates a race where the phone can time out387      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The388      // phone's onConsent is the single user gate; this side just proceeds.389      // Still show the locally derived code for visual verification.390      async confirmSas() {391        push("ADOPTION_SOURCE_SAS", {392          sas: derivePairingSAS(session.handshakeHash),393          displayOnly: true,394        });395        return true;396      },397      async createVaultFromRootKey(rootKeyHex) {398        // Direct call, not chrome.runtime.sendMessage — a service worker399        // calling sendMessage does not reach its own listener (returns400        // undefined), and the receiver already lives in the same SW context401        // as the vault module. Round-tripping through the message bus was402        // the root cause of "create_vault_no_response".403        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);404        createdVault = true;405        return { vaultId };406      },407      async applyLicense(license) {408        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self409        // issue as createVaultFromRootKey. SW can't reach its own listener.410        await applyTransferredLicense(license);411      },412      async license() {413        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;414      },415      async deriveLocalIdentity() {416        // Direct call for the same reason as applyLicense above.417        const masterKeyHex = getRootKeyHex();418        if (!masterKeyHex) throw new Error("identity_unavailable");419        const masterKey = hexToBytes(masterKeyHex);420        try {421          return await buildLocalIdentity(masterKey);422        } finally {423          masterKey.fill(0);424        }425      },426      async upsertPairedDevice(paired) {427        // Direct call for the same reason as applyLicense above.428        await upsertPairedDevice(paired);429      },430      pairState: {431        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),432        relayUrl,433      },434      onError(error) {435        protocolError = error;436      },437    });438439    if (!device) {440      if (createdVault) {441        await rollbackAdoptedVault();442        createdVault = false;443      }444      push("ADOPTION_SOURCE_RESULT", {445        status: "error",446        error: protocolError ?? "adoption_rejected",447      });448      return;449    }450    // The pairing channel is still open and authenticated — try to pull the451    // vault snapshot inline so the popup transitions immediately instead of452    // waiting on a second sync round. Any failure (older source, timeout,453    // corrupt chunk) falls through to the existing sync pipeline below.454    let snapshotApplied = false;455    try {456      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);457      const adapter = createSyncVaultAdapter();458      await adapter.applyEntries(snapshot.entries);459      await adapter.applyTombstones(snapshot.tombstones);460      // Duress install is isolated: a failure here must NOT demote an461      // otherwise-successful entry snapshot to the sync fallback.462      if (snapshot.duressSlot) {463        try {464          await installAdoptedDuressSlot(snapshot.duressSlot);465        } catch (duressError) {466          console.info(467            "[Peach] adopted duress slot install failed (non-fatal):",468            duressError,469          );470        }471      }472      await sendFrame(channel, {473        type: "ADOPT_SNAPSHOT_APPLIED",474        entries_received: Object.keys(snapshot.entries).length,475        tombstones_received: Object.keys(snapshot.tombstones).length,476      });477478      // Optional device graph. Non-fatal and isolated so a manifest timeout or479      // unsupported source cannot demote an otherwise-successful entry snapshot.480      try {481        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);482        if (483          isAdoptDeviceManifestFrame(manifestFrame) &&484          adapter.applyIncomingDeviceManifest &&485          typeof device.longTermPublicKeyHex === "string" &&486          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)487        ) {488          await adapter.applyIncomingDeviceManifest(489            manifestFrame.manifest,490            hexToBytes(device.longTermPublicKeyHex),491          );492          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });493        }494      } catch {495        // The graph arrives via incremental sync instead.496      }497498      await markLastSyncedAt(device.deviceId, Date.now());499      snapshotApplied = true;500    } catch (snapshotError) {501      console.info(502        "[Peach] inline snapshot failed, falling back to sync pipeline:",503        snapshotError,504      );505    }506507    // Adoption has committed identity and vault-key material, but entries have508    // not necessarily crossed yet. Persist the exact source row before telling509    // the popup pairing is complete so a popup close/reopen cannot reveal the510    // temporarily empty receiver vault. The popup clears this only after that511    // row records a successful sync.512    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(513      () => undefined,514    );515    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });516    createdVault = false;517518    if (snapshotApplied) {519      // The snapshot populated the vault and marked the source synced, so the520      // pending marker's job is done — clear it instead of making the popup wait.521      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);522    } else {523      // Fallback: the existing sync pipeline fills the vault. Immediate524      // best-effort trigger plus alarm-based retries that survive SW death.525      void triggerSyncIfReachable({ reason: "manual", force: true });526      schedulePostAdoptionSync();527    }528  } catch (error) {529    if (createdVault) {530      try {531        await rollbackAdoptedVault();532        createdVault = false;533      } catch (rollbackError) {534        console.error("[Peach] rollback failed:", rollbackError);535      }536    }537    console.error("[Peach] receiver session failed:", error);538    push("ADOPTION_SOURCE_RESULT", {539      status: "error",540      error: error instanceof Error ? error.message : "adoption_failed",541    });542  } finally {543    clearTimeout(timeout);544    noise?.close();545    socket.close();546    ephemeralPrivate.fill(0);547    ephemeralPublic.fill(0);548  }549}550551/**552 * A transferred entitlement is committed only when activation succeeds and553 * the stored value can immediately be re-read as Pro. activateLicense returns554 * a normal Free result for invalid input, so callers must not ignore it.555 */556async function applyTransferredLicense(license: string): Promise<void> {557  const activated = await activateLicense(license);558  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {559    throw new Error("license_transfer_failed");560  }561}562563/**564 * Push the SAS to the popup, then block until the popup responds via the565 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on566 * timeout or cancel so the adoption aborts before any vault state changes.567 *568 * The pending deferred MUST be installed before the push: a popup-side569 * auto-approver (test mocks, future automation) can synchronously resolve570 * the deferred the moment it sees the SAS push, and a missing slot would571 * drop that approval on the floor.572 */573function waitForSourceSasConfirmation(574  id: string,575  request: AdoptRequest,576  sas: string,577): Promise<boolean> {578  return new Promise<boolean>((resolve) => {579    const timer = setTimeout(() => {580      pendingSourceSasConfirmation.delete(id);581      resolve(false);582    }, SOURCE_WINDOW_MS);583    pendingSourceSasConfirmation.set(id, {584      resolve: (ok) => {585        clearTimeout(timer);586        resolve(ok);587      },588    });589    push("ADOPTION_SOURCE_SAS", {590      id,591      sas,592      receiverDeviceName: request.device_name,593    });594  });595}596597/**598 * Opening Add Device in an unlocked extension is the source-side authorization.599 * Announce the SAS for comparison, then let the scanning device's explicit600 * confirmation be the single commit gate. grantAdoption still refuses to send601 * the root key until the receiver returns ADOPT_CONFIRM(true).602 */603function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {604  return waitForSourceSasConfirmation(id, request, sas);605}606607function clearState(): void {608  activeSocket = null;609  inFlight = false;610  pendingSourceSasConfirmation.clear();611}612613function constantTimeEqual(a: string, b: string): boolean {614  if (a.length !== b.length) return false;615  let diff = 0;616  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);617  return diff === 0;618}619620function encodeJson(value: unknown): Uint8Array {621  return new TextEncoder().encode(JSON.stringify(value));622}623624function decodeJson<T>(value: Uint8Array): T {625  return JSON.parse(new TextDecoder().decode(value)) as T;626}627628function hexToBytes(hex: string): Uint8Array {629  const out = new Uint8Array(hex.length / 2);630  for (let i = 0; i < out.length; i++) {631    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);632  }633  return out;634}