sync.ts

13.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: "GET_SESSION_KEY"; connection_id: string }38  | { type: "STATUS" };3940type HostPeer = {41  host: string;42  port: number;43  x25519_public_key: string;44  device_id: string;45  name: string;46  pairing?: boolean;47};4849type HostTrustedDevice = {50  public_key_hex: string;51  device_name: string;52  paired_at: number;53  last_seen: number;54};5556type HostResponse =57  | { type: "IDENTITY"; public_key_hex: string }58  | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }59  | { type: "TRUST_DEVICE_OK" }60  | { type: "REMOVE_TRUSTED_DEVICE_OK" }61  | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }62  | { type: "ADVERTISING"; port: number }63  | { type: "STOP_ADVERTISING_OK" }64  | { type: "PEER_DISCOVERED"; peer: HostPeer }65  | { type: "PEER_LOST"; device_id: string }66  | { type: "STOP_DISCOVERY_OK" }67  | { type: "CONNECTED"; connection_id: string }68  | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string }69  | { type: "DISCONNECTED"; connection_id: string }70  | { type: "SYNC_RECEIVED"; connection_id: string; data: string }71  | { type: "SESSION_KEY"; connection_id: string; key: string }72  | { type: "STATUS"; advertising: boolean; tcp_port?: number | null; connections: string[] }73  | { type: "ERROR"; connection_id?: string | null; message: string };7475export interface PeerInfo {76  host: string;77  port: number;78  name: string;79  pairing: boolean;80  deviceId: string;81  x25519PublicKey: string;82}8384export interface NativeTrustedDevice {85  publicKeyHex: string;86  deviceName: string;87  pairedAt: number;88  lastSeen: number;89}9091export interface NativeSyncStatus {92  advertising: boolean;93  tcpPort?: number;94  connections: string[];95}9697type ExpectedType = HostResponse["type"];98type PendingRequest<T extends HostResponse = HostResponse> = {99  expected: readonly ExpectedType[];100  resolve: (response: T) => void;101  reject: (error: Error) => void;102  timeout: ReturnType<typeof setTimeout>;103};104105let syncPort: chrome.runtime.Port | null = null;106let pendingRequests: PendingRequest[] = [];107108const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];109const peerLostHandlers: Array<(deviceId: string) => void> = [];110const connectedHandlers: Array<(connectionId: string) => void> = [];111const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = [];112const disconnectedHandlers: Array<(connectionId?: string) => void> = [];113const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];114const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];115116function handleHostMessage(response: HostResponse): void {117  switch (response.type) {118    case "PEER_DISCOVERED": {119      const peer = mapPeer(response.peer);120      for (const handler of peerDiscoveredHandlers) handler(peer);121      break;122    }123    case "PEER_LOST": {124      for (const handler of peerLostHandlers) handler(response.device_id);125      break;126    }127    case "PEER_CONNECTED": {128      for (const handler of incomingConnectionHandlers) {129        handler(response.connection_id, response.peer_addr);130      }131      break;132    }133    case "DISCONNECTED": {134      for (const handler of disconnectedHandlers) handler(response.connection_id);135      break;136    }137    case "SYNC_RECEIVED": {138      for (const handler of syncMessageHandlers) {139        handler(response.connection_id, response.data);140      }141      break;142    }143    case "ERROR": {144      rejectPending(response.message, response.connection_id ?? undefined);145      for (const handler of errorHandlers) {146        handler(response.message, response.connection_id ?? undefined);147      }148      break;149    }150    default:151      resolveExpected(response);152      if (response.type === "CONNECTED") {153        for (const handler of connectedHandlers) handler(response.connection_id);154      }155      break;156  }157}158159function resolveExpected(response: HostResponse): void {160  const index = pendingRequests.findIndex((pending) =>161    pending.expected.includes(response.type)162  );163  if (index === -1) return;164165  const [pending] = pendingRequests.splice(index, 1);166  clearTimeout(pending.timeout);167  pending.resolve(response);168}169170function rejectPending(message: string, connectionId?: string): void {171  const index = pendingRequests.findIndex((pending) =>172    connectionId ? pending.expected.includes("CONNECTED") : true173  );174  if (index === -1) return;175176  const [pending] = pendingRequests.splice(index, 1);177  clearTimeout(pending.timeout);178  pending.reject(new Error(message));179}180181function rejectAllPending(message: string): void {182  const pending = pendingRequests;183  pendingRequests = [];184  for (const request of pending) {185    clearTimeout(request.timeout);186    request.reject(new Error(message));187  }188}189190function sendToHost(msg: HostMessage): void {191  if (!syncPort) throw new Error("sync native host is not connected");192  syncPort.postMessage(msg);193}194195function requestFromHost<T extends HostResponse>(196  msg: HostMessage,197  expected: readonly ExpectedType[],198  timeoutMs = 5000199): Promise<T> {200  if (!syncPort) connectSyncHost();201202  return new Promise<T>((resolve, reject) => {203    const pending: PendingRequest<T> = {204      expected,205      resolve,206      reject,207      timeout: setTimeout(() => {208        pendingRequests = pendingRequests.filter((entry) => entry !== pending);209        reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));210      }, timeoutMs),211    };212213    pendingRequests.push(pending as PendingRequest);214    try {215      sendToHost(msg);216    } catch (err) {217      pendingRequests = pendingRequests.filter((entry) => entry !== pending);218      clearTimeout(pending.timeout);219      reject(err instanceof Error ? err : new Error(String(err)));220    }221  });222}223224export function connectSyncHost(): void {225  if (syncPort) return;226  if (!chrome.runtime.connectNative) {227    throw new Error("native messaging is not available in this browser context");228  }229230  syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);231  syncPort.onMessage.addListener(handleHostMessage);232  syncPort.onDisconnect.addListener(() => {233    syncPort = null;234    rejectAllPending("sync native host disconnected");235    for (const handler of disconnectedHandlers) handler();236  });237}238239export function disconnectSyncHost(): void {240  const port = syncPort;241  syncPort = null;242  rejectAllPending("sync native host disconnected");243  port?.disconnect();244}245246export function isHostConnected(): boolean {247  return syncPort !== null;248}249250export async function getNativeIdentity(): Promise<string> {251  const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(252    { type: "GET_IDENTITY" },253    ["IDENTITY"]254  );255  return response.public_key_hex;256}257258export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {259  const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(260    { type: "GET_TRUSTED_DEVICES" },261    ["TRUSTED_DEVICES"]262  );263  return response.devices.map(mapTrustedDevice);264}265266export async function trustDevice(267  publicKeyHex: string,268  deviceName: string269): Promise<void> {270  await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [271    "TRUST_DEVICE_OK",272  ]);273}274275export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {276  await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [277    "REMOVE_TRUSTED_DEVICE_OK",278  ]);279}280281export async function rotateNativeIdentity(): Promise<string> {282  const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(283    { type: "ROTATE_IDENTITY" },284    ["ROTATE_IDENTITY_OK"]285  );286  return response.public_key_hex;287}288289export async function advertise(options: {290  deviceId: string;291  name: string;292  x25519PublicKey?: string;293  pairing?: boolean;294  port?: number;295}): Promise<number> {296  const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(297    {298      type: "ADVERTISE",299      x25519_public_key: options.x25519PublicKey ?? "",300      device_id: options.deviceId,301      name: options.name,302      pairing: options.pairing ?? false,303      port: options.port ?? 0,304    },305    ["ADVERTISING"]306  );307  return response.port;308}309310export async function stopAdvertising(): Promise<void> {311  await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);312}313314export function discoverPeers(): void {315  sendToHost({ type: "DISCOVER" });316}317318export async function stopDiscovery(): Promise<void> {319  await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);320}321322export async function connectToPeer(323  host: string,324  port: number,325  remoteX25519PublicKey: string326): Promise<string> {327  const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(328    {329      type: "CONNECT",330      host,331      port,332      remote_x25519_public_key: remoteX25519PublicKey,333      // The Rust host owns the private identity now and ignores this legacy334      // field, so the extension must never send private key material here.335      local_x25519_private_key: "",336    },337    ["CONNECTED"]338  );339  return response.connection_id;340}341342export function disconnectFromPeer(connectionId: string): void {343  sendToHost({ type: "DISCONNECT", connection_id: connectionId });344}345346export function sendSyncData(connectionId: string, data: string): void {347  sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });348}349350export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {351  const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(352    { type: "GET_SESSION_KEY", connection_id: connectionId },353    ["SESSION_KEY"]354  );355  if (response.connection_id !== connectionId) {356    throw new Error("sync native host returned session key for unexpected connection");357  }358  const decoded = decodeBase64Url(response.key);359  if (decoded.length !== 32) {360    throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);361  }362  return decoded;363}364365function decodeBase64Url(value: string): Uint8Array {366  const padded = value367    .replace(/-/g, "+")368    .replace(/_/g, "/")369    .padEnd(Math.ceil(value.length / 4) * 4, "=");370  const binary = atob(padded);371  const out = new Uint8Array(binary.length);372  for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);373  return out;374}375376export async function getNativeStatus(): Promise<NativeSyncStatus> {377  const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(378    { type: "STATUS" },379    ["STATUS"]380  );381  return {382    advertising: response.advertising,383    tcpPort: response.tcp_port ?? undefined,384    connections: response.connections,385  };386}387388export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {389  return subscribe(peerDiscoveredHandlers, handler);390}391392export function onPeerLost(handler: (deviceId: string) => void): () => void {393  return subscribe(peerLostHandlers, handler);394}395396export function onConnected(handler: (connectionId: string) => void): () => void {397  return subscribe(connectedHandlers, handler);398}399400export function onIncomingConnection(401  handler: (connectionId: string, peerAddr: string) => void402): () => void {403  return subscribe(incomingConnectionHandlers, handler);404}405406export function onDisconnected(handler: (connectionId?: string) => void): () => void {407  return subscribe(disconnectedHandlers, handler);408}409410export function onSyncMessage(411  handler: (connectionId: string, data: string) => void412): () => void {413  return subscribe(syncMessageHandlers, handler);414}415416export function onError(417  handler: (error: string, connectionId?: string) => void418): () => void {419  return subscribe(errorHandlers, handler);420}421422function subscribe<T>(handlers: T[], handler: T): () => void {423  handlers.push(handler);424  return () => {425    const index = handlers.indexOf(handler);426    if (index >= 0) handlers.splice(index, 1);427  };428}429430function mapPeer(peer: HostPeer): PeerInfo {431  return {432    host: peer.host,433    port: peer.port,434    name: peer.name,435    pairing: peer.pairing ?? false,436    deviceId: peer.device_id,437    x25519PublicKey: peer.x25519_public_key,438  };439}440441function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {442  return {443    publicKeyHex: device.public_key_hex,444    deviceName: device.device_name,445    pairedAt: device.paired_at,446    lastSeen: device.last_seen,447  };448}