relay-adoption-source-bg.ts

7.7 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 { noiseNKResponder } from "../shared/noise-js";11import {12  grantAdoption,13  isAdoptRequest,14  receiveFrame,15  type AdoptRequest,16} from "../shared/vault-adopt";17import {18  derivePairingSAS,19  type PairingTransport,20} from "../shared/pairing-handshake";21import {22  bytesToHex,23  deriveRendezvousSecret,24  generatePairingRendezvousToken,25} from "../shared/rendezvous";26import { productionCodePairingDeps } from "./code-pairing-bg";27import { getRelaySettings } from "./relay-settings";28import { getLicenseKey, isProUser } from "./license";29import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";30import { computeVaultId } from "../shared/sync-identity";31import { upsertPairedDevice } from "./paired-devices";32import { triggerSyncIfReachable } from "./sync-trigger";3334const SOURCE_WINDOW_MS = 5 * 60 * 1000;3536function push(type: string, payload: unknown): void {37  try {38    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;39    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {40      (maybe as Promise<unknown>).catch(() => undefined);41    }42  } catch {43    // The popup may have closed. The pending approval fails closed on cleanup.44  }45}4647let inFlight = false;48let activeSocket: PairingSocket | null = null;49let pendingConsent: ((approved: boolean) => void) | null = null;5051export function isRelayAdoptionSourceInFlight(): boolean {52  return inFlight;53}5455/**56 * Occupy the non-buffering room before returning the invitation to the UI.57 * All long-term key material remains in the background service worker.58 */59export async function startRelayAdoptionSource(): Promise<{60  invitation: string;61}> {62  if (inFlight) throw new Error("adoption_source_in_progress");63  if (!isUnlocked()) throw new Error("vault_locked");64  inFlight = true;6566  let local: Awaited<67    ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>68  > | null = null;69  try {70    local = await productionCodePairingDeps().getLocalIdentity();71    const relayUrl = (await getRelaySettings()).relayUrl;72    const token = generatePairingRendezvousToken();73    const encoded = createPairingInvitation(relayUrl, token);74    const invitation = parsePairingInvitation(encoded);75    if (!invitation) throw new Error("invitation_generation_failed");7677    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));78    activeSocket = socket;79    const readyLocal = local;80    void runSourceSession(81      socket,82      token,83      relayUrl,84      readyLocal,85    ).finally(() => {86      readyLocal.privateKey.fill(0);87      readyLocal.publicKey.fill(0);88      clearState();89    });90    return { invitation: encoded };91  } catch (error) {92    local?.privateKey.fill(0);93    local?.publicKey.fill(0);94    clearState();95    throw error;96  }97}9899/**100 * Wrong passwords do not consume the pending decision; cancel does.101 */102export async function confirmRelayAdoptionSource(103  password: string | null,104): Promise<{ valid: boolean }> {105  const pending = pendingConsent;106  if (!pending) return { valid: false };107  if (password === null) {108    pendingConsent = null;109    pending(false);110    return { valid: true };111  }112  const valid = await verifyMainPassword(password);113  if (valid) {114    pendingConsent = null;115    pending(true);116  }117  return { valid };118}119120export function cancelRelayAdoptionSource(): void {121  pendingConsent?.(false);122  pendingConsent = null;123  activeSocket?.close();124  clearState();125}126127async function runSourceSession(128  socket: PairingSocket,129  expectedToken: string,130  relayUrl: string,131  local: Awaited<ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>>,132): Promise<void> {133  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);134  try {135    const hello = decodeJson<{ type?: unknown; token?: unknown }>(136      await socket.receive(),137    );138    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||139        !constantTimeEqual(hello.token, expectedToken)) {140      throw new Error("invalid_invitation");141    }142    socket.send(encodeJson({143      type: "PEACH_HELLO_ACK",144      ephemeralPublicKeyHex: bytesToHex(local.publicKey),145    }));146147    const noise = await noiseNKResponder(148      local.privateKey,149      local.publicKey,150      async (bytes) => socket.send(bytes),151      () => socket.receive(),152    );153    const channel: PairingTransport = {154      async send(plaintext) { socket.send(noise.encrypt(plaintext)); },155      async receive() { return noise.decrypt(await socket.receive()); },156    };157    const request = await receiveFrame(channel);158    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");159160    const device = await grantAdoption(channel, request, {161      sourceIdentity: local.identity,162      pairState: {163        rendezvousSecretHex: bytesToHex(164          deriveRendezvousSecret(noise.handshakeHash),165        ),166        relayUrl,167      },168      async rootKeyHex() {169        // grantAdoption calls this only after local password approval and the170        // receiver's encrypted ADOPT_CONFIRM(true).171        const value = getRootKeyHex();172        if (!value) throw new Error("vault_locked");173        return value;174      },175      async vaultId() {176        const value = getRootKeyHex();177        if (!value) throw new Error("vault_locked");178        const bytes = hexToBytes(value);179        try {180          return await computeVaultId(bytes);181        } finally {182          bytes.fill(0);183        }184      },185      async license() {186        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;187      },188      onConsent: (adoptRequest) => requestConsent(189        adoptRequest,190        derivePairingSAS(noise.handshakeHash),191      ),192      upsertPairedDevice,193    });194    if (!device) {195      push("ADOPTION_SOURCE_RESULT", {196        status: "error",197        error: "adoption_rejected",198      });199      return;200    }201    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });202    // The Android receiver starts its relay listener as soon as its source203    // record is durable. Try once now and once after service startup settles so204    // the newly adopted empty vault receives its first sync without another205    // user action. Both calls are best-effort and contain no user data.206    void triggerSyncIfReachable({ reason: "manual", force: true });207    setTimeout(() => {208      void triggerSyncIfReachable({ reason: "manual", force: true });209    }, 1_000);210  } catch {211    push("ADOPTION_SOURCE_RESULT", {212      status: "error",213      error: "adoption_failed",214    });215  } finally {216    clearTimeout(timeout);217    socket.close();218  }219}220221function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {222  push("ADOPTION_SOURCE_SAS", {223    sas,224    receiverDeviceName: request.device_name,225  });226  return new Promise<boolean>((resolve) => {227    pendingConsent = resolve;228  });229}230231function clearState(): void {232  pendingConsent?.(false);233  pendingConsent = null;234  activeSocket = null;235  inFlight = false;236}237238function constantTimeEqual(a: string, b: string): boolean {239  if (a.length !== b.length) return false;240  let diff = 0;241  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);242  return diff === 0;243}244245function encodeJson(value: unknown): Uint8Array {246  return new TextEncoder().encode(JSON.stringify(value));247}248249function decodeJson<T>(value: Uint8Array): T {250  return JSON.parse(new TextDecoder().decode(value)) as T;251}252253function hexToBytes(hex: string): Uint8Array {254  const out = new Uint8Array(hex.length / 2);255  for (let i = 0; i < out.length; i++) {256    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);257  }258  return out;259}