123456789export 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 334 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}