relay-adoption-source-bg.ts

7.8 KB · raw

1import {2  createPairingInvitation,3  pairingInvitationUrl,4  parsePairingInvitation,5} from "../shared/pairing-invitation";6import {7  openRelayPairingSocket,8  type PairingSocket,9} from "../shared/pairing-dial";10import {11  noiseNKResponder,12  type NoiseNKSession,13} from "../shared/noise-js";14import {15  grantAdoption,16  isAdoptRequest,17  receiveFrame,18  type AdoptRequest,19} from "../shared/vault-adopt";20import {21  derivePairingSAS,22  type PairingTransport,23} from "../shared/pairing-handshake";24import {25  bytesToHex,26  deriveRendezvousSecret,27  generatePairingRendezvousToken,28} from "../shared/rendezvous";29import { getLocalPairingIdentity } from "./device-identity";30import { getSyncSettings } from "./sync-settings";31import { getLicenseKey, isProUser } from "./license";32import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";33import { computeVaultId } from "../shared/sync-identity";34import { upsertPairedDevice } from "./paired-devices";35import { triggerSyncIfReachable } from "./sync-trigger";3637const SOURCE_WINDOW_MS = 5 * 60 * 1000;3839function push(type: string, payload: unknown): void {40  try {41    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;42    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {43      (maybe as Promise<unknown>).catch(() => undefined);44    }45  } catch {46    // The popup may have closed. The pending approval fails closed on cleanup.47  }48}4950let inFlight = false;51let activeSocket: PairingSocket | null = null;52let pendingConsent: ((approved: boolean) => void) | null = null;5354export function isRelayAdoptionSourceInFlight(): boolean {55  return inFlight;56}5758/**59 * Occupy the non-buffering room before returning the invitation to the UI.60 * All long-term key material remains in the background service worker.61 */62export async function startRelayAdoptionSource(): Promise<{63  invitation: string;64}> {65  if (inFlight) throw new Error("adoption_source_in_progress");66  if (!isUnlocked()) throw new Error("vault_locked");67  inFlight = true;6869  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;70  try {71    const settings = await getSyncSettings();72    if (settings.mode === "off") throw new Error("sync_disabled");73    local = await getLocalPairingIdentity();74    const relayUrl = settings.relayUrl;75    const token = generatePairingRendezvousToken();76    const encoded = createPairingInvitation(relayUrl, token);77    const invitation = parsePairingInvitation(encoded);78    if (!invitation) throw new Error("invitation_generation_failed");7980    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));81    activeSocket = socket;82    const readyLocal = local;83    void runSourceSession(84      socket,85      token,86      relayUrl,87      readyLocal,88    ).finally(() => {89      readyLocal.privateKey.fill(0);90      readyLocal.publicKey.fill(0);91      clearState();92    });93    return { invitation: encoded };94  } catch (error) {95    local?.privateKey.fill(0);96    local?.publicKey.fill(0);97    clearState();98    throw error;99  }100}101102/**103 * Wrong passwords do not consume the pending decision; cancel does.104 */105export async function confirmRelayAdoptionSource(106  password: string | null,107): Promise<{ valid: boolean }> {108  const pending = pendingConsent;109  if (!pending) return { valid: false };110  if (password === null) {111    pendingConsent = null;112    pending(false);113    return { valid: true };114  }115  const valid = await verifyMainPassword(password);116  if (valid) {117    pendingConsent = null;118    pending(true);119  }120  return { valid };121}122123export function cancelRelayAdoptionSource(): void {124  pendingConsent?.(false);125  pendingConsent = null;126  activeSocket?.close();127  clearState();128}129130async function runSourceSession(131  socket: PairingSocket,132  expectedToken: string,133  relayUrl: string,134  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,135): Promise<void> {136  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);137  let noise: NoiseNKSession | undefined;138  try {139    const hello = decodeJson<{ type?: unknown; token?: unknown }>(140      await socket.receive(),141    );142    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||143        !constantTimeEqual(hello.token, expectedToken)) {144      throw new Error("invalid_invitation");145    }146    socket.send(encodeJson({147      type: "PEACH_HELLO_ACK",148      ephemeralPublicKeyHex: bytesToHex(local.publicKey),149    }));150151    const session = await noiseNKResponder(152      local.privateKey,153      local.publicKey,154      async (bytes) => socket.send(bytes),155      () => socket.receive(),156    );157    noise = session;158    const channel: PairingTransport = {159      async send(plaintext) { socket.send(session.encrypt(plaintext)); },160      async receive() { return session.decrypt(await socket.receive()); },161    };162    const request = await receiveFrame(channel);163    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");164165    const device = await grantAdoption(channel, request, {166      sourceIdentity: local.identity,167      pairState: {168        rendezvousSecretHex: bytesToHex(169          deriveRendezvousSecret(session.handshakeHash),170        ),171        relayUrl,172      },173      async rootKeyHex() {174        // grantAdoption calls this only after local password approval and the175        // receiver's encrypted ADOPT_CONFIRM(true).176        const value = getRootKeyHex();177        if (!value) throw new Error("vault_locked");178        return value;179      },180      async vaultId() {181        const value = getRootKeyHex();182        if (!value) throw new Error("vault_locked");183        const bytes = hexToBytes(value);184        try {185          return await computeVaultId(bytes);186        } finally {187          bytes.fill(0);188        }189      },190      async license() {191        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;192      },193      onConsent: (adoptRequest) => requestConsent(194        adoptRequest,195        derivePairingSAS(session.handshakeHash),196      ),197      upsertPairedDevice,198    });199    if (!device) {200      push("ADOPTION_SOURCE_RESULT", {201        status: "error",202        error: "adoption_rejected",203      });204      return;205    }206    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });207    // The Android receiver starts its relay listener as soon as its source208    // record is durable. Try once now and once after service startup settles so209    // the newly adopted empty vault receives its first sync without another210    // user action. Both calls are best-effort and contain no user data.211    void triggerSyncIfReachable({ reason: "manual", force: true });212    setTimeout(() => {213      void triggerSyncIfReachable({ reason: "manual", force: true });214    }, 1_000);215  } catch {216    push("ADOPTION_SOURCE_RESULT", {217      status: "error",218      error: "adoption_failed",219    });220  } finally {221    clearTimeout(timeout);222    noise?.close();223    socket.close();224  }225}226227function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {228  push("ADOPTION_SOURCE_SAS", {229    sas,230    receiverDeviceName: request.device_name,231  });232  return new Promise<boolean>((resolve) => {233    pendingConsent = resolve;234  });235}236237function clearState(): void {238  pendingConsent?.(false);239  pendingConsent = null;240  activeSocket = null;241  inFlight = false;242}243244function constantTimeEqual(a: string, b: string): boolean {245  if (a.length !== b.length) return false;246  let diff = 0;247  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);248  return diff === 0;249}250251function encodeJson(value: unknown): Uint8Array {252  return new TextEncoder().encode(JSON.stringify(value));253}254255function decodeJson<T>(value: Uint8Array): T {256  return JSON.parse(new TextDecoder().decode(value)) as T;257}258259function hexToBytes(hex: string): Uint8Array {260  const out = new Uint8Array(hex.length / 2);261  for (let i = 0; i < out.length; i++) {262    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);263  }264  return out;265}