relay-adoption-source-bg.ts

7.3 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, relayInForce } from "./sync-settings";31import { getLicenseKey, isProUser } from "./license";32import { getRootKeyHex, isUnlocked } 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;5253export function isRelayAdoptionSourceInFlight(): boolean {54  return inFlight;55}5657/**58 * Occupy the non-buffering room before returning the invitation to the UI.59 * All long-term key material remains in the background service worker.60 */61export async function startRelayAdoptionSource(): Promise<{62  invitation: string;63}> {64  if (inFlight) throw new Error("adoption_source_in_progress");65  if (!isUnlocked()) throw new Error("vault_locked");66  inFlight = true;6768  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;69  try {70    const settings = await getSyncSettings();71    const relayUrl = relayInForce(settings);72    local = await getLocalPairingIdentity();73    const token = generatePairingRendezvousToken();74    const encoded = createPairingInvitation(relayUrl, token);75    const invitation = parsePairingInvitation(encoded);76    if (!invitation) throw new Error("invitation_generation_failed");7778    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));79    activeSocket = socket;80    const readyLocal = local;81    void runSourceSession(82      socket,83      token,84      relayUrl,85      readyLocal,86    ).finally(() => {87      readyLocal.privateKey.fill(0);88      readyLocal.publicKey.fill(0);89      clearState();90    });91    return { invitation: encoded };92  } catch (error) {93    local?.privateKey.fill(0);94    local?.publicKey.fill(0);95    clearState();96    throw error;97  }98}99100export function cancelRelayAdoptionSource(): void {101  activeSocket?.close();102  clearState();103}104105async function runSourceSession(106  socket: PairingSocket,107  expectedToken: string,108  relayUrl: string,109  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,110): Promise<void> {111  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);112  let noise: NoiseNKSession | undefined;113  try {114    const hello = decodeJson<{ type?: unknown; token?: unknown }>(115      await socket.receive(),116    );117    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||118        !constantTimeEqual(hello.token, expectedToken)) {119      throw new Error("invalid_invitation");120    }121    socket.send(encodeJson({122      type: "PEACH_HELLO_ACK",123      ephemeralPublicKeyHex: bytesToHex(local.publicKey),124    }));125126    const session = await noiseNKResponder(127      local.privateKey,128      local.publicKey,129      async (bytes) => socket.send(bytes),130      () => socket.receive(),131    );132    noise = session;133    const channel: PairingTransport = {134      async send(plaintext) { socket.send(session.encrypt(plaintext)); },135      async receive() { return session.decrypt(await socket.receive()); },136    };137    const request = await receiveFrame(channel);138    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");139140    const device = await grantAdoption(channel, request, {141      sourceIdentity: local.identity,142      pairState: {143        rendezvousSecretHex: bytesToHex(144          deriveRendezvousSecret(session.handshakeHash),145        ),146        relayUrl,147      },148      async rootKeyHex() {149        // grantAdoption calls this only after local password approval and the150        // receiver's encrypted ADOPT_CONFIRM(true).151        const value = getRootKeyHex();152        if (!value) throw new Error("vault_locked");153        return value;154      },155      async vaultId() {156        const value = getRootKeyHex();157        if (!value) throw new Error("vault_locked");158        const bytes = hexToBytes(value);159        try {160          return await computeVaultId(bytes);161        } finally {162          bytes.fill(0);163        }164      },165      async license() {166        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;167      },168      onConsent: (adoptRequest) => requestConsent(169        adoptRequest,170        derivePairingSAS(session.handshakeHash),171      ),172      upsertPairedDevice,173    });174    if (!device) {175      push("ADOPTION_SOURCE_RESULT", {176        status: "error",177        error: "adoption_rejected",178      });179      return;180    }181    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });182    // The Android receiver starts its relay listener as soon as its source183    // record is durable. Try once now and once after service startup settles so184    // the newly adopted empty vault receives its first sync without another185    // user action. Both calls are best-effort and contain no user data.186    void triggerSyncIfReachable({ reason: "manual", force: true });187    setTimeout(() => {188      void triggerSyncIfReachable({ reason: "manual", force: true });189    }, 1_000);190  } catch {191    push("ADOPTION_SOURCE_RESULT", {192      status: "error",193      error: "adoption_failed",194    });195  } finally {196    clearTimeout(timeout);197    noise?.close();198    socket.close();199  }200}201202/**203 * Opening Add Device in an unlocked extension is the source-side authorization.204 * Announce the SAS for comparison, then let the scanning device's explicit205 * confirmation be the single commit gate. grantAdoption still refuses to send206 * the root key until the receiver returns ADOPT_CONFIRM(true).207 */208function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {209  push("ADOPTION_SOURCE_SAS", {210    sas,211    receiverDeviceName: request.device_name,212  });213  return Promise.resolve(true);214}215216function clearState(): void {217  activeSocket = null;218  inFlight = false;219}220221function constantTimeEqual(a: string, b: string): boolean {222  if (a.length !== b.length) return false;223  let diff = 0;224  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);225  return diff === 0;226}227228function encodeJson(value: unknown): Uint8Array {229  return new TextEncoder().encode(JSON.stringify(value));230}231232function decodeJson<T>(value: Uint8Array): T {233  return JSON.parse(new TextDecoder().decode(value)) as T;234}235236function hexToBytes(hex: string): Uint8Array {237  const out = new Uint8Array(hex.length / 2);238  for (let i = 0; i < out.length; i++) {239    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);240  }241  return out;242}