sync.ts

14.9 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" };4142type HostPeer = {43  host: string;44  port: number;45  ws_port?: number | null;46  x25519_public_key: string;47  device_id: string;48  name: string;49  pairing?: boolean;50};5152type HostTrustedDevice = {53  public_key_hex: string;54  device_name: string;55  paired_at: number;56  last_seen: number;57};5859type HostResponse =60  | { type: "IDENTITY"; public_key_hex: string }61  | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }62  | { type: "TRUST_DEVICE_OK" }63  | { type: "REMOVE_TRUSTED_DEVICE_OK" }64  | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }65  | { type: "ADVERTISING"; port: number; ws_port?: number | null }66  | { type: "STOP_ADVERTISING_OK" }67  | { type: "PEER_DISCOVERED"; peer: HostPeer }68  | { type: "PEER_LOST"; device_id: string }69  | { type: "STOP_DISCOVERY_OK" }70  | { type: "CONNECTED"; connection_id: string }71  | {72      type: "PEER_CONNECTED";73      connection_id: string;74      peer_addr: string;75      is_pairing_only?: boolean;76      peer_public_key_hex?: string;77    }78  | { type: "DISCONNECTED"; connection_id: string }79  | { type: "SYNC_RECEIVED"; connection_id: string; data: string }80  | { type: "SESSION_KEY"; connection_id: string; key: string }81  | {82      type: "STATUS";83      advertising: boolean;84      tcp_port?: number | null;85      ws_port?: number | null;86      connections: string[];87    }88  | { type: "ERROR"; connection_id?: string | null; message: string }89  | { type: "PAIRING_MODE_ENTERED" }90  | { type: "PAIRING_MODE_EXITED" };9192export interface PeerInfo {93  host: string;94  port: number;95  /**96   * WebSocket port advertised in mDNS `wsport`. Mobile clients (Android via97   * Capacitor) connect through this; extension/desktop clients ignore it and98   * use the raw TCP `port` instead.99   */100  wsPort?: number;101  name: string;102  pairing: boolean;103  deviceId: string;104  x25519PublicKey: string;105}106107export interface NativeTrustedDevice {108  publicKeyHex: string;109  deviceName: string;110  pairedAt: number;111  lastSeen: number;112}113114export interface NativeSyncStatus {115  advertising: boolean;116  tcpPort?: number;117  wsPort?: number;118  connections: string[];119}120121type ExpectedType = HostResponse["type"];122type PendingRequest<T extends HostResponse = HostResponse> = {123  expected: readonly ExpectedType[];124  resolve: (response: T) => void;125  reject: (error: Error) => void;126  timeout: ReturnType<typeof setTimeout>;127};128129let syncPort: chrome.runtime.Port | null = null;130let pendingRequests: PendingRequest[] = [];131132const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];133const peerLostHandlers: Array<(deviceId: string) => void> = [];134const connectedHandlers: Array<(connectionId: string) => void> = [];135export interface IncomingConnectionInfo {136  connectionId: string;137  peerAddr: string;138  /** True when admitted only because pairing mode was active. */139  isPairingOnly: boolean;140  /** Hex-encoded x25519 public key the initiator authenticated with. */141  peerPublicKeyHex?: string;142}143144const incomingConnectionHandlers: Array<(info: IncomingConnectionInfo) => void> = [];145const disconnectedHandlers: Array<(connectionId?: string) => void> = [];146const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];147const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];148149function handleHostMessage(response: HostResponse): void {150  switch (response.type) {151    case "PEER_DISCOVERED": {152      const peer = mapPeer(response.peer);153      for (const handler of peerDiscoveredHandlers) handler(peer);154      break;155    }156    case "PEER_LOST": {157      for (const handler of peerLostHandlers) handler(response.device_id);158      break;159    }160    case "PEER_CONNECTED": {161      const info: IncomingConnectionInfo = {162        connectionId: response.connection_id,163        peerAddr: response.peer_addr,164        isPairingOnly: response.is_pairing_only === true,165        peerPublicKeyHex: response.peer_public_key_hex,166      };167      for (const handler of incomingConnectionHandlers) handler(info);168      break;169    }170    case "DISCONNECTED": {171      for (const handler of disconnectedHandlers) handler(response.connection_id);172      break;173    }174    case "SYNC_RECEIVED": {175      for (const handler of syncMessageHandlers) {176        handler(response.connection_id, response.data);177      }178      break;179    }180    case "ERROR": {181      rejectPending(response.message, response.connection_id ?? undefined);182      for (const handler of errorHandlers) {183        handler(response.message, response.connection_id ?? undefined);184      }185      break;186    }187    default:188      resolveExpected(response);189      if (response.type === "CONNECTED") {190        for (const handler of connectedHandlers) handler(response.connection_id);191      }192      break;193  }194}195196function resolveExpected(response: HostResponse): void {197  const index = pendingRequests.findIndex((pending) =>198    pending.expected.includes(response.type)199  );200  if (index === -1) return;201202  const [pending] = pendingRequests.splice(index, 1);203  clearTimeout(pending.timeout);204  pending.resolve(response);205}206207function rejectPending(message: string, connectionId?: string): void {208  const index = pendingRequests.findIndex((pending) =>209    connectionId ? pending.expected.includes("CONNECTED") : true210  );211  if (index === -1) return;212213  const [pending] = pendingRequests.splice(index, 1);214  clearTimeout(pending.timeout);215  pending.reject(new Error(message));216}217218function rejectAllPending(message: string): void {219  const pending = pendingRequests;220  pendingRequests = [];221  for (const request of pending) {222    clearTimeout(request.timeout);223    request.reject(new Error(message));224  }225}226227function sendToHost(msg: HostMessage): void {228  if (!syncPort) throw new Error("sync native host is not connected");229  syncPort.postMessage(msg);230}231232function requestFromHost<T extends HostResponse>(233  msg: HostMessage,234  expected: readonly ExpectedType[],235  timeoutMs = 5000236): Promise<T> {237  if (!syncPort) connectSyncHost();238239  return new Promise<T>((resolve, reject) => {240    const pending: PendingRequest<T> = {241      expected,242      resolve,243      reject,244      timeout: setTimeout(() => {245        pendingRequests = pendingRequests.filter((entry) => entry !== pending);246        reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));247      }, timeoutMs),248    };249250    pendingRequests.push(pending as PendingRequest);251    try {252      sendToHost(msg);253    } catch (err) {254      pendingRequests = pendingRequests.filter((entry) => entry !== pending);255      clearTimeout(pending.timeout);256      reject(err instanceof Error ? err : new Error(String(err)));257    }258  });259}260261export function connectSyncHost(): void {262  if (syncPort) return;263  if (!chrome.runtime.connectNative) {264    throw new Error("native messaging is not available in this browser context");265  }266267  syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);268  syncPort.onMessage.addListener(handleHostMessage);269  syncPort.onDisconnect.addListener(() => {270    syncPort = null;271    rejectAllPending("sync native host disconnected");272    for (const handler of disconnectedHandlers) handler();273  });274}275276export function disconnectSyncHost(): void {277  const port = syncPort;278  syncPort = null;279  rejectAllPending("sync native host disconnected");280  port?.disconnect();281}282283export function isHostConnected(): boolean {284  return syncPort !== null;285}286287export async function getNativeIdentity(): Promise<string> {288  const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(289    { type: "GET_IDENTITY" },290    ["IDENTITY"]291  );292  return response.public_key_hex;293}294295export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {296  const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(297    { type: "GET_TRUSTED_DEVICES" },298    ["TRUSTED_DEVICES"]299  );300  return response.devices.map(mapTrustedDevice);301}302303export async function trustDevice(304  publicKeyHex: string,305  deviceName: string306): Promise<void> {307  await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [308    "TRUST_DEVICE_OK",309  ]);310}311312export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {313  await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [314    "REMOVE_TRUSTED_DEVICE_OK",315  ]);316}317318export async function rotateNativeIdentity(): Promise<string> {319  const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(320    { type: "ROTATE_IDENTITY" },321    ["ROTATE_IDENTITY_OK"]322  );323  return response.public_key_hex;324}325326export async function advertise(options: {327  deviceId: string;328  name: string;329  x25519PublicKey?: string;330  pairing?: boolean;331  port?: number;332}): Promise<number> {333  const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(334    {335      type: "ADVERTISE",336      x25519_public_key: options.x25519PublicKey ?? "",337      device_id: options.deviceId,338      name: options.name,339      pairing: options.pairing ?? false,340      port: options.port ?? 0,341    },342    ["ADVERTISING"]343  );344  return response.port;345}346347export async function stopAdvertising(): Promise<void> {348  await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);349}350351export function discoverPeers(): void {352  sendToHost({ type: "DISCOVER" });353}354355export async function stopDiscovery(): Promise<void> {356  await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);357}358359export async function connectToPeer(360  host: string,361  port: number,362  remoteX25519PublicKey: string363): Promise<string> {364  const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(365    {366      type: "CONNECT",367      host,368      port,369      remote_x25519_public_key: remoteX25519PublicKey,370      // The Rust host owns the private identity now and ignores this legacy371      // field, so the extension must never send private key material here.372      local_x25519_private_key: "",373    },374    ["CONNECTED"]375  );376  return response.connection_id;377}378379export function disconnectFromPeer(connectionId: string): void {380  sendToHost({ type: "DISCONNECT", connection_id: connectionId });381}382383export function sendSyncData(connectionId: string, data: string): void {384  sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });385}386387export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {388  const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(389    { type: "GET_SESSION_KEY", connection_id: connectionId },390    ["SESSION_KEY"]391  );392  if (response.connection_id !== connectionId) {393    throw new Error("sync native host returned session key for unexpected connection");394  }395  const decoded = decodeBase64Url(response.key);396  if (decoded.length !== 32) {397    throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);398  }399  return decoded;400}401402function decodeBase64Url(value: string): Uint8Array {403  const padded = value404    .replace(/-/g, "+")405    .replace(/_/g, "/")406    .padEnd(Math.ceil(value.length / 4) * 4, "=");407  const binary = atob(padded);408  const out = new Uint8Array(binary.length);409  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);410  return out;411}412413export async function enterPairingMode(): Promise<void> {414  await requestFromHost({ type: "ENTER_PAIRING_MODE" }, ["PAIRING_MODE_ENTERED"]);415}416417export async function exitPairingMode(): Promise<void> {418  await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);419}420421/**422 * The native host's static x25519 public key (this device's sync identity).423 * Initiators include this in PAIR_REQUEST so the responder can record us in424 * its trust store after the user accepts; responders do likewise in425 * PAIR_ACCEPT.426 */427export async function getIdentityPublicKey(): Promise<string> {428  return getNativeIdentity();429}430431export async function getNativeStatus(): Promise<NativeSyncStatus> {432  const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(433    { type: "STATUS" },434    ["STATUS"]435  );436  return {437    advertising: response.advertising,438    tcpPort: response.tcp_port ?? undefined,439    wsPort: response.ws_port ?? undefined,440    connections: response.connections,441  };442}443444export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {445  return subscribe(peerDiscoveredHandlers, handler);446}447448export function onPeerLost(handler: (deviceId: string) => void): () => void {449  return subscribe(peerLostHandlers, handler);450}451452export function onConnected(handler: (connectionId: string) => void): () => void {453  return subscribe(connectedHandlers, handler);454}455456export function onIncomingConnection(457  handler: (info: IncomingConnectionInfo) => void458): () => void {459  return subscribe(incomingConnectionHandlers, handler);460}461462export function onDisconnected(handler: (connectionId?: string) => void): () => void {463  return subscribe(disconnectedHandlers, handler);464}465466export function onSyncMessage(467  handler: (connectionId: string, data: string) => void468): () => void {469  return subscribe(syncMessageHandlers, handler);470}471472export function onError(473  handler: (error: string, connectionId?: string) => void474): () => void {475  return subscribe(errorHandlers, handler);476}477478function subscribe<T>(handlers: T[], handler: T): () => void {479  handlers.push(handler);480  return () => {481    const index = handlers.indexOf(handler);482    if (index >= 0) handlers.splice(index, 1);483  };484}485486function mapPeer(peer: HostPeer): PeerInfo {487  return {488    host: peer.host,489    port: peer.port,490    wsPort: peer.ws_port ?? undefined,491    name: peer.name,492    pairing: peer.pairing ?? false,493    deviceId: peer.device_id,494    x25519PublicKey: peer.x25519_public_key,495  };496}497498function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {499  return {500    publicKeyHex: device.public_key_hex,501    deviceName: device.device_name,502    pairedAt: device.paired_at,503    lastSeen: device.last_seen,504  };505}