/** * GATED — peach-sync native host client. * * This module mirrors the Rust host protocol but is still not imported by any * live background path. Keep `nativeMessaging` out of the manifests until the * SYN-001 audit/gate checklist passes and the installer manifest exists. */ export const SYNC_NATIVE_HOST_NAME = "com.peachpasswords.sync"; type HostMessage = | { type: "GET_IDENTITY" } | { type: "GET_TRUSTED_DEVICES" } | { type: "TRUST_DEVICE"; public_key_hex: string; device_name: string } | { type: "REMOVE_TRUSTED_DEVICE"; public_key_hex: string } | { type: "ROTATE_IDENTITY" } | { type: "ADVERTISE"; x25519_public_key: string; device_id: string; name: string; pairing?: boolean; port?: number; } | { type: "STOP_ADVERTISING" } | { type: "DISCOVER" } | { type: "STOP_DISCOVERY" } | { type: "CONNECT"; host: string; port: number; remote_x25519_public_key: string; local_x25519_private_key: string; } | { type: "DISCONNECT"; connection_id: string } | { type: "SYNC_SEND"; connection_id: string; data: string } | { type: "STATUS" }; type HostPeer = { host: string; port: number; x25519_public_key: string; device_id: string; name: string; pairing?: boolean; }; type HostTrustedDevice = { public_key_hex: string; device_name: string; paired_at: number; last_seen: number; }; type HostResponse = | { type: "IDENTITY"; public_key_hex: string } | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] } | { type: "TRUST_DEVICE_OK" } | { type: "REMOVE_TRUSTED_DEVICE_OK" } | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string } | { type: "ADVERTISING"; port: number } | { type: "STOP_ADVERTISING_OK" } | { type: "PEER_DISCOVERED"; peer: HostPeer } | { type: "PEER_LOST"; device_id: string } | { type: "STOP_DISCOVERY_OK" } | { type: "CONNECTED"; connection_id: string } | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string } | { type: "DISCONNECTED"; connection_id: string } | { type: "SYNC_RECEIVED"; connection_id: string; data: string } | { type: "STATUS"; advertising: boolean; tcp_port?: number | null; connections: string[] } | { type: "ERROR"; connection_id?: string | null; message: string }; export interface PeerInfo { host: string; port: number; name: string; pairing: boolean; deviceId: string; x25519PublicKey: string; } export interface NativeTrustedDevice { publicKeyHex: string; deviceName: string; pairedAt: number; lastSeen: number; } export interface NativeSyncStatus { advertising: boolean; tcpPort?: number; connections: string[]; } type ExpectedType = HostResponse["type"]; type PendingRequest = { expected: readonly ExpectedType[]; resolve: (response: T) => void; reject: (error: Error) => void; timeout: ReturnType; }; let syncPort: chrome.runtime.Port | null = null; let pendingRequests: PendingRequest[] = []; const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = []; const peerLostHandlers: Array<(deviceId: string) => void> = []; const connectedHandlers: Array<(connectionId: string) => void> = []; const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = []; const disconnectedHandlers: Array<(connectionId?: string) => void> = []; const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = []; const errorHandlers: Array<(error: string, connectionId?: string) => void> = []; function handleHostMessage(response: HostResponse): void { switch (response.type) { case "PEER_DISCOVERED": { const peer = mapPeer(response.peer); for (const handler of peerDiscoveredHandlers) handler(peer); break; } case "PEER_LOST": { for (const handler of peerLostHandlers) handler(response.device_id); break; } case "PEER_CONNECTED": { for (const handler of incomingConnectionHandlers) { handler(response.connection_id, response.peer_addr); } break; } case "DISCONNECTED": { for (const handler of disconnectedHandlers) handler(response.connection_id); break; } case "SYNC_RECEIVED": { for (const handler of syncMessageHandlers) { handler(response.connection_id, response.data); } break; } case "ERROR": { rejectPending(response.message, response.connection_id ?? undefined); for (const handler of errorHandlers) { handler(response.message, response.connection_id ?? undefined); } break; } default: resolveExpected(response); if (response.type === "CONNECTED") { for (const handler of connectedHandlers) handler(response.connection_id); } break; } } function resolveExpected(response: HostResponse): void { const index = pendingRequests.findIndex((pending) => pending.expected.includes(response.type) ); if (index === -1) return; const [pending] = pendingRequests.splice(index, 1); clearTimeout(pending.timeout); pending.resolve(response); } function rejectPending(message: string, connectionId?: string): void { const index = pendingRequests.findIndex((pending) => connectionId ? pending.expected.includes("CONNECTED") : true ); if (index === -1) return; const [pending] = pendingRequests.splice(index, 1); clearTimeout(pending.timeout); pending.reject(new Error(message)); } function rejectAllPending(message: string): void { const pending = pendingRequests; pendingRequests = []; for (const request of pending) { clearTimeout(request.timeout); request.reject(new Error(message)); } } function sendToHost(msg: HostMessage): void { if (!syncPort) throw new Error("sync native host is not connected"); syncPort.postMessage(msg); } function requestFromHost( msg: HostMessage, expected: readonly ExpectedType[], timeoutMs = 5000 ): Promise { if (!syncPort) connectSyncHost(); return new Promise((resolve, reject) => { const pending: PendingRequest = { expected, resolve, reject, timeout: setTimeout(() => { pendingRequests = pendingRequests.filter((entry) => entry !== pending); reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`)); }, timeoutMs), }; pendingRequests.push(pending as PendingRequest); try { sendToHost(msg); } catch (err) { pendingRequests = pendingRequests.filter((entry) => entry !== pending); clearTimeout(pending.timeout); reject(err instanceof Error ? err : new Error(String(err))); } }); } export function connectSyncHost(): void { if (syncPort) return; if (!chrome.runtime.connectNative) { throw new Error("native messaging is not available in this browser context"); } syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME); syncPort.onMessage.addListener(handleHostMessage); syncPort.onDisconnect.addListener(() => { syncPort = null; rejectAllPending("sync native host disconnected"); for (const handler of disconnectedHandlers) handler(); }); } export function disconnectSyncHost(): void { const port = syncPort; syncPort = null; rejectAllPending("sync native host disconnected"); port?.disconnect(); } export function isHostConnected(): boolean { return syncPort !== null; } export async function getNativeIdentity(): Promise { const response = await requestFromHost>( { type: "GET_IDENTITY" }, ["IDENTITY"] ); return response.public_key_hex; } export async function getTrustedDevices(): Promise { const response = await requestFromHost>( { type: "GET_TRUSTED_DEVICES" }, ["TRUSTED_DEVICES"] ); return response.devices.map(mapTrustedDevice); } export async function trustDevice( publicKeyHex: string, deviceName: string ): Promise { await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [ "TRUST_DEVICE_OK", ]); } export async function removeTrustedDevice(publicKeyHex: string): Promise { await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [ "REMOVE_TRUSTED_DEVICE_OK", ]); } export async function rotateNativeIdentity(): Promise { const response = await requestFromHost>( { type: "ROTATE_IDENTITY" }, ["ROTATE_IDENTITY_OK"] ); return response.public_key_hex; } export async function advertise(options: { deviceId: string; name: string; x25519PublicKey?: string; pairing?: boolean; port?: number; }): Promise { const response = await requestFromHost>( { type: "ADVERTISE", x25519_public_key: options.x25519PublicKey ?? "", device_id: options.deviceId, name: options.name, pairing: options.pairing ?? false, port: options.port ?? 0, }, ["ADVERTISING"] ); return response.port; } export async function stopAdvertising(): Promise { await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]); } export function discoverPeers(): void { sendToHost({ type: "DISCOVER" }); } export async function stopDiscovery(): Promise { await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]); } export async function connectToPeer( host: string, port: number, remoteX25519PublicKey: string ): Promise { const response = await requestFromHost>( { type: "CONNECT", host, port, remote_x25519_public_key: remoteX25519PublicKey, // The Rust host owns the private identity now and ignores this legacy // field, so the extension must never send private key material here. local_x25519_private_key: "", }, ["CONNECTED"] ); return response.connection_id; } export function disconnectFromPeer(connectionId: string): void { sendToHost({ type: "DISCONNECT", connection_id: connectionId }); } export function sendSyncData(connectionId: string, data: string): void { sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data }); } export async function getNativeStatus(): Promise { const response = await requestFromHost>( { type: "STATUS" }, ["STATUS"] ); return { advertising: response.advertising, tcpPort: response.tcp_port ?? undefined, connections: response.connections, }; } export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void { return subscribe(peerDiscoveredHandlers, handler); } export function onPeerLost(handler: (deviceId: string) => void): () => void { return subscribe(peerLostHandlers, handler); } export function onConnected(handler: (connectionId: string) => void): () => void { return subscribe(connectedHandlers, handler); } export function onIncomingConnection( handler: (connectionId: string, peerAddr: string) => void ): () => void { return subscribe(incomingConnectionHandlers, handler); } export function onDisconnected(handler: (connectionId?: string) => void): () => void { return subscribe(disconnectedHandlers, handler); } export function onSyncMessage( handler: (connectionId: string, data: string) => void ): () => void { return subscribe(syncMessageHandlers, handler); } export function onError( handler: (error: string, connectionId?: string) => void ): () => void { return subscribe(errorHandlers, handler); } function subscribe(handlers: T[], handler: T): () => void { handlers.push(handler); return () => { const index = handlers.indexOf(handler); if (index >= 0) handlers.splice(index, 1); }; } function mapPeer(peer: HostPeer): PeerInfo { return { host: peer.host, port: peer.port, name: peer.name, pairing: peer.pairing ?? false, deviceId: peer.device_id, x25519PublicKey: peer.x25519_public_key, }; } function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice { return { publicKeyHex: device.public_key_hex, deviceName: device.device_name, pairedAt: device.paired_at, lastSeen: device.last_seen, }; }