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 ws_port?: number | null;44 x25519_public_key: string;45 device_id: string;46 name: string;47 pairing?: boolean;48};4950type HostTrustedDevice = {51 public_key_hex: string;52 device_name: string;53 paired_at: number;54 last_seen: number;55};5657type HostResponse =58 | { type: "IDENTITY"; public_key_hex: string }59 | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }60 | { type: "TRUST_DEVICE_OK" }61 | { type: "REMOVE_TRUSTED_DEVICE_OK" }62 | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }63 | { type: "ADVERTISING"; port: number; ws_port?: number | null }64 | { type: "STOP_ADVERTISING_OK" }65 | { type: "PEER_DISCOVERED"; peer: HostPeer }66 | { type: "PEER_LOST"; device_id: string }67 | { type: "STOP_DISCOVERY_OK" }68 | { type: "CONNECTED"; connection_id: string }69 | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string }70 | { type: "DISCONNECTED"; connection_id: string }71 | { type: "SYNC_RECEIVED"; connection_id: string; data: string }72 | { type: "SESSION_KEY"; connection_id: string; key: string }73 | {74 type: "STATUS";75 advertising: boolean;76 tcp_port?: number | null;77 ws_port?: number | null;78 connections: string[];79 }80 | { type: "ERROR"; connection_id?: string | null; message: string };8182export interface PeerInfo {83 host: string;84 port: number;85 8687888990 wsPort?: number;91 name: string;92 pairing: boolean;93 deviceId: string;94 x25519PublicKey: string;95}9697export interface NativeTrustedDevice {98 publicKeyHex: string;99 deviceName: string;100 pairedAt: number;101 lastSeen: number;102}103104export interface NativeSyncStatus {105 advertising: boolean;106 tcpPort?: number;107 wsPort?: number;108 connections: string[];109}110111type ExpectedType = HostResponse["type"];112type PendingRequest<T extends HostResponse = HostResponse> = {113 expected: readonly ExpectedType[];114 resolve: (response: T) => void;115 reject: (error: Error) => void;116 timeout: ReturnType<typeof setTimeout>;117};118119let syncPort: chrome.runtime.Port | null = null;120let pendingRequests: PendingRequest[] = [];121122const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];123const peerLostHandlers: Array<(deviceId: string) => void> = [];124const connectedHandlers: Array<(connectionId: string) => void> = [];125const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = [];126const disconnectedHandlers: Array<(connectionId?: string) => void> = [];127const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];128const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];129130function handleHostMessage(response: HostResponse): void {131 switch (response.type) {132 case "PEER_DISCOVERED": {133 const peer = mapPeer(response.peer);134 for (const handler of peerDiscoveredHandlers) handler(peer);135 break;136 }137 case "PEER_LOST": {138 for (const handler of peerLostHandlers) handler(response.device_id);139 break;140 }141 case "PEER_CONNECTED": {142 for (const handler of incomingConnectionHandlers) {143 handler(response.connection_id, response.peer_addr);144 }145 break;146 }147 case "DISCONNECTED": {148 for (const handler of disconnectedHandlers) handler(response.connection_id);149 break;150 }151 case "SYNC_RECEIVED": {152 for (const handler of syncMessageHandlers) {153 handler(response.connection_id, response.data);154 }155 break;156 }157 case "ERROR": {158 rejectPending(response.message, response.connection_id ?? undefined);159 for (const handler of errorHandlers) {160 handler(response.message, response.connection_id ?? undefined);161 }162 break;163 }164 default:165 resolveExpected(response);166 if (response.type === "CONNECTED") {167 for (const handler of connectedHandlers) handler(response.connection_id);168 }169 break;170 }171}172173function resolveExpected(response: HostResponse): void {174 const index = pendingRequests.findIndex((pending) =>175 pending.expected.includes(response.type)176 );177 if (index === -1) return;178179 const [pending] = pendingRequests.splice(index, 1);180 clearTimeout(pending.timeout);181 pending.resolve(response);182}183184function rejectPending(message: string, connectionId?: string): void {185 const index = pendingRequests.findIndex((pending) =>186 connectionId ? pending.expected.includes("CONNECTED") : true187 );188 if (index === -1) return;189190 const [pending] = pendingRequests.splice(index, 1);191 clearTimeout(pending.timeout);192 pending.reject(new Error(message));193}194195function rejectAllPending(message: string): void {196 const pending = pendingRequests;197 pendingRequests = [];198 for (const request of pending) {199 clearTimeout(request.timeout);200 request.reject(new Error(message));201 }202}203204function sendToHost(msg: HostMessage): void {205 if (!syncPort) throw new Error("sync native host is not connected");206 syncPort.postMessage(msg);207}208209function requestFromHost<T extends HostResponse>(210 msg: HostMessage,211 expected: readonly ExpectedType[],212 timeoutMs = 5000213): Promise<T> {214 if (!syncPort) connectSyncHost();215216 return new Promise<T>((resolve, reject) => {217 const pending: PendingRequest<T> = {218 expected,219 resolve,220 reject,221 timeout: setTimeout(() => {222 pendingRequests = pendingRequests.filter((entry) => entry !== pending);223 reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));224 }, timeoutMs),225 };226227 pendingRequests.push(pending as PendingRequest);228 try {229 sendToHost(msg);230 } catch (err) {231 pendingRequests = pendingRequests.filter((entry) => entry !== pending);232 clearTimeout(pending.timeout);233 reject(err instanceof Error ? err : new Error(String(err)));234 }235 });236}237238export function connectSyncHost(): void {239 if (syncPort) return;240 if (!chrome.runtime.connectNative) {241 throw new Error("native messaging is not available in this browser context");242 }243244 syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);245 syncPort.onMessage.addListener(handleHostMessage);246 syncPort.onDisconnect.addListener(() => {247 syncPort = null;248 rejectAllPending("sync native host disconnected");249 for (const handler of disconnectedHandlers) handler();250 });251}252253export function disconnectSyncHost(): void {254 const port = syncPort;255 syncPort = null;256 rejectAllPending("sync native host disconnected");257 port?.disconnect();258}259260export function isHostConnected(): boolean {261 return syncPort !== null;262}263264export async function getNativeIdentity(): Promise<string> {265 const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(266 { type: "GET_IDENTITY" },267 ["IDENTITY"]268 );269 return response.public_key_hex;270}271272export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {273 const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(274 { type: "GET_TRUSTED_DEVICES" },275 ["TRUSTED_DEVICES"]276 );277 return response.devices.map(mapTrustedDevice);278}279280export async function trustDevice(281 publicKeyHex: string,282 deviceName: string283): Promise<void> {284 await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [285 "TRUST_DEVICE_OK",286 ]);287}288289export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {290 await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [291 "REMOVE_TRUSTED_DEVICE_OK",292 ]);293}294295export async function rotateNativeIdentity(): Promise<string> {296 const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(297 { type: "ROTATE_IDENTITY" },298 ["ROTATE_IDENTITY_OK"]299 );300 return response.public_key_hex;301}302303export async function advertise(options: {304 deviceId: string;305 name: string;306 x25519PublicKey?: string;307 pairing?: boolean;308 port?: number;309}): Promise<number> {310 const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(311 {312 type: "ADVERTISE",313 x25519_public_key: options.x25519PublicKey ?? "",314 device_id: options.deviceId,315 name: options.name,316 pairing: options.pairing ?? false,317 port: options.port ?? 0,318 },319 ["ADVERTISING"]320 );321 return response.port;322}323324export async function stopAdvertising(): Promise<void> {325 await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);326}327328export function discoverPeers(): void {329 sendToHost({ type: "DISCOVER" });330}331332export async function stopDiscovery(): Promise<void> {333 await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);334}335336export async function connectToPeer(337 host: string,338 port: number,339 remoteX25519PublicKey: string340): Promise<string> {341 const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(342 {343 type: "CONNECT",344 host,345 port,346 remote_x25519_public_key: remoteX25519PublicKey,347 348 349 local_x25519_private_key: "",350 },351 ["CONNECTED"]352 );353 return response.connection_id;354}355356export function disconnectFromPeer(connectionId: string): void {357 sendToHost({ type: "DISCONNECT", connection_id: connectionId });358}359360export function sendSyncData(connectionId: string, data: string): void {361 sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });362}363364export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {365 const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(366 { type: "GET_SESSION_KEY", connection_id: connectionId },367 ["SESSION_KEY"]368 );369 if (response.connection_id !== connectionId) {370 throw new Error("sync native host returned session key for unexpected connection");371 }372 const decoded = decodeBase64Url(response.key);373 if (decoded.length !== 32) {374 throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);375 }376 return decoded;377}378379function decodeBase64Url(value: string): Uint8Array {380 const padded = value381 .replace(/-/g, "+")382 .replace(/_/g, "/")383 .padEnd(Math.ceil(value.length / 4) * 4, "=");384 const binary = atob(padded);385 const out = new Uint8Array(binary.length);386 for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);387 return out;388}389390export async function getNativeStatus(): Promise<NativeSyncStatus> {391 const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(392 { type: "STATUS" },393 ["STATUS"]394 );395 return {396 advertising: response.advertising,397 tcpPort: response.tcp_port ?? undefined,398 wsPort: response.ws_port ?? undefined,399 connections: response.connections,400 };401}402403export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {404 return subscribe(peerDiscoveredHandlers, handler);405}406407export function onPeerLost(handler: (deviceId: string) => void): () => void {408 return subscribe(peerLostHandlers, handler);409}410411export function onConnected(handler: (connectionId: string) => void): () => void {412 return subscribe(connectedHandlers, handler);413}414415export function onIncomingConnection(416 handler: (connectionId: string, peerAddr: string) => void417): () => void {418 return subscribe(incomingConnectionHandlers, handler);419}420421export function onDisconnected(handler: (connectionId?: string) => void): () => void {422 return subscribe(disconnectedHandlers, handler);423}424425export function onSyncMessage(426 handler: (connectionId: string, data: string) => void427): () => void {428 return subscribe(syncMessageHandlers, handler);429}430431export function onError(432 handler: (error: string, connectionId?: string) => void433): () => void {434 return subscribe(errorHandlers, handler);435}436437function subscribe<T>(handlers: T[], handler: T): () => void {438 handlers.push(handler);439 return () => {440 const index = handlers.indexOf(handler);441 if (index >= 0) handlers.splice(index, 1);442 };443}444445function mapPeer(peer: HostPeer): PeerInfo {446 return {447 host: peer.host,448 port: peer.port,449 wsPort: peer.ws_port ?? undefined,450 name: peer.name,451 pairing: peer.pairing ?? false,452 deviceId: peer.device_id,453 x25519PublicKey: peer.x25519_public_key,454 };455}456457function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {458 return {459 publicKeyHex: device.public_key_hex,460 deviceName: device.device_name,461 pairedAt: device.paired_at,462 lastSeen: device.last_seen,463 };464}