sync.ts

16.4 KB · raw

1/**2 * GATED — peach-sync native host client.3 *4 * This module mirrors the Rust host protocol but is still not imported by any5 * live background path. Keep `nativeMessaging` out of the manifests until the6 * SYN-001 audit/gate checklist passes and the installer manifest exists.7 */89export const SYNC_NATIVE_HOST_NAME = "com.peachpasswords.sync";1011type HostMessage =12  | { type: "GET_IDENTITY" }13  | { type: "GET_TRUSTED_DEVICES" }14  | { type: "TRUST_DEVICE"; public_key_hex: string; device_name: string }15  | { type: "REMOVE_TRUSTED_DEVICE"; public_key_hex: string }16  | { type: "ROTATE_IDENTITY" }17  | {18      type: "ADVERTISE";19      x25519_public_key: string;20      device_id: string;21      name: string;22      pairing?: boolean;23      port?: number;24    }25  | { type: "STOP_ADVERTISING" }26  | { type: "DISCOVER" }27  | { type: "STOP_DISCOVERY" }28  | {29      type: "CONNECT";30      host: string;31      port: number;32      remote_x25519_public_key: string;33      local_x25519_private_key: string;34    }35  | { type: "DISCONNECT"; connection_id: string }36  | { type: "SYNC_SEND"; connection_id: string; data: string }37  | { type: "GET_SESSION_KEY"; connection_id: string }38  | { type: "STATUS" }39  | { type: "ENTER_PAIRING_MODE" }40  | { type: "EXIT_PAIRING_MODE" }41  | { type: "START_PAIRING_SERVER"; token: string; device_id: string; name: string }42  | { type: "STOP_PAIRING_SERVER" }43  | { type: "RESOLVE_PEER_BY_CODE"; token: string; timeout_ms?: number };4445type HostPeer = {46  host: string;47  port: number;48  ws_port?: number | null;49  x25519_public_key: string;50  device_id: string;51  name: string;52  pairing?: boolean;53};5455type HostTrustedDevice = {56  public_key_hex: string;57  device_name: string;58  paired_at: number;59  last_seen: number;60};6162type HostResponse =63  | { type: "IDENTITY"; public_key_hex: string }64  | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }65  | { type: "TRUST_DEVICE_OK" }66  | { type: "REMOVE_TRUSTED_DEVICE_OK" }67  | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }68  | { type: "ADVERTISING"; port: number; ws_port?: number | null }69  | { type: "STOP_ADVERTISING_OK" }70  | { type: "PEER_DISCOVERED"; peer: HostPeer }71  | { type: "PEER_LOST"; device_id: string }72  | { type: "STOP_DISCOVERY_OK" }73  | { type: "CONNECTED"; connection_id: string }74  | {75      type: "PEER_CONNECTED";76      connection_id: string;77      peer_addr: string;78      is_pairing_only?: boolean;79      peer_public_key_hex?: string;80    }81  | { type: "DISCONNECTED"; connection_id: string }82  | { type: "SYNC_RECEIVED"; connection_id: string; data: string }83  | { type: "SESSION_KEY"; connection_id: string; key: string }84  | {85      type: "STATUS";86      advertising: boolean;87      tcp_port?: number | null;88      ws_port?: number | null;89      connections: string[];90    }91  | { type: "ERROR"; connection_id?: string | null; message: string }92  | { type: "PAIRING_MODE_ENTERED" }93  | { type: "PAIRING_MODE_EXITED" }94  | { type: "PAIRING_SERVER_STARTED"; tcp_port: number; ws_port?: number | null }95  | { type: "STOP_PAIRING_SERVER_OK" }96  | { type: "PEER_RESOLVED"; peer: HostPeer };9798export interface PeerInfo {99  host: string;100  port: number;101  /**102   * WebSocket port advertised in mDNS `wsport`. Mobile clients (Android via103   * Capacitor) connect through this; extension/desktop clients ignore it and104   * use the raw TCP `port` instead.105   */106  wsPort?: number;107  name: string;108  pairing: boolean;109  deviceId: string;110  x25519PublicKey: string;111}112113export interface NativeTrustedDevice {114  publicKeyHex: string;115  deviceName: string;116  pairedAt: number;117  lastSeen: number;118}119120export interface NativeSyncStatus {121  advertising: boolean;122  tcpPort?: number;123  wsPort?: number;124  connections: string[];125}126127type ExpectedType = HostResponse["type"];128type PendingRequest<T extends HostResponse = HostResponse> = {129  expected: readonly ExpectedType[];130  resolve: (response: T) => void;131  reject: (error: Error) => void;132  timeout: ReturnType<typeof setTimeout>;133};134135let syncPort: chrome.runtime.Port | null = null;136let pendingRequests: PendingRequest[] = [];137138const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];139const peerLostHandlers: Array<(deviceId: string) => void> = [];140const connectedHandlers: Array<(connectionId: string) => void> = [];141export interface IncomingConnectionInfo {142  connectionId: string;143  peerAddr: string;144  /** True when admitted only because pairing mode was active. */145  isPairingOnly: boolean;146  /** Hex-encoded x25519 public key the initiator authenticated with. */147  peerPublicKeyHex?: string;148}149150const incomingConnectionHandlers: Array<(info: IncomingConnectionInfo) => void> = [];151const disconnectedHandlers: Array<(connectionId?: string) => void> = [];152const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];153const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];154155function handleHostMessage(response: HostResponse): void {156  switch (response.type) {157    case "PEER_DISCOVERED": {158      const peer = mapPeer(response.peer);159      for (const handler of peerDiscoveredHandlers) handler(peer);160      break;161    }162    case "PEER_LOST": {163      for (const handler of peerLostHandlers) handler(response.device_id);164      break;165    }166    case "PEER_CONNECTED": {167      const info: IncomingConnectionInfo = {168        connectionId: response.connection_id,169        peerAddr: response.peer_addr,170        isPairingOnly: response.is_pairing_only === true,171        peerPublicKeyHex: response.peer_public_key_hex,172      };173      for (const handler of incomingConnectionHandlers) handler(info);174      break;175    }176    case "DISCONNECTED": {177      for (const handler of disconnectedHandlers) handler(response.connection_id);178      break;179    }180    case "SYNC_RECEIVED": {181      for (const handler of syncMessageHandlers) {182        handler(response.connection_id, response.data);183      }184      break;185    }186    case "ERROR": {187      rejectPending(response.message, response.connection_id ?? undefined);188      for (const handler of errorHandlers) {189        handler(response.message, response.connection_id ?? undefined);190      }191      break;192    }193    default:194      resolveExpected(response);195      if (response.type === "CONNECTED") {196        for (const handler of connectedHandlers) handler(response.connection_id);197      }198      break;199  }200}201202function resolveExpected(response: HostResponse): void {203  const index = pendingRequests.findIndex((pending) =>204    pending.expected.includes(response.type)205  );206  if (index === -1) return;207208  const [pending] = pendingRequests.splice(index, 1);209  clearTimeout(pending.timeout);210  pending.resolve(response);211}212213function rejectPending(message: string, connectionId?: string): void {214  const index = pendingRequests.findIndex((pending) =>215    connectionId ? pending.expected.includes("CONNECTED") : true216  );217  if (index === -1) return;218219  const [pending] = pendingRequests.splice(index, 1);220  clearTimeout(pending.timeout);221  pending.reject(new Error(message));222}223224function rejectAllPending(message: string): void {225  const pending = pendingRequests;226  pendingRequests = [];227  for (const request of pending) {228    clearTimeout(request.timeout);229    request.reject(new Error(message));230  }231}232233function sendToHost(msg: HostMessage): void {234  if (!syncPort) throw new Error("sync native host is not connected");235  syncPort.postMessage(msg);236}237238function requestFromHost<T extends HostResponse>(239  msg: HostMessage,240  expected: readonly ExpectedType[],241  timeoutMs = 5000242): Promise<T> {243  if (!syncPort) connectSyncHost();244245  return new Promise<T>((resolve, reject) => {246    const pending: PendingRequest<T> = {247      expected,248      resolve,249      reject,250      timeout: setTimeout(() => {251        pendingRequests = pendingRequests.filter((entry) => entry !== pending);252        reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));253      }, timeoutMs),254    };255256    pendingRequests.push(pending as PendingRequest);257    try {258      sendToHost(msg);259    } catch (err) {260      pendingRequests = pendingRequests.filter((entry) => entry !== pending);261      clearTimeout(pending.timeout);262      reject(err instanceof Error ? err : new Error(String(err)));263    }264  });265}266267export function connectSyncHost(): void {268  if (syncPort) return;269  if (!chrome.runtime.connectNative) {270    throw new Error("native messaging is not available in this browser context");271  }272273  syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);274  syncPort.onMessage.addListener(handleHostMessage);275  syncPort.onDisconnect.addListener(() => {276    syncPort = null;277    rejectAllPending("sync native host disconnected");278    for (const handler of disconnectedHandlers) handler();279  });280}281282export function disconnectSyncHost(): void {283  const port = syncPort;284  syncPort = null;285  rejectAllPending("sync native host disconnected");286  port?.disconnect();287}288289export function isHostConnected(): boolean {290  return syncPort !== null;291}292293export async function getNativeIdentity(): Promise<string> {294  const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(295    { type: "GET_IDENTITY" },296    ["IDENTITY"]297  );298  return response.public_key_hex;299}300301export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {302  const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(303    { type: "GET_TRUSTED_DEVICES" },304    ["TRUSTED_DEVICES"]305  );306  return response.devices.map(mapTrustedDevice);307}308309export async function trustDevice(310  publicKeyHex: string,311  deviceName: string312): Promise<void> {313  await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [314    "TRUST_DEVICE_OK",315  ]);316}317318export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {319  await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [320    "REMOVE_TRUSTED_DEVICE_OK",321  ]);322}323324export async function rotateNativeIdentity(): Promise<string> {325  const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(326    { type: "ROTATE_IDENTITY" },327    ["ROTATE_IDENTITY_OK"]328  );329  return response.public_key_hex;330}331332export async function advertise(options: {333  deviceId: string;334  name: string;335  x25519PublicKey?: string;336  pairing?: boolean;337  port?: number;338}): Promise<number> {339  const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(340    {341      type: "ADVERTISE",342      x25519_public_key: options.x25519PublicKey ?? "",343      device_id: options.deviceId,344      name: options.name,345      pairing: options.pairing ?? false,346      port: options.port ?? 0,347    },348    ["ADVERTISING"]349  );350  return response.port;351}352353export async function stopAdvertising(): Promise<void> {354  await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);355}356357export function discoverPeers(): void {358  sendToHost({ type: "DISCOVER" });359}360361export async function stopDiscovery(): Promise<void> {362  await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);363}364365export async function connectToPeer(366  host: string,367  port: number,368  remoteX25519PublicKey: string369): Promise<string> {370  const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(371    {372      type: "CONNECT",373      host,374      port,375      remote_x25519_public_key: remoteX25519PublicKey,376      // The Rust host owns the private identity now and ignores this legacy377      // field, so the extension must never send private key material here.378      local_x25519_private_key: "",379    },380    ["CONNECTED"]381  );382  return response.connection_id;383}384385export function disconnectFromPeer(connectionId: string): void {386  sendToHost({ type: "DISCONNECT", connection_id: connectionId });387}388389export function sendSyncData(connectionId: string, data: string): void {390  sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });391}392393export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {394  const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(395    { type: "GET_SESSION_KEY", connection_id: connectionId },396    ["SESSION_KEY"]397  );398  if (response.connection_id !== connectionId) {399    throw new Error("sync native host returned session key for unexpected connection");400  }401  const decoded = decodeBase64Url(response.key);402  if (decoded.length !== 32) {403    throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);404  }405  return decoded;406}407408function decodeBase64Url(value: string): Uint8Array {409  const padded = value410    .replace(/-/g, "+")411    .replace(/_/g, "/")412    .padEnd(Math.ceil(value.length / 4) * 4, "=");413  const binary = atob(padded);414  const out = new Uint8Array(binary.length);415  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);416  return out;417}418419export async function enterPairingMode(): Promise<void> {420  await requestFromHost({ type: "ENTER_PAIRING_MODE" }, ["PAIRING_MODE_ENTERED"]);421}422423export async function exitPairingMode(): Promise<void> {424  await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);425}426427export interface PairingServerInfo {428  tcpPort: number;429  wsPort?: number;430}431432export async function startPairingServer(options: {433  token: string;434  deviceId: string;435  name: string;436}): Promise<PairingServerInfo> {437  const response = await requestFromHost<Extract<HostResponse, { type: "PAIRING_SERVER_STARTED" }>>(438    {439      type: "START_PAIRING_SERVER",440      token: options.token,441      device_id: options.deviceId,442      name: options.name,443    },444    ["PAIRING_SERVER_STARTED"],445    15_000,446  );447  return {448    tcpPort: response.tcp_port,449    wsPort: response.ws_port ?? undefined,450  };451}452453export async function stopPairingServer(): Promise<void> {454  await requestFromHost({ type: "STOP_PAIRING_SERVER" }, ["STOP_PAIRING_SERVER_OK"]);455}456457export async function resolvePeerByCode(458  token: string,459  timeoutMs = 8_000,460): Promise<PeerInfo> {461  const response = await requestFromHost<Extract<HostResponse, { type: "PEER_RESOLVED" }>>(462    { type: "RESOLVE_PEER_BY_CODE", token, timeout_ms: timeoutMs },463    ["PEER_RESOLVED"],464    timeoutMs + 2_000,465  );466  return mapPeer(response.peer);467}468469/**470 * The native host's static x25519 public key (this device's sync identity).471 * Initiators include this in PAIR_REQUEST so the responder can record us in472 * its trust store after the user accepts; responders do likewise in473 * PAIR_ACCEPT.474 */475export async function getIdentityPublicKey(): Promise<string> {476  return getNativeIdentity();477}478479export async function getNativeStatus(): Promise<NativeSyncStatus> {480  const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(481    { type: "STATUS" },482    ["STATUS"]483  );484  return {485    advertising: response.advertising,486    tcpPort: response.tcp_port ?? undefined,487    wsPort: response.ws_port ?? undefined,488    connections: response.connections,489  };490}491492export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {493  return subscribe(peerDiscoveredHandlers, handler);494}495496export function onPeerLost(handler: (deviceId: string) => void): () => void {497  return subscribe(peerLostHandlers, handler);498}499500export function onConnected(handler: (connectionId: string) => void): () => void {501  return subscribe(connectedHandlers, handler);502}503504export function onIncomingConnection(505  handler: (info: IncomingConnectionInfo) => void506): () => void {507  return subscribe(incomingConnectionHandlers, handler);508}509510export function onDisconnected(handler: (connectionId?: string) => void): () => void {511  return subscribe(disconnectedHandlers, handler);512}513514export function onSyncMessage(515  handler: (connectionId: string, data: string) => void516): () => void {517  return subscribe(syncMessageHandlers, handler);518}519520export function onError(521  handler: (error: string, connectionId?: string) => void522): () => void {523  return subscribe(errorHandlers, handler);524}525526function subscribe<T>(handlers: T[], handler: T): () => void {527  handlers.push(handler);528  return () => {529    const index = handlers.indexOf(handler);530    if (index >= 0) handlers.splice(index, 1);531  };532}533534function mapPeer(peer: HostPeer): PeerInfo {535  return {536    host: peer.host,537    port: peer.port,538    wsPort: peer.ws_port ?? undefined,539    name: peer.name,540    pairing: peer.pairing ?? false,541    deviceId: peer.device_id,542    x25519PublicKey: peer.x25519_public_key,543  };544}545546function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {547  return {548    publicKeyHex: device.public_key_hex,549    deviceName: device.device_name,550    pairedAt: device.paired_at,551    lastSeen: device.last_seen,552  };553}