relay-adoption-source-bg.ts

9.2 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    // Source side has the vault — caller checked isUnlocked — so the phone75    // can read the invitation and know it should receive the vault.76    const encoded = createPairingInvitation(relayUrl, token, "populated");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}101102export function cancelRelayAdoptionSource(): void {103  activeSocket?.close();104  clearState();105}106107/**108 * Onboarding pairing request — the empty extension shows a QR and the populated109 * phone scans it. The invitation is identical to the source-side one (relay110 * URL + rendezvous token); the difference is the extension has no vault yet111 * and will receive one from the phone over the established Noise channel.112 *113 * This is the UI-only side of the reversal. The "phone sends vault back" wire114 * protocol still needs the phone-side counterpart before the connection can115 * actually deliver a vault — until then, this generates a valid QR that the116 * existing `relay-dial` flow can connect to.117 */118export async function startRelayAdoptionReceiver(): Promise<{ invitation: string }> {119  if (inFlight) throw new Error("adoption_receiver_in_progress");120  inFlight = true;121  try {122    const settings = await getSyncSettings();123    const relayUrl = relayInForce(settings);124    const token = generatePairingRendezvousToken();125    // Receiver side is the empty browser — onboarding hasn't created a vault126    // yet. The phone sees "empty" in the invitation and switches to source.127    const encoded = createPairingInvitation(relayUrl, token, "empty");128    const invitation = parsePairingInvitation(encoded);129    if (!invitation) throw new Error("invitation_generation_failed");130    // Open the relay socket so the rendezvous token is reserved. Without a131    // vault to send, this side does nothing on the Noise channel yet — it132    // just holds the room open for the phone's scan.133    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));134    activeSocket = socket;135    return { invitation: encoded };136  } catch (error) {137    clearState();138    throw error;139  }140}141142async function runSourceSession(143  socket: PairingSocket,144  expectedToken: string,145  relayUrl: string,146  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,147): Promise<void> {148  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);149  let noise: NoiseNKSession | undefined;150  try {151    const hello = decodeJson<{ type?: unknown; token?: unknown }>(152      await socket.receive(),153    );154    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||155        !constantTimeEqual(hello.token, expectedToken)) {156      throw new Error("invalid_invitation");157    }158    socket.send(encodeJson({159      type: "PEACH_HELLO_ACK",160      ephemeralPublicKeyHex: bytesToHex(local.publicKey),161    }));162163    const session = await noiseNKResponder(164      local.privateKey,165      local.publicKey,166      async (bytes) => socket.send(bytes),167      () => socket.receive(),168    );169    noise = session;170    const channel: PairingTransport = {171      async send(plaintext) { socket.send(session.encrypt(plaintext)); },172      async receive() { return session.decrypt(await socket.receive()); },173    };174    const request = await receiveFrame(channel);175    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");176177    const device = await grantAdoption(channel, request, {178      sourceIdentity: local.identity,179      pairState: {180        rendezvousSecretHex: bytesToHex(181          deriveRendezvousSecret(session.handshakeHash),182        ),183        relayUrl,184      },185      async rootKeyHex() {186        // grantAdoption calls this only after local password approval and the187        // receiver's encrypted ADOPT_CONFIRM(true).188        const value = getRootKeyHex();189        if (!value) throw new Error("vault_locked");190        return value;191      },192      async vaultId() {193        const value = getRootKeyHex();194        if (!value) throw new Error("vault_locked");195        const bytes = hexToBytes(value);196        try {197          return await computeVaultId(bytes);198        } finally {199          bytes.fill(0);200        }201      },202      async license() {203        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;204      },205      onConsent: (adoptRequest) => requestConsent(206        adoptRequest,207        derivePairingSAS(session.handshakeHash),208      ),209      upsertPairedDevice,210    });211    if (!device) {212      push("ADOPTION_SOURCE_RESULT", {213        status: "error",214        error: "adoption_rejected",215      });216      return;217    }218    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });219    // The Android receiver starts its relay listener as soon as its source220    // record is durable. Try once now and once after service startup settles so221    // the newly adopted empty vault receives its first sync without another222    // user action. Both calls are best-effort and contain no user data.223    void triggerSyncIfReachable({ reason: "manual", force: true });224    setTimeout(() => {225      void triggerSyncIfReachable({ reason: "manual", force: true });226    }, 1_000);227  } catch {228    push("ADOPTION_SOURCE_RESULT", {229      status: "error",230      error: "adoption_failed",231    });232  } finally {233    clearTimeout(timeout);234    noise?.close();235    socket.close();236  }237}238239/**240 * Opening Add Device in an unlocked extension is the source-side authorization.241 * Announce the SAS for comparison, then let the scanning device's explicit242 * confirmation be the single commit gate. grantAdoption still refuses to send243 * the root key until the receiver returns ADOPT_CONFIRM(true).244 */245function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {246  push("ADOPTION_SOURCE_SAS", {247    sas,248    receiverDeviceName: request.device_name,249  });250  return Promise.resolve(true);251}252253function clearState(): void {254  activeSocket = null;255  inFlight = false;256}257258function constantTimeEqual(a: string, b: string): boolean {259  if (a.length !== b.length) return false;260  let diff = 0;261  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);262  return diff === 0;263}264265function encodeJson(value: unknown): Uint8Array {266  return new TextEncoder().encode(JSON.stringify(value));267}268269function decodeJson<T>(value: Uint8Array): T {270  return JSON.parse(new TextDecoder().decode(value)) as T;271}272273function hexToBytes(hex: string): Uint8Array {274  const out = new Uint8Array(hex.length / 2);275  for (let i = 0; i < out.length; i++) {276    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);277  }278  return out;279}