capacitor-pairing-server.ts

10.4 KB · raw

1/**2 * Capacitor pairing server — JS runtime for Android show-mode pairing.3 *4 * Kotlin (`SyncPlugin.startPairingTcpServer`) is the byte pump: it accepts5 * one TCP connection and pumps inbound bytes to JS as `pairingChunk` events6 * (base64). Outbound bytes go back via `sendPairingChunk`. Bytes are not7 * framed on the Kotlin side — chunks may split or coalesce frames.8 *9 * This module reassembles u32-BE length-prefixed frames (matching10 * `peach-sync-host/src/frame.rs`), drives the Noise NK responder, runs the11 * auth challenge/proof round, and processes a single PAIR_REQUEST. On12 * accept it sends PAIR_ACCEPT, persists the paired device to13 * `peach_paired_devices`, and writes a `RecentPairing` so the popup can14 * surface a "Paired with X" toast on next open.15 */1617import { registerPlugin, type PluginListenerHandle } from "@capacitor/core";18import {19  noiseNKResponder,20  performResponderAuth,21  type NoiseNKSession,22} from "./noise-js";23import { encodeSyncMessage, decodeSyncMessage } from "./sync-wire";2425const MAX_FRAME_BYTES = 1024 * 1024;26const PAIR_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;2728interface SyncPluginPairing {29  startPairingTcpServer(): Promise<{ tcpPort: number }>;30  startPairingBeacon(opts: {31    token: string;32    tcpPort: number;33    x25519PublicKey: string;34    deviceId: string;35    name: string;36  }): Promise<void>;37  stopPairingServer(): Promise<void>;38  sendPairingChunk(opts: { dataBase64: string }): Promise<void>;39  addListener(40    eventName: "pairingChunk",41    listener: (event: { dataBase64: string }) => void,42  ): Promise<PluginListenerHandle>;43  addListener(44    eventName: "pairingConnected",45    listener: (event: { peerAddr: string }) => void,46  ): Promise<PluginListenerHandle>;47  addListener(48    eventName: "pairingEnded",49    listener: () => void,50  ): Promise<PluginListenerHandle>;51}5253const Sync = registerPlugin<SyncPluginPairing>("Sync");5455export interface CapacitorPairingDeps {56  /** Our static x25519 keypair. Persisted by `getAndroidSyncIdentity()`. */57  privateKey: Uint8Array;58  publicKey: Uint8Array;59  /** Local device name shown to the peer in PAIR_ACCEPT. */60  deviceName: string;61  /** base64url-encoded random device id (not the public key). */62  deviceId: string;63  /** Hex-encoded public key, for the UDP beacon's `x25519_public_key`. */64  publicKeyHex: string;65  /** 6-digit pairing code. */66  token: string;67  /** Persists the new paired device on accept. */68  onPaired: (paired: {69    deviceId: string;70    displayName: string;71    publicKeyHex: string;72  }) => void | Promise<void>;73  /**74   * Optional notification fired BEFORE persisting — popup uses it to flip to75   * the success card while the SW is still finishing storage writes.76   */77  onPairRequest?: (deviceName: string) => void;78}7980export interface CapacitorPairingHandle {81  /** Tear down the server, beacon, and any in-flight handshake. */82  stop: () => Promise<void>;83}8485function base64ToBytes(b64: string): Uint8Array {86  const binary = atob(b64);87  const out = new Uint8Array(binary.length);88  for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);89  return out;90}9192function bytesToBase64(bytes: Uint8Array): string {93  let binary = "";94  for (const b of bytes) binary += String.fromCharCode(b);95  return btoa(binary);96}9798/**99 * Start the show-mode pairing server: bind TCP listener via Kotlin, start100 * the UDP beacon, then drive Noise NK responder + auth + PAIR_REQUEST as101 * soon as a peer connects.102 *103 * The returned handle's `stop()` is idempotent — call it from popup104 * cleanup, vault-lock, and post-pair so the kotlin sockets always close.105 */106export async function startCapacitorPairingServer(107  deps: CapacitorPairingDeps,108): Promise<CapacitorPairingHandle> {109  const { tcpPort } = await Sync.startPairingTcpServer();110  await Sync.startPairingBeacon({111    token: deps.token,112    tcpPort,113    x25519PublicKey: deps.publicKeyHex,114    deviceId: deps.deviceId,115    name: deps.deviceName,116  });117118  // Inbound byte buffer — Kotlin sends raw socket reads; we reassemble119  // u32-BE length-prefixed frames ourselves.120  let inboundBuf = new Uint8Array(0);121  const frameWaiters: Array<{122    resolve: (frame: Uint8Array) => void;123    reject: (err: Error) => void;124  }> = [];125  let connectionClosed = false;126  let closeReason: Error | null = null;127  let stopped = false;128129  function appendInbound(chunk: Uint8Array) {130    const next = new Uint8Array(inboundBuf.length + chunk.length);131    next.set(inboundBuf, 0);132    next.set(chunk, inboundBuf.length);133    inboundBuf = next;134    drainFrameWaiters();135  }136137  function tryReadFrame(): Uint8Array | null {138    if (inboundBuf.length < 4) return null;139    const view = new DataView(inboundBuf.buffer, inboundBuf.byteOffset, 4);140    const len = view.getUint32(0, false); // big-endian, matches frame.rs141    if (len > MAX_FRAME_BYTES) {142      throw new Error(`incoming frame too large: ${len}`);143    }144    if (inboundBuf.length < 4 + len) return null;145    const payload = inboundBuf.slice(4, 4 + len);146    inboundBuf = inboundBuf.slice(4 + len);147    return payload;148  }149150  function drainFrameWaiters() {151    while (frameWaiters.length > 0) {152      let frame: Uint8Array | null;153      try {154        frame = tryReadFrame();155      } catch (err) {156        const w = frameWaiters.shift();157        w?.reject(err instanceof Error ? err : new Error(String(err)));158        continue;159      }160      if (!frame) return;161      const w = frameWaiters.shift()!;162      w.resolve(frame);163    }164  }165166  function failPendingWaiters(err: Error) {167    while (frameWaiters.length > 0) {168      const w = frameWaiters.shift()!;169      w.reject(err);170    }171  }172173  function receiveFrame(): Promise<Uint8Array> {174    if (connectionClosed && inboundBuf.length < 4) {175      return Promise.reject(closeReason ?? new Error("connection closed"));176    }177    return new Promise<Uint8Array>((resolve, reject) => {178      frameWaiters.push({ resolve, reject });179      drainFrameWaiters();180    });181  }182183  async function sendFrame(payload: Uint8Array): Promise<void> {184    if (connectionClosed) {185      throw closeReason ?? new Error("connection closed");186    }187    const len = payload.length;188    const framed = new Uint8Array(4 + len);189    new DataView(framed.buffer).setUint32(0, len, false);190    framed.set(payload, 4);191    await Sync.sendPairingChunk({ dataBase64: bytesToBase64(framed) });192  }193194  const chunkHandle = await Sync.addListener("pairingChunk", (event) => {195    if (stopped) return;196    try {197      appendInbound(base64ToBytes(event.dataBase64));198    } catch (err) {199      failPendingWaiters(err instanceof Error ? err : new Error(String(err)));200    }201  });202203  const endHandle = await Sync.addListener("pairingEnded", () => {204    connectionClosed = true;205    closeReason = closeReason ?? new Error("connection closed");206    failPendingWaiters(closeReason);207  });208209  const connectedHandle = await Sync.addListener("pairingConnected", () => {210    void runHandshake();211  });212213  async function runHandshake(): Promise<void> {214    let session: NoiseNKSession;215    try {216      session = await noiseNKResponder(217        deps.privateKey,218        deps.publicKey,219        sendFrame,220        receiveFrame,221      );222    } catch (err) {223      console.warn("[peach-pair] noise responder failed:", err);224      void stop();225      return;226    }227228    const sendEnc = async (data: Uint8Array) => sendFrame(session.encrypt(data));229    const recvEnc = async () => session.decrypt(await receiveFrame());230231    let initiatorPublicKey: Uint8Array;232    try {233      const auth = await performResponderAuth(234        session,235        deps.privateKey,236        deps.publicKey,237        sendEnc,238        recvEnc,239      );240      initiatorPublicKey = auth.initiatorPublicKey;241    } catch (err) {242      console.warn("[peach-pair] auth failed:", err);243      void stop();244      return;245    }246247    // Read exactly one PAIR_REQUEST. Anything else on a code-pairing window248    // is an attempted bypass — drop the connection.249    let pairRequest;250    try {251      const frame = await Promise.race([252        recvEnc(),253        new Promise<Uint8Array>((_, reject) =>254          setTimeout(() => reject(new Error("PAIR_REQUEST timeout")), PAIR_REQUEST_TIMEOUT_MS),255        ),256      ]);257      const text = new TextDecoder().decode(frame);258      const message = decodeSyncMessage(text);259      if (message.type !== "PAIR_REQUEST") {260        throw new Error(`unexpected frame: ${message.type}`);261      }262      pairRequest = message;263    } catch (err) {264      console.warn("[peach-pair] PAIR_REQUEST read failed:", err);265      void stop();266      return;267    }268269    // The 6-digit token IS the user's consent — auto-accept on the showing270    // side. The Noise+auth round above already proved the initiator owns271    // the public key in PAIR_REQUEST; cross-check it with auth here.272    const claimedHex = pairRequest.public_key_hex.toLowerCase();273    const authedHex = bytesToHex(initiatorPublicKey);274    if (claimedHex !== authedHex) {275      console.warn(276        "[peach-pair] PAIR_REQUEST public_key_hex did not match authenticated key",277      );278      void stop();279      return;280    }281282    deps.onPairRequest?.(pairRequest.device_name);283284    try {285      await sendEnc(286        new TextEncoder().encode(287          encodeSyncMessage({288            type: "PAIR_ACCEPT",289            public_key_hex: bytesToHex(deps.publicKey),290            device_name: deps.deviceName,291          }),292        ),293      );294    } catch (err) {295      console.warn("[peach-pair] PAIR_ACCEPT send failed:", err);296      void stop();297      return;298    }299300    try {301      await deps.onPaired({302        deviceId: pairRequest.public_key_hex,303        displayName: pairRequest.device_name,304        publicKeyHex: pairRequest.public_key_hex,305      });306    } catch (err) {307      console.warn("[peach-pair] onPaired callback failed:", err);308    }309310    // One code = one pairing. Drop the connection so a second peer can't311    // sneak in on the same window.312    void stop();313  }314315  async function stop(): Promise<void> {316    if (stopped) return;317    stopped = true;318    try { await chunkHandle.remove(); } catch { /* best-effort */ }319    try { await endHandle.remove(); } catch { /* best-effort */ }320    try { await connectedHandle.remove(); } catch { /* best-effort */ }321    try { await Sync.stopPairingServer(); } catch { /* best-effort */ }322    connectionClosed = true;323    closeReason = closeReason ?? new Error("pairing server stopped");324    failPendingWaiters(closeReason);325  }326327  return { stop };328}329330function bytesToHex(bytes: Uint8Array): string {331  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");332}