relay-adoption-source-bg.ts

17.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  isAdoptRequest,18  receiveFrame,19  requestAdoption,20  type AdoptRequest,21} from "../shared/vault-adopt";22import {23  derivePairingSAS,24  type PairingTransport,25} from "../shared/pairing-handshake";26import {27  bytesToHex,28  deriveRendezvousSecret,29  generatePairingRendezvousToken,30} from "../shared/rendezvous";31import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";32import { getSyncSettings, relayInForce } from "./sync-settings";33import { activateLicense, getLicenseKey, isProUser } from "./license";34import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";35import { computeVaultId } from "../shared/sync-identity";36import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";37import { storageSet } from "../shared/storage";38import { upsertPairedDevice } from "./paired-devices";39import { triggerSyncIfReachable } from "./sync-trigger";4041const SOURCE_WINDOW_MS = 5 * 60 * 1000;4243function push(type: string, payload: unknown): void {44  try {45    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;46    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {47      (maybe as Promise<unknown>).catch(() => undefined);48    }49  } catch {50    // The popup may have closed. The pending approval fails closed on cleanup.51  }52}5354let inFlight = false;55let activeSocket: PairingSocket | null = null;56// Outstanding source-side SAS confirmations, keyed by id. A map (vs the57// receiver's single slot) makes a stale decision from a previous source58// session impossible to land on the current one even if both run on the59// same background worker.60const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();6162export function isRelayAdoptionSourceInFlight(): boolean {63  return inFlight;64}6566/**67 * Occupy the non-buffering room before returning the invitation to the UI.68 * All long-term key material remains in the background service worker.69 */70export async function startRelayAdoptionSource(): Promise<{71  invitation: string;72}> {73  if (inFlight) throw new Error("adoption_source_in_progress");74  if (!isUnlocked()) throw new Error("vault_locked");75  inFlight = true;7677  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;78  try {79    const settings = await getSyncSettings();80    const relayUrl = relayInForce(settings);81    local = await getLocalPairingIdentity();82    const token = generatePairingRendezvousToken();83    // Source side has the vault — caller checked isUnlocked — so the phone84    // can read the invitation and know it should receive the vault.85    const encoded = createPairingInvitation(relayUrl, token, "populated");86    const invitation = parsePairingInvitation(encoded);87    if (!invitation) throw new Error("invitation_generation_failed");8889    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));90    activeSocket = socket;91    const readyLocal = local;92    void runSourceSession(93      socket,94      token,95      relayUrl,96      readyLocal,97    ).finally(() => {98      readyLocal.privateKey.fill(0);99      readyLocal.publicKey.fill(0);100      clearState();101    });102    return { invitation: encoded };103  } catch (error) {104    local?.privateKey.fill(0);105    local?.publicKey.fill(0);106    clearState();107    throw error;108  }109}110111export function cancelRelayAdoptionSource(): void {112  activeSocket?.close();113  clearState();114}115116/**117 * Onboarding pairing request — the empty extension shows a QR and the populated118 * phone scans it. The invitation carries `vaultStatus: "empty"`, which the119 * phone reads to switch into source mode and `grantAdoption` over the120 * established Noise channel. The receiver's `runReceiverSession` accepts the121 * root key and creates a local vault wrapped with the popup-supplied password.122 *123 * `password` is the local-only password the user picked on the "Set a password124 * for this device" screen; it wraps the adopted root key at rest and has no125 * relationship to the source device's password.126 */127export async function startRelayAdoptionReceiver(128  password: string,129): Promise<{ invitation: string }> {130  if (inFlight) throw new Error("adoption_receiver_in_progress");131  if (typeof password !== "string" || password.length === 0) {132    throw new Error("ADOPT_INVALID_PASSWORD");133  }134  inFlight = true;135  try {136    const settings = await getSyncSettings();137    const relayUrl = relayInForce(settings);138    const token = generatePairingRendezvousToken();139    const encoded = createPairingInvitation(relayUrl, token, "empty");140    const invitation = parsePairingInvitation(encoded);141    if (!invitation) throw new Error("invitation_generation_failed");142    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));143    activeSocket = socket;144    void runReceiverSession(145      socket,146      token,147      relayUrl,148      password,149    ).finally(clearState);150    return { invitation: encoded };151  } catch (error) {152    clearState();153    throw error;154  }155}156157/**158 * Resolves the source-side SAS confirmation by id. Called by the message159 * handler when the popup taps Confirm/Cancel on the source's SAS screen.160 * Returns true if the id matched an in-flight confirmation. Unknown ids161 * (already resolved, timed out, or never existed) are a no-op so a late162 * popup click cannot crash anything.163 */164export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {165  const pending = pendingSourceSasConfirmation.get(id);166  if (!pending) return false;167  pendingSourceSasConfirmation.delete(id);168  pending.resolve(ok);169  return true;170}171172async function runSourceSession(173  socket: PairingSocket,174  expectedToken: string,175  relayUrl: string,176  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,177): Promise<void> {178  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);179  let noise: NoiseNKSession | undefined;180  try {181    const hello = decodeJson<{ type?: unknown; token?: unknown }>(182      await socket.receive(),183    );184    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||185        !constantTimeEqual(hello.token, expectedToken)) {186      throw new Error("invalid_invitation");187    }188    socket.send(encodeJson({189      type: "PEACH_HELLO_ACK",190      ephemeralPublicKeyHex: bytesToHex(local.publicKey),191    }));192193    const session = await noiseNKResponder(194      local.privateKey,195      local.publicKey,196      async (bytes) => socket.send(bytes),197      () => socket.receive(),198    );199    noise = session;200    const channel: PairingTransport = {201      async send(plaintext) { socket.send(session.encrypt(plaintext)); },202      async receive() { return session.decrypt(await socket.receive()); },203    };204    const request = await receiveFrame(channel);205    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");206207    const device = await grantAdoption(channel, request, {208      sourceIdentity: local.identity,209      pairState: {210        rendezvousSecretHex: bytesToHex(211          deriveRendezvousSecret(session.handshakeHash),212        ),213        relayUrl,214      },215      async rootKeyHex() {216        // grantAdoption calls this only after local password approval and the217        // receiver's encrypted ADOPT_CONFIRM(true).218        const value = getRootKeyHex();219        if (!value) throw new Error("vault_locked");220        return value;221      },222      async vaultId() {223        const value = getRootKeyHex();224        if (!value) throw new Error("vault_locked");225        const bytes = hexToBytes(value);226        try {227          return await computeVaultId(bytes);228        } finally {229          bytes.fill(0);230        }231      },232      async license() {233        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;234      },235      async applyLicense(license) {236        await applyTransferredLicense(license);237      },238      onConsent: (adoptRequest) => requestConsent(239        adoptRequest,240        derivePairingSAS(session.handshakeHash),241        crypto.randomUUID(),242      ),243      upsertPairedDevice,244    });245    if (!device) {246      push("ADOPTION_SOURCE_RESULT", {247        status: "error",248        error: "adoption_rejected",249      });250      return;251    }252    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });253    // The Android receiver starts its relay listener as soon as its source254    // record is durable. Try once now and once after service startup settles so255    // the newly adopted empty vault receives its first sync without another256    // user action. Both calls are best-effort and contain no user data.257    void triggerSyncIfReachable({ reason: "manual", force: true });258    setTimeout(() => {259      void triggerSyncIfReachable({ reason: "manual", force: true });260    }, 1_000);261  } catch (error) {262    push("ADOPTION_SOURCE_RESULT", {263      status: "error",264      error: error instanceof Error ? error.message : "adoption_failed",265    });266  } finally {267    clearTimeout(timeout);268    noise?.close();269    socket.close();270    pendingSourceSasConfirmation.clear();271  }272}273274/**275 * Browser receives the vault from the phone. The protocol is the mirror of276 * the source side: phone is the Noise initiator and the source of truth for277 * the SAS gate, browser is the responder that creates the vault locally.278 */279async function runReceiverSession(280  socket: PairingSocket,281  expectedToken: string,282  relayUrl: string,283  password: string,284): Promise<void> {285  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);286  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.287  const ephemeral = generateKeyPair();288  const ephemeralPrivate = ephemeral.secretKey;289  const ephemeralPublic = ephemeral.publicKey;290  let noise: NoiseNKSession | undefined;291  let createdVault = false;292  let protocolError: string | undefined;293  try {294    const hello = decodeJson<{ type?: unknown; token?: unknown }>(295      await socket.receive(),296    );297    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||298        !constantTimeEqual(hello.token, expectedToken)) {299      throw new Error("invalid_invitation");300    }301    socket.send(encodeJson({302      type: "PEACH_HELLO_ACK",303      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),304    }));305306    const session = await noiseNKResponder(307      ephemeralPrivate,308      ephemeralPublic,309      async (bytes) => socket.send(bytes),310      () => socket.receive(),311    );312    noise = session;313    const channel: PairingTransport = {314      async send(plaintext) { socket.send(session.encrypt(plaintext)); },315      async receive() { return session.decrypt(await socket.receive()); },316    };317318    const deviceName = await getDeviceName();319320    const device = await requestAdoption(channel, {321      deviceName,322      generateDeviceId: () => crypto.randomUUID(),323      // Auto-accept the SAS: both sides derive it from the same Noise324      // handshake hash, so the codes match by construction. Requiring the user325      // to confirm on both sides creates a race where the phone can time out326      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The327      // phone's onConsent is the single user gate; this side just proceeds.328      // Still show the locally derived code for visual verification.329      async confirmSas() {330        push("ADOPTION_SOURCE_SAS", {331          sas: derivePairingSAS(session.handshakeHash),332          displayOnly: true,333        });334        return true;335      },336      async createVaultFromRootKey(rootKeyHex) {337        // Direct call, not chrome.runtime.sendMessage — a service worker338        // calling sendMessage does not reach its own listener (returns339        // undefined), and the receiver already lives in the same SW context340        // as the vault module. Round-tripping through the message bus was341        // the root cause of "create_vault_no_response".342        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);343        createdVault = true;344        return { vaultId };345      },346      async applyLicense(license) {347        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self348        // issue as createVaultFromRootKey. SW can't reach its own listener.349        await applyTransferredLicense(license);350      },351      async license() {352        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;353      },354      async deriveLocalIdentity() {355        // Direct call for the same reason as applyLicense above.356        const masterKeyHex = getRootKeyHex();357        if (!masterKeyHex) throw new Error("identity_unavailable");358        const masterKey = hexToBytes(masterKeyHex);359        try {360          return await buildLocalIdentity(masterKey);361        } finally {362          masterKey.fill(0);363        }364      },365      async upsertPairedDevice(paired) {366        // Direct call for the same reason as applyLicense above.367        await upsertPairedDevice(paired);368      },369      pairState: {370        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),371        relayUrl,372      },373      onError(error) {374        protocolError = error;375      },376    });377378    if (!device) {379      if (createdVault) {380        await rollbackAdoptedVault();381        createdVault = false;382      }383      push("ADOPTION_SOURCE_RESULT", {384        status: "error",385        error: protocolError ?? "adoption_rejected",386      });387      return;388    }389    // Adoption has committed identity and vault-key material, but entries have390    // not necessarily crossed yet. Persist the exact source row before telling391    // the popup pairing is complete so a popup close/reopen cannot reveal the392    // temporarily empty receiver vault. The popup clears this only after that393    // row records a successful sync.394    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(395      () => undefined,396    );397    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });398    createdVault = false;399    void triggerSyncIfReachable({ reason: "manual", force: true });400  } catch (error) {401    if (createdVault) {402      try {403        await rollbackAdoptedVault();404        createdVault = false;405      } catch (rollbackError) {406        console.error("[Peach] rollback failed:", rollbackError);407      }408    }409    console.error("[Peach] receiver session failed:", error);410    push("ADOPTION_SOURCE_RESULT", {411      status: "error",412      error: error instanceof Error ? error.message : "adoption_failed",413    });414  } finally {415    clearTimeout(timeout);416    noise?.close();417    socket.close();418    ephemeralPrivate.fill(0);419    ephemeralPublic.fill(0);420  }421}422423/**424 * A transferred entitlement is committed only when activation succeeds and425 * the stored value can immediately be re-read as Pro. activateLicense returns426 * a normal Free result for invalid input, so callers must not ignore it.427 */428async function applyTransferredLicense(license: string): Promise<void> {429  const activated = await activateLicense(license);430  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {431    throw new Error("license_transfer_failed");432  }433}434435/**436 * Push the SAS to the popup, then block until the popup responds via the437 * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on438 * timeout or cancel so the adoption aborts before any vault state changes.439 *440 * The pending deferred MUST be installed before the push: a popup-side441 * auto-approver (test mocks, future automation) can synchronously resolve442 * the deferred the moment it sees the SAS push, and a missing slot would443 * drop that approval on the floor.444 */445function waitForSourceSasConfirmation(446  id: string,447  request: AdoptRequest,448  sas: string,449): Promise<boolean> {450  return new Promise<boolean>((resolve) => {451    const timer = setTimeout(() => {452      pendingSourceSasConfirmation.delete(id);453      resolve(false);454    }, SOURCE_WINDOW_MS);455    pendingSourceSasConfirmation.set(id, {456      resolve: (ok) => {457        clearTimeout(timer);458        resolve(ok);459      },460    });461    push("ADOPTION_SOURCE_SAS", {462      id,463      sas,464      receiverDeviceName: request.device_name,465    });466  });467}468469/**470 * Opening Add Device in an unlocked extension is the source-side authorization.471 * Announce the SAS for comparison, then let the scanning device's explicit472 * confirmation be the single commit gate. grantAdoption still refuses to send473 * the root key until the receiver returns ADOPT_CONFIRM(true).474 */475function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {476  return waitForSourceSasConfirmation(id, request, sas);477}478479function clearState(): void {480  activeSocket = null;481  inFlight = false;482  pendingSourceSasConfirmation.clear();483}484485function constantTimeEqual(a: string, b: string): boolean {486  if (a.length !== b.length) return false;487  let diff = 0;488  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);489  return diff === 0;490}491492function encodeJson(value: unknown): Uint8Array {493  return new TextEncoder().encode(JSON.stringify(value));494}495496function decodeJson<T>(value: Uint8Array): T {497  return JSON.parse(new TextDecoder().decode(value)) as T;498}499500function hexToBytes(hex: string): Uint8Array {501  const out = new Uint8Array(hex.length / 2);502  for (let i = 0; i < out.length; i++) {503    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);504  }505  return out;506}