capacitor-sync-transport.ts

7.4 KB · raw

1/**2 * Capacitor (Android) sync transport.3 *4 * The Android WebView cannot use the native messaging host (stdio is for5 * extension service workers only) and cannot open raw TCP sockets. The6 * desktop host exposes the same Noise NK protocol over WebSocket at7 * `tcp_port + 1` so this side speaks WebSocket binary frames carrying the8 * exact same bytes as the TCP path.9 *10 * Bytes-on-the-wire MUST stay identical to the native TCP/WS framing — see11 * `native/peach-sync-host/src/frame.rs`. A WebSocket Binary message carries12 * one logical Noise/auth/transport frame; the u32 length prefix used on TCP13 * is absent here because tungstenite already frames messages.14 */1516import { noiseNKInitiator, performInitiatorAuth, type NoiseNKSession } from "./noise-js";17import type { SyncSessionTransport } from "../background/sync-coordinator";18import type { DiscoveredPeer } from "./sync-types";1920const HANDSHAKE_TIMEOUT_MS = 10_000;2122function hexToBytes(hex: string): Uint8Array {23  if (hex.length % 2 !== 0) throw new Error("invalid hex");24  const out = new Uint8Array(hex.length / 2);25  for (let i = 0; i < hex.length; i += 2) {26    out[i / 2] = parseInt(hex.slice(i, i + 2), 16);27  }28  return out;29}3031function base64urlEncode(bytes: Uint8Array): string {32  let binary = "";33  for (const byte of bytes) binary += String.fromCharCode(byte);34  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");35}3637function base64urlDecode(value: string): Uint8Array {38  const padded = value39    .replace(/-/g, "+")40    .replace(/_/g, "/")41    .padEnd(Math.ceil(value.length / 4) * 4, "=");42  const binary = atob(padded);43  const out = new Uint8Array(binary.length);44  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);45  return out;46}4748/**49 * Open a WebSocket to the peer, complete the Noise NK handshake + auth, and50 * return a SyncSessionTransport that runBlobSyncSession can drive.51 */52export async function createCapacitorSyncTransport(53  peer: DiscoveredPeer,54  ourPrivateKey: Uint8Array,55  ourPublicKey: Uint8Array,56): Promise<SyncSessionTransport> {57  if (peer.wsPort === undefined) {58    throw new Error("peer did not advertise a WebSocket port");59  }60  const wsUrl = `ws://${peer.host}:${peer.wsPort}/`;6162  const ws = await new Promise<WebSocket>((resolve, reject) => {63    const socket = new WebSocket(wsUrl);64    socket.binaryType = "arraybuffer";65    const timer = setTimeout(66      () => {67        try { socket.close(); } catch { /* best-effort */ }68        reject(new Error("WebSocket open timeout"));69      },70      HANDSHAKE_TIMEOUT_MS,71    );72    socket.addEventListener("open", () => {73      clearTimeout(timer);74      resolve(socket);75    });76    socket.addEventListener("error", () => {77      clearTimeout(timer);78      reject(new Error(`Cannot connect to ${wsUrl}`));79    });80  });8182  // Single-message receive queue. The Noise handshake / auth round-trips83  // alternate sends and receives in a strict order; any frame that arrives84  // while no pending receiver exists is queued for the next caller.85  const queue: Uint8Array[] = [];86  let pending: ((data: Uint8Array) => void) | null = null;87  let pendingReject: ((err: Error) => void) | null = null;88  let closed = false;89  let lastError: string | null = null;9091  ws.addEventListener("message", (event) => {92    const data = new Uint8Array(event.data as ArrayBuffer);93    if (pending) {94      const resolve = pending;95      pending = null;96      pendingReject = null;97      resolve(data);98    } else {99      queue.push(data);100    }101  });102  ws.addEventListener("close", () => {103    closed = true;104    if (pendingReject) {105      const reject = pendingReject;106      pending = null;107      pendingReject = null;108      reject(new Error("WebSocket closed during handshake"));109    }110  });111  ws.addEventListener("error", () => {112    lastError = "websocket_error";113    if (pendingReject) {114      const reject = pendingReject;115      pending = null;116      pendingReject = null;117      reject(new Error("WebSocket error"));118    }119  });120121  function rawSend(bytes: Uint8Array): Promise<void> {122    if (closed) return Promise.reject(new Error("WebSocket closed"));123    // Force a fresh ArrayBuffer copy so the WebSocket type checker doesn't124    // hit the SharedArrayBuffer-or-ArrayBuffer ambiguity that some Uint8Array125    // typings expose.126    const copy = new Uint8Array(bytes);127    ws.send(copy.buffer);128    return Promise.resolve();129  }130131  function rawReceive(): Promise<Uint8Array> {132    if (queue.length > 0) return Promise.resolve(queue.shift()!);133    if (closed) return Promise.reject(new Error("WebSocket closed"));134    return new Promise<Uint8Array>((resolve, reject) => {135      pending = resolve;136      pendingReject = reject;137    });138  }139140  const responderPublicKey = hexToBytes(peer.x25519PublicKey);141142  // 1) Noise NK handshake bootstrap143  const session: NoiseNKSession = await noiseNKInitiator(144    responderPublicKey,145    rawSend,146    rawReceive,147  );148149  // 2) Auth challenge/proof — encrypted via the post-handshake transport150  const sendEncrypted = async (data: Uint8Array): Promise<void> => {151    await rawSend(session.encrypt(data));152  };153  const receiveEncrypted = async (): Promise<Uint8Array> => {154    return session.decrypt(await rawReceive());155  };156157  await performInitiatorAuth(158    session,159    ourPrivateKey,160    ourPublicKey,161    responderPublicKey,162    sendEncrypted,163    receiveEncrypted,164  );165166  // 3) Hand off to runBlobSyncSession via SyncSessionTransport.167  // The session-coordinator framing is base64url-encoded JSON over Noise.168  // We bridge it: outgoing frame string → bytes(base64url-decode) → encrypt169  // → ws.send. Incoming ws.message → decrypt → bytes → base64url-encode →170  // hand to onFrame handlers as a string.171  const frameHandlers: Array<(frame: string) => void> = [];172  const errorHandlers: Array<(message: string) => void> = [];173  const closeHandlers: Array<() => void> = [];174175  // Replace the message listener with one that decrypts every frame and176  // routes it to onFrame. Drain anything queued from the auth round.177  ws.addEventListener("message", (event) => {178    try {179      const cipher = new Uint8Array(event.data as ArrayBuffer);180      const plain = session.decrypt(cipher);181      const frame = base64urlEncode(plain);182      for (const h of frameHandlers) h(frame);183    } catch (err) {184      const msg = err instanceof Error ? err.message : String(err);185      for (const h of errorHandlers) h(msg);186    }187  });188  ws.addEventListener("close", () => {189    for (const h of closeHandlers) h();190  });191  ws.addEventListener("error", () => {192    const msg = lastError || "websocket_error";193    for (const h of errorHandlers) h(msg);194  });195196  return {197    send: async (frame: string) => {198      const plain = base64urlDecode(frame);199      await rawSend(session.encrypt(plain));200    },201    onFrame: (handler) => {202      frameHandlers.push(handler);203      return () => {204        const i = frameHandlers.indexOf(handler);205        if (i >= 0) frameHandlers.splice(i, 1);206      };207    },208    onError: (handler) => {209      errorHandlers.push(handler);210      return () => {211        const i = errorHandlers.indexOf(handler);212        if (i >= 0) errorHandlers.splice(i, 1);213      };214    },215    onClose: (handler) => {216      closeHandlers.push(handler);217      return () => {218        const i = closeHandlers.indexOf(handler);219        if (i >= 0) closeHandlers.splice(i, 1);220      };221    },222    close: () => {223      try { ws.close(); } catch { /* best-effort */ }224    },225  };226}