sync.ts

13.7 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" };3940type HostPeer = {41  host: string;42  port: number;43  ws_port?: number | null;44  x25519_public_key: string;45  device_id: string;46  name: string;47  pairing?: boolean;48};4950type HostTrustedDevice = {51  public_key_hex: string;52  device_name: string;53  paired_at: number;54  last_seen: number;55};5657type HostResponse =58  | { type: "IDENTITY"; public_key_hex: string }59  | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }60  | { type: "TRUST_DEVICE_OK" }61  | { type: "REMOVE_TRUSTED_DEVICE_OK" }62  | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }63  | { type: "ADVERTISING"; port: number; ws_port?: number | null }64  | { type: "STOP_ADVERTISING_OK" }65  | { type: "PEER_DISCOVERED"; peer: HostPeer }66  | { type: "PEER_LOST"; device_id: string }67  | { type: "STOP_DISCOVERY_OK" }68  | { type: "CONNECTED"; connection_id: string }69  | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string }70  | { type: "DISCONNECTED"; connection_id: string }71  | { type: "SYNC_RECEIVED"; connection_id: string; data: string }72  | { type: "SESSION_KEY"; connection_id: string; key: string }73  | {74      type: "STATUS";75      advertising: boolean;76      tcp_port?: number | null;77      ws_port?: number | null;78      connections: string[];79    }80  | { type: "ERROR"; connection_id?: string | null; message: string };8182export interface PeerInfo {83  host: string;84  port: number;85  /**86   * WebSocket port advertised in mDNS `wsport`. Mobile clients (Android via87   * Capacitor) connect through this; extension/desktop clients ignore it and88   * use the raw TCP `port` instead.89   */90  wsPort?: number;91  name: string;92  pairing: boolean;93  deviceId: string;94  x25519PublicKey: string;95}9697export interface NativeTrustedDevice {98  publicKeyHex: string;99  deviceName: string;100  pairedAt: number;101  lastSeen: number;102}103104export interface NativeSyncStatus {105  advertising: boolean;106  tcpPort?: number;107  wsPort?: number;108  connections: string[];109}110111type ExpectedType = HostResponse["type"];112type PendingRequest<T extends HostResponse = HostResponse> = {113  expected: readonly ExpectedType[];114  resolve: (response: T) => void;115  reject: (error: Error) => void;116  timeout: ReturnType<typeof setTimeout>;117};118119let syncPort: chrome.runtime.Port | null = null;120let pendingRequests: PendingRequest[] = [];121122const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];123const peerLostHandlers: Array<(deviceId: string) => void> = [];124const connectedHandlers: Array<(connectionId: string) => void> = [];125const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = [];126const disconnectedHandlers: Array<(connectionId?: string) => void> = [];127const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];128const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];129130function handleHostMessage(response: HostResponse): void {131  switch (response.type) {132    case "PEER_DISCOVERED": {133      const peer = mapPeer(response.peer);134      for (const handler of peerDiscoveredHandlers) handler(peer);135      break;136    }137    case "PEER_LOST": {138      for (const handler of peerLostHandlers) handler(response.device_id);139      break;140    }141    case "PEER_CONNECTED": {142      for (const handler of incomingConnectionHandlers) {143        handler(response.connection_id, response.peer_addr);144      }145      break;146    }147    case "DISCONNECTED": {148      for (const handler of disconnectedHandlers) handler(response.connection_id);149      break;150    }151    case "SYNC_RECEIVED": {152      for (const handler of syncMessageHandlers) {153        handler(response.connection_id, response.data);154      }155      break;156    }157    case "ERROR": {158      rejectPending(response.message, response.connection_id ?? undefined);159      for (const handler of errorHandlers) {160        handler(response.message, response.connection_id ?? undefined);161      }162      break;163    }164    default:165      resolveExpected(response);166      if (response.type === "CONNECTED") {167        for (const handler of connectedHandlers) handler(response.connection_id);168      }169      break;170  }171}172173function resolveExpected(response: HostResponse): void {174  const index = pendingRequests.findIndex((pending) =>175    pending.expected.includes(response.type)176  );177  if (index === -1) return;178179  const [pending] = pendingRequests.splice(index, 1);180  clearTimeout(pending.timeout);181  pending.resolve(response);182}183184function rejectPending(message: string, connectionId?: string): void {185  const index = pendingRequests.findIndex((pending) =>186    connectionId ? pending.expected.includes("CONNECTED") : true187  );188  if (index === -1) return;189190  const [pending] = pendingRequests.splice(index, 1);191  clearTimeout(pending.timeout);192  pending.reject(new Error(message));193}194195function rejectAllPending(message: string): void {196  const pending = pendingRequests;197  pendingRequests = [];198  for (const request of pending) {199    clearTimeout(request.timeout);200    request.reject(new Error(message));201  }202}203204function sendToHost(msg: HostMessage): void {205  if (!syncPort) throw new Error("sync native host is not connected");206  syncPort.postMessage(msg);207}208209function requestFromHost<T extends HostResponse>(210  msg: HostMessage,211  expected: readonly ExpectedType[],212  timeoutMs = 5000213): Promise<T> {214  if (!syncPort) connectSyncHost();215216  return new Promise<T>((resolve, reject) => {217    const pending: PendingRequest<T> = {218      expected,219      resolve,220      reject,221      timeout: setTimeout(() => {222        pendingRequests = pendingRequests.filter((entry) => entry !== pending);223        reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));224      }, timeoutMs),225    };226227    pendingRequests.push(pending as PendingRequest);228    try {229      sendToHost(msg);230    } catch (err) {231      pendingRequests = pendingRequests.filter((entry) => entry !== pending);232      clearTimeout(pending.timeout);233      reject(err instanceof Error ? err : new Error(String(err)));234    }235  });236}237238export function connectSyncHost(): void {239  if (syncPort) return;240  if (!chrome.runtime.connectNative) {241    throw new Error("native messaging is not available in this browser context");242  }243244  syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);245  syncPort.onMessage.addListener(handleHostMessage);246  syncPort.onDisconnect.addListener(() => {247    syncPort = null;248    rejectAllPending("sync native host disconnected");249    for (const handler of disconnectedHandlers) handler();250  });251}252253export function disconnectSyncHost(): void {254  const port = syncPort;255  syncPort = null;256  rejectAllPending("sync native host disconnected");257  port?.disconnect();258}259260export function isHostConnected(): boolean {261  return syncPort !== null;262}263264export async function getNativeIdentity(): Promise<string> {265  const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(266    { type: "GET_IDENTITY" },267    ["IDENTITY"]268  );269  return response.public_key_hex;270}271272export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {273  const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(274    { type: "GET_TRUSTED_DEVICES" },275    ["TRUSTED_DEVICES"]276  );277  return response.devices.map(mapTrustedDevice);278}279280export async function trustDevice(281  publicKeyHex: string,282  deviceName: string283): Promise<void> {284  await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [285    "TRUST_DEVICE_OK",286  ]);287}288289export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {290  await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [291    "REMOVE_TRUSTED_DEVICE_OK",292  ]);293}294295export async function rotateNativeIdentity(): Promise<string> {296  const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(297    { type: "ROTATE_IDENTITY" },298    ["ROTATE_IDENTITY_OK"]299  );300  return response.public_key_hex;301}302303export async function advertise(options: {304  deviceId: string;305  name: string;306  x25519PublicKey?: string;307  pairing?: boolean;308  port?: number;309}): Promise<number> {310  const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(311    {312      type: "ADVERTISE",313      x25519_public_key: options.x25519PublicKey ?? "",314      device_id: options.deviceId,315      name: options.name,316      pairing: options.pairing ?? false,317      port: options.port ?? 0,318    },319    ["ADVERTISING"]320  );321  return response.port;322}323324export async function stopAdvertising(): Promise<void> {325  await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);326}327328export function discoverPeers(): void {329  sendToHost({ type: "DISCOVER" });330}331332export async function stopDiscovery(): Promise<void> {333  await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);334}335336export async function connectToPeer(337  host: string,338  port: number,339  remoteX25519PublicKey: string340): Promise<string> {341  const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(342    {343      type: "CONNECT",344      host,345      port,346      remote_x25519_public_key: remoteX25519PublicKey,347      // The Rust host owns the private identity now and ignores this legacy348      // field, so the extension must never send private key material here.349      local_x25519_private_key: "",350    },351    ["CONNECTED"]352  );353  return response.connection_id;354}355356export function disconnectFromPeer(connectionId: string): void {357  sendToHost({ type: "DISCONNECT", connection_id: connectionId });358}359360export function sendSyncData(connectionId: string, data: string): void {361  sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });362}363364export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {365  const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(366    { type: "GET_SESSION_KEY", connection_id: connectionId },367    ["SESSION_KEY"]368  );369  if (response.connection_id !== connectionId) {370    throw new Error("sync native host returned session key for unexpected connection");371  }372  const decoded = decodeBase64Url(response.key);373  if (decoded.length !== 32) {374    throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);375  }376  return decoded;377}378379function decodeBase64Url(value: string): Uint8Array {380  const padded = value381    .replace(/-/g, "+")382    .replace(/_/g, "/")383    .padEnd(Math.ceil(value.length / 4) * 4, "=");384  const binary = atob(padded);385  const out = new Uint8Array(binary.length);386  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);387  return out;388}389390export async function getNativeStatus(): Promise<NativeSyncStatus> {391  const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(392    { type: "STATUS" },393    ["STATUS"]394  );395  return {396    advertising: response.advertising,397    tcpPort: response.tcp_port ?? undefined,398    wsPort: response.ws_port ?? undefined,399    connections: response.connections,400  };401}402403export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {404  return subscribe(peerDiscoveredHandlers, handler);405}406407export function onPeerLost(handler: (deviceId: string) => void): () => void {408  return subscribe(peerLostHandlers, handler);409}410411export function onConnected(handler: (connectionId: string) => void): () => void {412  return subscribe(connectedHandlers, handler);413}414415export function onIncomingConnection(416  handler: (connectionId: string, peerAddr: string) => void417): () => void {418  return subscribe(incomingConnectionHandlers, handler);419}420421export function onDisconnected(handler: (connectionId?: string) => void): () => void {422  return subscribe(disconnectedHandlers, handler);423}424425export function onSyncMessage(426  handler: (connectionId: string, data: string) => void427): () => void {428  return subscribe(syncMessageHandlers, handler);429}430431export function onError(432  handler: (error: string, connectionId?: string) => void433): () => void {434  return subscribe(errorHandlers, handler);435}436437function subscribe<T>(handlers: T[], handler: T): () => void {438  handlers.push(handler);439  return () => {440    const index = handlers.indexOf(handler);441    if (index >= 0) handlers.splice(index, 1);442  };443}444445function mapPeer(peer: HostPeer): PeerInfo {446  return {447    host: peer.host,448    port: peer.port,449    wsPort: peer.ws_port ?? undefined,450    name: peer.name,451    pairing: peer.pairing ?? false,452    deviceId: peer.device_id,453    x25519PublicKey: peer.x25519_public_key,454  };455}456457function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {458  return {459    publicKeyHex: device.public_key_hex,460    deviceName: device.device_name,461    pairedAt: device.paired_at,462    lastSeen: device.last_seen,463  };464}