relay-adoption-bg.ts

23.9 KB · raw

1import { generateKeyPair } from "@stablelib/x25519";2import {3  createPairingInvitation,4  pairingInvitationUrl,5  parsePairingInvitation,6} from "../shared/pairing-invitation";7import {8  openRelayPairingSocket,9  type PairingSocket,10} from "../shared/pairing-dial";11import {12  noiseNKResponder,13  type NoiseNKSession,14} from "../shared/noise-js";15import {16  grantAdoption,17  isAdoptDeviceManifestFrame,18  isAdoptRequest,19  isAdoptSnapshotApplied,20  receiveFrame,21  receiveVaultSnapshot,22  requestAdoption,23  sendFrame,24  sendVaultSnapshot,25  type AdoptRequest,26} from "../shared/vault-adopt";27import {28  derivePairingSAS,29  type PairingTransport,30} from "../shared/pairing-handshake";31import type { PairedDevice } from "../shared/sync-types";32import {33  bytesToHex,34  deriveRendezvousSecret,35  generatePairingRendezvousToken,36} from "../shared/rendezvous";37import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";38import { getSyncSettings, relayInForce } from "./sync-settings";39import { activateLicense, getLicenseKey, isProUser } from "./license";40import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";41import { computeVaultId } from "../shared/sync-identity";42import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";43import { storageRemove, storageSet } from "../shared/storage";44import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";45import { isValidPairSecret, setPairSecret } from "./pair-secrets";46import { createSyncVaultAdapter } from "./sync-vault-adapter";47import { triggerSyncIfReachable } from "./sync-trigger";48import { schedulePostAdoptionSync } from "./sync-alarm";4950const SOURCE_WINDOW_MS = 5 * 60 * 1000;5152function push(type: string, payload: unknown): void {53  try {54    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;55    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {56      (maybe as Promise<unknown>).catch(() => undefined);57    }58  } catch {59    // The popup may have closed. The pending approval fails closed on cleanup.60  }61}6263/** Race a promise against a wall-clock deadline; rejects with "timeout". */64function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {65  return new Promise<T>((resolve, reject) => {66    const timer = setTimeout(() => reject(new Error("timeout")), ms);67    promise.then(68      (value) => {69        clearTimeout(timer);70        resolve(value);71      },72      (error) => {73        clearTimeout(timer);74        reject(error);75      },76    );77  });78}7980let inFlight = false;81let activeSocket: PairingSocket | null = null;82// Outstanding source-side SAS confirmations, keyed by id. A map (vs the83// receiver's single slot) makes a stale decision from a previous source84// session impossible to land on the current one even if both run on the85// same background worker.86const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();8788export function isRelayAdoptionSourceInFlight(): boolean {89  return inFlight;90}9192/**93 * Occupy the non-buffering room before returning the invitation to the UI.94 * All long-term key material remains in the background service worker.95 */96export async function startRelayAdoptionSource(): Promise<{97  invitation: string;98}> {99  if (inFlight) throw new Error("adoption_source_in_progress");100  if (!isUnlocked()) throw new Error("vault_locked");101  inFlight = true;102103  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;104  try {105    const settings = await getSyncSettings();106    const relayUrl = relayInForce(settings);107    local = await getLocalPairingIdentity();108    const token = generatePairingRendezvousToken();109    // Source side has the vault — caller checked isUnlocked — so the phone110    // can read the invitation and know it should receive the vault.111    const encoded = createPairingInvitation(relayUrl, token, "populated", bytesToHex(local.publicKey));112    const invitation = parsePairingInvitation(encoded);113    if (!invitation) throw new Error("invitation_generation_failed");114115    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));116    activeSocket = socket;117    const readyLocal = local;118    void runSourceSession(119      socket,120      token,121      relayUrl,122      readyLocal,123    ).finally(() => {124      readyLocal.privateKey.fill(0);125      readyLocal.publicKey.fill(0);126      clearState();127    });128    return { invitation: encoded };129  } catch (error) {130    local?.privateKey.fill(0);131    local?.publicKey.fill(0);132    clearState();133    throw error;134  }135}136137export function cancelRelayAdoptionSource(): void {138  activeSocket?.close();139  clearState();140}141142/**143 * Onboarding pairing request — the empty extension shows a QR and the populated144 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the145 * phone reads to switch into source mode and `grantAdoption` over the146 * established Noise channel. The receiver's `runReceiverSession` accepts the147 * root key and creates a local vault wrapped with the popup-supplied password.148 *149 * `password` is the local-only password the user picked on the "Set a password150 * for this device" screen; it wraps the adopted root key at rest and has no151 * relationship to the source device's password.152 */153export async function startRelayAdoptionReceiver(154  password: string,155): Promise<{ invitation: string }> {156  if (inFlight) throw new Error("adoption_receiver_in_progress");157  if (typeof password !== "string" || password.length === 0) {158    throw new Error("ADOPT_INVALID_PASSWORD");159  }160  inFlight = true;161  try {162    const settings = await getSyncSettings();163    const relayUrl = relayInForce(settings);164    const token = generatePairingRendezvousToken();165    const ephemeral = generateKeyPair();166    const encoded = createPairingInvitation(relayUrl, token, "empty", bytesToHex(ephemeral.publicKey));167    const invitation = parsePairingInvitation(encoded);168    if (!invitation) throw new Error("invitation_generation_failed");169    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));170    activeSocket = socket;171    void runReceiverSession(172      socket,173      token,174      relayUrl,175      password,176      ephemeral,177    ).finally(() => {178      ephemeral.secretKey.fill(0);179      ephemeral.publicKey.fill(0);180      clearState();181    });182    return { invitation: encoded };183  } catch (error) {184    clearState();185    throw error;186  }187}188189/**190 * Resolves the source-side SAS confirmation by id. Called by the message191 * handler when the popup taps Confirm/Cancel on the source's SAS screen.192 * Returns true if the id matched an in-flight confirmation. Unknown ids193 * (already resolved, timed out, or never existed) are a no-op so a late194 * popup click cannot crash anything.195 */196export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {197  const pending = pendingSourceSasConfirmation.get(id);198  if (!pending) return false;199  pendingSourceSasConfirmation.delete(id);200  pending.resolve(ok);201  return true;202}203204async function runSourceSession(205  socket: PairingSocket,206  expectedToken: string,207  relayUrl: string,208  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,209): Promise<void> {210  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);211  let noise: NoiseNKSession | undefined;212  try {213    const hello = decodeJson<{ type?: unknown; token?: unknown }>(214      await socket.receive(),215    );216    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||217        !constantTimeEqual(hello.token, expectedToken)) {218      throw new Error("invalid_invitation");219    }220    socket.send(encodeJson({221      type: "PEACH_HELLO_ACK",222      ephemeralPublicKeyHex: bytesToHex(local.publicKey),223    }));224225    const session = await noiseNKResponder(226      local.privateKey,227      local.publicKey,228      async (bytes) => socket.send(bytes),229      () => socket.receive(),230    );231    noise = session;232    const channel: PairingTransport = {233      async send(plaintext) { socket.send(session.encrypt(plaintext)); },234      async receive() { return session.decrypt(await socket.receive()); },235    };236    const request = await receiveFrame(channel);237    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");238239    const device = await grantAdoption(channel, request, {240      sourceIdentity: local.identity,241      pairState: {242        rendezvousSecretHex: bytesToHex(243          deriveRendezvousSecret(session.handshakeHash),244        ),245        relayUrl,246      },247      async rootKeyHex() {248        // grantAdoption calls this only after local password approval and the249        // receiver's encrypted ADOPT_CONFIRM(true).250        const value = getRootKeyHex();251        if (!value) throw new Error("vault_locked");252        return value;253      },254      async vaultId() {255        const value = getRootKeyHex();256        if (!value) throw new Error("vault_locked");257        const bytes = hexToBytes(value);258        try {259          return await computeVaultId(bytes);260        } finally {261          bytes.fill(0);262        }263      },264      async license() {265        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;266      },267      async applyLicense(license) {268        await applyTransferredLicense(license);269      },270      onConsent: (adoptRequest) => requestConsent(271        adoptRequest,272        derivePairingSAS(session.handshakeHash),273        crypto.randomUUID(),274      ),275      upsertPairedDevice: storeAdoptedDevice,276    });277    if (!device) {278      push("ADOPTION_SOURCE_RESULT", {279        status: "error",280        error: "adoption_rejected",281      });282      return;283    }284    // If the receiver advertised inline-snapshot support, send the vault now285    // over the same authenticated channel. Best-effort: any failure leaves the286    // receiver to fall back to the sync pipeline triggered below.287    if (request.snapshot_capable === true) {288      try {289        const adapter = createSyncVaultAdapter();290        const entryManifest = await adapter.buildEntryManifest();291        const entryIds = Object.keys(entryManifest);292        const entries =293          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};294        const tombstones = await adapter.buildTombstoneManifest();295        let duressSlot: string | null = null;296        try {297          duressSlot = await exportDuressSlotForAdoption();298        } catch {299          // Duress omission must not fail the whole snapshot.300        }301        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);302303        const applied = await withTimeout(receiveFrame(channel), 15_000);304        if (isAdoptSnapshotApplied(applied)) {305          console.info(306            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,307          );308        }309310        if (adapter.buildOutgoingDeviceManifest) {311          const manifest = await adapter.buildOutgoingDeviceManifest();312          if (manifest) {313            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });314            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);315          }316        }317      } catch (snapshotError) {318        if (319          snapshotError instanceof Error &&320          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"321        ) {322          throw snapshotError;323        }324        console.info(325          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",326          snapshotError,327        );328      }329    }330    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });331    // The Android receiver starts its relay listener as soon as its source332    // record is durable. Immediate best-effort trigger; alarm-based retries333    // cover MV3 service-worker death (setTimeout does not survive it).334    void triggerSyncIfReachable({ reason: "manual", force: true });335    schedulePostAdoptionSync();336  } catch (error) {337    push("ADOPTION_SOURCE_RESULT", {338      status: "error",339      error: error instanceof Error ? error.message : "adoption_failed",340    });341  } finally {342    clearTimeout(timeout);343    noise?.close();344    socket.close();345    pendingSourceSasConfirmation.clear();346  }347}348349/**350 * Browser receives the vault from the phone. The protocol is the mirror of351 * the source side: phone is the Noise initiator and the source of truth for352 * the SAS gate, browser is the responder that creates the vault locally.353 */354async function runReceiverSession(355  socket: PairingSocket,356  expectedToken: string,357  relayUrl: string,358  password: string,359  invitationEphemeral: { secretKey: Uint8Array; publicKey: Uint8Array },360): Promise<void> {361  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);362  // The invitation binds the responder static so the initiator can verify the ACK.363  const ephemeral = invitationEphemeral;364  const ephemeralPrivate = ephemeral.secretKey;365  const ephemeralPublic = ephemeral.publicKey;366  let noise: NoiseNKSession | undefined;367  let createdVault = false;368  let protocolError: string | undefined;369  try {370    const hello = decodeJson<{ type?: unknown; token?: unknown }>(371      await socket.receive(),372    );373    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||374        !constantTimeEqual(hello.token, expectedToken)) {375      throw new Error("invalid_invitation");376    }377    socket.send(encodeJson({378      type: "PEACH_HELLO_ACK",379      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),380    }));381382    const session = await noiseNKResponder(383      ephemeralPrivate,384      ephemeralPublic,385      async (bytes) => socket.send(bytes),386      () => socket.receive(),387    );388    noise = session;389    const channel: PairingTransport = {390      async send(plaintext) { socket.send(session.encrypt(plaintext)); },391      async receive() { return session.decrypt(await socket.receive()); },392    };393394    const deviceName = await getDeviceName();395396    const adoptionResult = await requestAdoption(channel, {397      deviceName,398      generateDeviceId: () => crypto.randomUUID(),399      // Auto-accept the SAS: both sides derive it from the same Noise400      // handshake hash, so the codes match by construction. Requiring the user401      // to confirm on both sides creates a race where the phone can time out402      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The403      // phone's onConsent is the single user gate; this side just proceeds.404      // Still show the locally derived code for visual verification.405      async confirmSas() {406        push("ADOPTION_SOURCE_SAS", {407          sas: derivePairingSAS(session.handshakeHash),408          displayOnly: true,409        });410        return true;411      },412      async createVaultFromRootKey(rootKeyHex) {413        // Direct call, not chrome.runtime.sendMessage — a service worker414        // calling sendMessage does not reach its own listener (returns415        // undefined), and the receiver already lives in the same SW context416        // as the vault module. Round-tripping through the message bus was417        // the root cause of "create_vault_no_response".418        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);419        createdVault = true;420        return { vaultId };421      },422      async applyLicense(license) {423        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self424        // issue as createVaultFromRootKey. SW can't reach its own listener.425        await applyTransferredLicense(license);426      },427      async license() {428        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;429      },430      async deriveLocalIdentity() {431        // Direct call for the same reason as applyLicense above.432        const masterKeyHex = getRootKeyHex();433        if (!masterKeyHex) throw new Error("identity_unavailable");434        const masterKey = hexToBytes(masterKeyHex);435        try {436          return await buildLocalIdentity(masterKey);437        } finally {438          masterKey.fill(0);439        }440      },441      async upsertPairedDevice(paired) {442        // Direct call for the same reason as applyLicense above.443        await storeAdoptedDevice(paired);444      },445      pairState: {446        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),447        relayUrl,448      },449      onError(error) {450        protocolError = error;451      },452    });453454    if (!adoptionResult) {455      if (createdVault) {456        await rollbackAdoptedVault();457        createdVault = false;458      }459      push("ADOPTION_SOURCE_RESULT", {460        status: "error",461        error: protocolError ?? "adoption_rejected",462      });463      return;464    }465    const { device, snapshotCapable } = adoptionResult;466    // The pairing channel is still open and authenticated — try to pull the467    // vault snapshot inline so the popup transitions immediately instead of468    // waiting on a second sync round. Skip the wait entirely when the source469    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);470    // fall through to the existing sync pipeline below.471    let snapshotApplied = false;472    if (snapshotCapable) try {473      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);474      const adapter = createSyncVaultAdapter();475      await adapter.applyEntries(snapshot.entries);476      await adapter.applyTombstones(snapshot.tombstones);477      // Duress install is isolated: a failure here must NOT demote an478      // otherwise-successful entry snapshot to the sync fallback.479      if (snapshot.duressSlot) {480        try {481          await installAdoptedDuressSlot(snapshot.duressSlot);482        } catch (duressError) {483          console.info(484            "[Peach] adopted duress slot install failed (non-fatal):",485            duressError,486          );487        }488      }489      await sendFrame(channel, {490        type: "ADOPT_SNAPSHOT_APPLIED",491        entries_received: Object.keys(snapshot.entries).length,492        tombstones_received: Object.keys(snapshot.tombstones).length,493      });494495      // Optional device graph. Non-fatal and isolated so a manifest timeout or496      // unsupported source cannot demote an otherwise-successful entry snapshot.497      try {498        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);499        if (500          isAdoptDeviceManifestFrame(manifestFrame) &&501          adapter.applyIncomingDeviceManifest &&502          typeof device.longTermPublicKeyHex === "string" &&503          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)504        ) {505          await adapter.applyIncomingDeviceManifest(506            manifestFrame.manifest,507            hexToBytes(device.longTermPublicKeyHex),508          );509          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });510        }511      } catch {512        // The graph arrives via incremental sync instead.513      }514515      await markLastSyncedAt(device.deviceId, Date.now());516      snapshotApplied = true;517    } catch (snapshotError) {518      console.info(519        "[Peach] inline snapshot failed, falling back to sync pipeline:",520        snapshotError,521      );522    }523524    // Adoption has committed identity and vault-key material, but entries have525    // not necessarily crossed yet. Persist the exact source row before telling526    // the popup pairing is complete so a popup close/reopen cannot reveal the527    // temporarily empty receiver vault. The popup clears this only after that528    // row records a successful sync.529    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(530      () => undefined,531    );532    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });533    createdVault = false;534535    if (snapshotApplied) {536      // The snapshot populated the vault and marked the source synced, so the537      // pending marker's job is done — clear it instead of making the popup wait.538      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);539    } else {540      // Fallback: the existing sync pipeline fills the vault. Immediate541      // best-effort trigger plus alarm-based retries that survive SW death.542      void triggerSyncIfReachable({ reason: "manual", force: true });543      schedulePostAdoptionSync();544    }545  } catch (error) {546    if (createdVault) {547      try {548        await rollbackAdoptedVault();549        createdVault = false;550      } catch (rollbackError) {551        console.error("[Peach] rollback failed:", rollbackError);552      }553    }554    console.error("[Peach] receiver session failed:", error);555    push("ADOPTION_SOURCE_RESULT", {556      status: "error",557      error: error instanceof Error ? error.message : "adoption_failed",558    });559  } finally {560    clearTimeout(timeout);561    noise?.close();562    socket.close();563    ephemeralPrivate.fill(0);564    ephemeralPublic.fill(0);565  }566}567568/**569 * A transferred entitlement is committed only when activation succeeds and570 * the stored value can immediately be re-read as Pro. activateLicense returns571 * a normal Free result for invalid input, so callers must not ignore it.572 */573async function applyTransferredLicense(license: string): Promise<void> {574  const activated = await activateLicense(license);575  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {576    throw new Error("license_transfer_failed");577  }578}579580/**581 * Push the SAS to the popup, then block until the popup responds via the582 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on583 * timeout or cancel so the adoption aborts before any vault state changes.584 *585 * The pending deferred MUST be installed before the push: a popup-side586 * auto-approver (test mocks, future automation) can synchronously resolve587 * the deferred the moment it sees the SAS push, and a missing slot would588 * drop that approval on the floor.589 */590function waitForSourceSasConfirmation(591  id: string,592  request: AdoptRequest,593  sas: string,594): Promise<boolean> {595  return new Promise<boolean>((resolve) => {596    const timer = setTimeout(() => {597      pendingSourceSasConfirmation.delete(id);598      resolve(false);599    }, SOURCE_WINDOW_MS);600    pendingSourceSasConfirmation.set(id, {601      resolve: (ok) => {602        clearTimeout(timer);603        resolve(ok);604      },605    });606    push("ADOPTION_SOURCE_SAS", {607      id,608      sas,609      receiverDeviceName: request.device_name,610    });611  });612}613614/**615 * Opening Add Device in an unlocked extension is the source-side authorization.616 * Announce the SAS for comparison, then let the scanning device's explicit617 * confirmation be the single commit gate. grantAdoption still refuses to send618 * the root key until the receiver returns ADOPT_CONFIRM(true).619 */620function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {621  return waitForSourceSasConfirmation(id, request, sas);622}623624function clearState(): void {625  activeSocket = null;626  inFlight = false;627  pendingSourceSasConfirmation.clear();628}629630function constantTimeEqual(a: string, b: string): boolean {631  if (a.length !== b.length) return false;632  let diff = 0;633  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);634  return diff === 0;635}636637function encodeJson(value: unknown): Uint8Array {638  return new TextEncoder().encode(JSON.stringify(value));639}640641function decodeJson<T>(value: Uint8Array): T {642  return JSON.parse(new TextDecoder().decode(value)) as T;643}644645/**646 * PB-26-213: an adopted device's secret bypasses the public registry into647 * the encrypted pair-secret record. The registry row stays secretless; when648 * the vault is somehow locked here the row still lands and the next unlock649 * cannot recover this secret — re-pair is the fail-closed path.650 */651async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {652  const secret = paired.rendezvousSecretHex;653  const pub = { ...paired };654  delete pub.rendezvousSecretHex;655  await upsertPairedDevice(pub);656  if (!isValidPairSecret(secret)) return;657  const masterKeyHex = getRootKeyHex();658  if (!masterKeyHex) return;659  const masterKey = hexToBytes(masterKeyHex);660  try {661    await setPairSecret(paired.deviceId, secret, masterKey);662  } finally {663    masterKey.fill(0);664  }665}666667function hexToBytes(hex: string): Uint8Array {668  const out = new Uint8Array(hex.length / 2);669  for (let i = 0; i < out.length; i++) {670    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);671  }672  return out;673}