sync.ts

12.2 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: "STATUS" };3839type HostPeer = {40  host: string;41  port: number;42  x25519_public_key: string;43  device_id: string;44  name: string;45  pairing?: boolean;46};4748type HostTrustedDevice = {49  public_key_hex: string;50  device_name: string;51  paired_at: number;52  last_seen: number;53};5455type HostResponse =56  | { type: "IDENTITY"; public_key_hex: string }57  | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }58  | { type: "TRUST_DEVICE_OK" }59  | { type: "REMOVE_TRUSTED_DEVICE_OK" }60  | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }61  | { type: "ADVERTISING"; port: number }62  | { type: "STOP_ADVERTISING_OK" }63  | { type: "PEER_DISCOVERED"; peer: HostPeer }64  | { type: "PEER_LOST"; device_id: string }65  | { type: "STOP_DISCOVERY_OK" }66  | { type: "CONNECTED"; connection_id: string }67  | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string }68  | { type: "DISCONNECTED"; connection_id: string }69  | { type: "SYNC_RECEIVED"; connection_id: string; data: string }70  | { type: "STATUS"; advertising: boolean; tcp_port?: number | null; connections: string[] }71  | { type: "ERROR"; connection_id?: string | null; message: string };7273export interface PeerInfo {74  host: string;75  port: number;76  name: string;77  pairing: boolean;78  deviceId: string;79  x25519PublicKey: string;80}8182export interface NativeTrustedDevice {83  publicKeyHex: string;84  deviceName: string;85  pairedAt: number;86  lastSeen: number;87}8889export interface NativeSyncStatus {90  advertising: boolean;91  tcpPort?: number;92  connections: string[];93}9495type ExpectedType = HostResponse["type"];96type PendingRequest<T extends HostResponse = HostResponse> = {97  expected: readonly ExpectedType[];98  resolve: (response: T) => void;99  reject: (error: Error) => void;100  timeout: ReturnType<typeof setTimeout>;101};102103let syncPort: chrome.runtime.Port | null = null;104let pendingRequests: PendingRequest[] = [];105106const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];107const peerLostHandlers: Array<(deviceId: string) => void> = [];108const connectedHandlers: Array<(connectionId: string) => void> = [];109const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = [];110const disconnectedHandlers: Array<(connectionId?: string) => void> = [];111const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];112const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];113114function handleHostMessage(response: HostResponse): void {115  switch (response.type) {116    case "PEER_DISCOVERED": {117      const peer = mapPeer(response.peer);118      for (const handler of peerDiscoveredHandlers) handler(peer);119      break;120    }121    case "PEER_LOST": {122      for (const handler of peerLostHandlers) handler(response.device_id);123      break;124    }125    case "PEER_CONNECTED": {126      for (const handler of incomingConnectionHandlers) {127        handler(response.connection_id, response.peer_addr);128      }129      break;130    }131    case "DISCONNECTED": {132      for (const handler of disconnectedHandlers) handler(response.connection_id);133      break;134    }135    case "SYNC_RECEIVED": {136      for (const handler of syncMessageHandlers) {137        handler(response.connection_id, response.data);138      }139      break;140    }141    case "ERROR": {142      rejectPending(response.message, response.connection_id ?? undefined);143      for (const handler of errorHandlers) {144        handler(response.message, response.connection_id ?? undefined);145      }146      break;147    }148    default:149      resolveExpected(response);150      if (response.type === "CONNECTED") {151        for (const handler of connectedHandlers) handler(response.connection_id);152      }153      break;154  }155}156157function resolveExpected(response: HostResponse): void {158  const index = pendingRequests.findIndex((pending) =>159    pending.expected.includes(response.type)160  );161  if (index === -1) return;162163  const [pending] = pendingRequests.splice(index, 1);164  clearTimeout(pending.timeout);165  pending.resolve(response);166}167168function rejectPending(message: string, connectionId?: string): void {169  const index = pendingRequests.findIndex((pending) =>170    connectionId ? pending.expected.includes("CONNECTED") : true171  );172  if (index === -1) return;173174  const [pending] = pendingRequests.splice(index, 1);175  clearTimeout(pending.timeout);176  pending.reject(new Error(message));177}178179function rejectAllPending(message: string): void {180  const pending = pendingRequests;181  pendingRequests = [];182  for (const request of pending) {183    clearTimeout(request.timeout);184    request.reject(new Error(message));185  }186}187188function sendToHost(msg: HostMessage): void {189  if (!syncPort) throw new Error("sync native host is not connected");190  syncPort.postMessage(msg);191}192193function requestFromHost<T extends HostResponse>(194  msg: HostMessage,195  expected: readonly ExpectedType[],196  timeoutMs = 5000197): Promise<T> {198  if (!syncPort) connectSyncHost();199200  return new Promise<T>((resolve, reject) => {201    const pending: PendingRequest<T> = {202      expected,203      resolve,204      reject,205      timeout: setTimeout(() => {206        pendingRequests = pendingRequests.filter((entry) => entry !== pending);207        reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));208      }, timeoutMs),209    };210211    pendingRequests.push(pending as PendingRequest);212    try {213      sendToHost(msg);214    } catch (err) {215      pendingRequests = pendingRequests.filter((entry) => entry !== pending);216      clearTimeout(pending.timeout);217      reject(err instanceof Error ? err : new Error(String(err)));218    }219  });220}221222export function connectSyncHost(): void {223  if (syncPort) return;224  if (!chrome.runtime.connectNative) {225    throw new Error("native messaging is not available in this browser context");226  }227228  syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);229  syncPort.onMessage.addListener(handleHostMessage);230  syncPort.onDisconnect.addListener(() => {231    syncPort = null;232    rejectAllPending("sync native host disconnected");233    for (const handler of disconnectedHandlers) handler();234  });235}236237export function disconnectSyncHost(): void {238  const port = syncPort;239  syncPort = null;240  rejectAllPending("sync native host disconnected");241  port?.disconnect();242}243244export function isHostConnected(): boolean {245  return syncPort !== null;246}247248export async function getNativeIdentity(): Promise<string> {249  const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(250    { type: "GET_IDENTITY" },251    ["IDENTITY"]252  );253  return response.public_key_hex;254}255256export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {257  const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(258    { type: "GET_TRUSTED_DEVICES" },259    ["TRUSTED_DEVICES"]260  );261  return response.devices.map(mapTrustedDevice);262}263264export async function trustDevice(265  publicKeyHex: string,266  deviceName: string267): Promise<void> {268  await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [269    "TRUST_DEVICE_OK",270  ]);271}272273export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {274  await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [275    "REMOVE_TRUSTED_DEVICE_OK",276  ]);277}278279export async function rotateNativeIdentity(): Promise<string> {280  const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(281    { type: "ROTATE_IDENTITY" },282    ["ROTATE_IDENTITY_OK"]283  );284  return response.public_key_hex;285}286287export async function advertise(options: {288  deviceId: string;289  name: string;290  x25519PublicKey?: string;291  pairing?: boolean;292  port?: number;293}): Promise<number> {294  const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(295    {296      type: "ADVERTISE",297      x25519_public_key: options.x25519PublicKey ?? "",298      device_id: options.deviceId,299      name: options.name,300      pairing: options.pairing ?? false,301      port: options.port ?? 0,302    },303    ["ADVERTISING"]304  );305  return response.port;306}307308export async function stopAdvertising(): Promise<void> {309  await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);310}311312export function discoverPeers(): void {313  sendToHost({ type: "DISCOVER" });314}315316export async function stopDiscovery(): Promise<void> {317  await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);318}319320export async function connectToPeer(321  host: string,322  port: number,323  remoteX25519PublicKey: string324): Promise<string> {325  const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(326    {327      type: "CONNECT",328      host,329      port,330      remote_x25519_public_key: remoteX25519PublicKey,331      // The Rust host owns the private identity now and ignores this legacy332      // field, so the extension must never send private key material here.333      local_x25519_private_key: "",334    },335    ["CONNECTED"]336  );337  return response.connection_id;338}339340export function disconnectFromPeer(connectionId: string): void {341  sendToHost({ type: "DISCONNECT", connection_id: connectionId });342}343344export function sendSyncData(connectionId: string, data: string): void {345  sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });346}347348export async function getNativeStatus(): Promise<NativeSyncStatus> {349  const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(350    { type: "STATUS" },351    ["STATUS"]352  );353  return {354    advertising: response.advertising,355    tcpPort: response.tcp_port ?? undefined,356    connections: response.connections,357  };358}359360export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {361  return subscribe(peerDiscoveredHandlers, handler);362}363364export function onPeerLost(handler: (deviceId: string) => void): () => void {365  return subscribe(peerLostHandlers, handler);366}367368export function onConnected(handler: (connectionId: string) => void): () => void {369  return subscribe(connectedHandlers, handler);370}371372export function onIncomingConnection(373  handler: (connectionId: string, peerAddr: string) => void374): () => void {375  return subscribe(incomingConnectionHandlers, handler);376}377378export function onDisconnected(handler: (connectionId?: string) => void): () => void {379  return subscribe(disconnectedHandlers, handler);380}381382export function onSyncMessage(383  handler: (connectionId: string, data: string) => void384): () => void {385  return subscribe(syncMessageHandlers, handler);386}387388export function onError(389  handler: (error: string, connectionId?: string) => void390): () => void {391  return subscribe(errorHandlers, handler);392}393394function subscribe<T>(handlers: T[], handler: T): () => void {395  handlers.push(handler);396  return () => {397    const index = handlers.indexOf(handler);398    if (index >= 0) handlers.splice(index, 1);399  };400}401402function mapPeer(peer: HostPeer): PeerInfo {403  return {404    host: peer.host,405    port: peer.port,406    name: peer.name,407    pairing: peer.pairing ?? false,408    deviceId: peer.device_id,409    x25519PublicKey: peer.x25519_public_key,410  };411}412413function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {414  return {415    publicKeyHex: device.public_key_hex,416    deviceName: device.device_name,417    pairedAt: device.paired_at,418    lastSeen: device.last_seen,419  };420}