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" }39 | { type: "ENTER_PAIRING_MODE" }40 | { type: "EXIT_PAIRING_MODE" }41 | { type: "START_PAIRING_SERVER"; token: string; device_id: string; name: string }42 | { type: "STOP_PAIRING_SERVER" }43 | { type: "RESOLVE_PEER_BY_CODE"; token: string; timeout_ms?: number };4445type HostPeer = {46 host: string;47 port: number;48 ws_port?: number | null;49 x25519_public_key: string;50 device_id: string;51 name: string;52 pairing?: boolean;53};5455type HostTrustedDevice = {56 public_key_hex: string;57 device_name: string;58 paired_at: number;59 last_seen: number;60};6162type HostResponse =63 | { type: "IDENTITY"; public_key_hex: string }64 | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }65 | { type: "TRUST_DEVICE_OK" }66 | { type: "REMOVE_TRUSTED_DEVICE_OK" }67 | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }68 | { type: "ADVERTISING"; port: number; ws_port?: number | null }69 | { type: "STOP_ADVERTISING_OK" }70 | { type: "PEER_DISCOVERED"; peer: HostPeer }71 | { type: "PEER_LOST"; device_id: string }72 | { type: "STOP_DISCOVERY_OK" }73 | { type: "CONNECTED"; connection_id: string }74 | {75 type: "PEER_CONNECTED";76 connection_id: string;77 peer_addr: string;78 is_pairing_only?: boolean;79 peer_public_key_hex?: string;80 }81 | { type: "DISCONNECTED"; connection_id: string }82 | { type: "SYNC_RECEIVED"; connection_id: string; data: string }83 | { type: "SESSION_KEY"; connection_id: string; key: string }84 | {85 type: "STATUS";86 advertising: boolean;87 tcp_port?: number | null;88 ws_port?: number | null;89 connections: string[];90 }91 | { type: "ERROR"; connection_id?: string | null; message: string }92 | { type: "PAIRING_MODE_ENTERED" }93 | { type: "PAIRING_MODE_EXITED" }94 | { type: "PAIRING_SERVER_STARTED"; tcp_port: number; ws_port?: number | null }95 | { type: "STOP_PAIRING_SERVER_OK" }96 | { type: "PEER_RESOLVED"; peer: HostPeer };9798export interface PeerInfo {99 host: string;100 port: number;101 102103104105106 wsPort?: number;107 name: string;108 pairing: boolean;109 deviceId: string;110 x25519PublicKey: string;111}112113export interface NativeTrustedDevice {114 publicKeyHex: string;115 deviceName: string;116 pairedAt: number;117 lastSeen: number;118}119120export interface NativeSyncStatus {121 advertising: boolean;122 tcpPort?: number;123 wsPort?: number;124 connections: string[];125}126127type ExpectedType = HostResponse["type"];128type PendingRequest<T extends HostResponse = HostResponse> = {129 expected: readonly ExpectedType[];130 resolve: (response: T) => void;131 reject: (error: Error) => void;132 timeout: ReturnType<typeof setTimeout>;133};134135let syncPort: chrome.runtime.Port | null = null;136let pendingRequests: PendingRequest[] = [];137138const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];139const peerLostHandlers: Array<(deviceId: string) => void> = [];140const connectedHandlers: Array<(connectionId: string) => void> = [];141export interface IncomingConnectionInfo {142 connectionId: string;143 peerAddr: string;144 145 isPairingOnly: boolean;146 147 peerPublicKeyHex?: string;148}149150const incomingConnectionHandlers: Array<(info: IncomingConnectionInfo) => void> = [];151const disconnectedHandlers: Array<(connectionId?: string) => void> = [];152const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];153const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];154155function handleHostMessage(response: HostResponse): void {156 switch (response.type) {157 case "PEER_DISCOVERED": {158 const peer = mapPeer(response.peer);159 for (const handler of peerDiscoveredHandlers) handler(peer);160 break;161 }162 case "PEER_LOST": {163 for (const handler of peerLostHandlers) handler(response.device_id);164 break;165 }166 case "PEER_CONNECTED": {167 const info: IncomingConnectionInfo = {168 connectionId: response.connection_id,169 peerAddr: response.peer_addr,170 isPairingOnly: response.is_pairing_only === true,171 peerPublicKeyHex: response.peer_public_key_hex,172 };173 for (const handler of incomingConnectionHandlers) handler(info);174 break;175 }176 case "DISCONNECTED": {177 for (const handler of disconnectedHandlers) handler(response.connection_id);178 break;179 }180 case "SYNC_RECEIVED": {181 for (const handler of syncMessageHandlers) {182 handler(response.connection_id, response.data);183 }184 break;185 }186 case "ERROR": {187 rejectPending(response.message, response.connection_id ?? undefined);188 for (const handler of errorHandlers) {189 handler(response.message, response.connection_id ?? undefined);190 }191 break;192 }193 default:194 resolveExpected(response);195 if (response.type === "CONNECTED") {196 for (const handler of connectedHandlers) handler(response.connection_id);197 }198 break;199 }200}201202function resolveExpected(response: HostResponse): void {203 const index = pendingRequests.findIndex((pending) =>204 pending.expected.includes(response.type)205 );206 if (index === -1) return;207208 const [pending] = pendingRequests.splice(index, 1);209 clearTimeout(pending.timeout);210 pending.resolve(response);211}212213function rejectPending(message: string, connectionId?: string): void {214 const index = pendingRequests.findIndex((pending) =>215 connectionId ? pending.expected.includes("CONNECTED") : true216 );217 if (index === -1) return;218219 const [pending] = pendingRequests.splice(index, 1);220 clearTimeout(pending.timeout);221 pending.reject(new Error(message));222}223224function rejectAllPending(message: string): void {225 const pending = pendingRequests;226 pendingRequests = [];227 for (const request of pending) {228 clearTimeout(request.timeout);229 request.reject(new Error(message));230 }231}232233function sendToHost(msg: HostMessage): void {234 if (!syncPort) throw new Error("sync native host is not connected");235 syncPort.postMessage(msg);236}237238function requestFromHost<T extends HostResponse>(239 msg: HostMessage,240 expected: readonly ExpectedType[],241 timeoutMs = 5000242): Promise<T> {243 if (!syncPort) connectSyncHost();244245 return new Promise<T>((resolve, reject) => {246 const pending: PendingRequest<T> = {247 expected,248 resolve,249 reject,250 timeout: setTimeout(() => {251 pendingRequests = pendingRequests.filter((entry) => entry !== pending);252 reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));253 }, timeoutMs),254 };255256 pendingRequests.push(pending as PendingRequest);257 try {258 sendToHost(msg);259 } catch (err) {260 pendingRequests = pendingRequests.filter((entry) => entry !== pending);261 clearTimeout(pending.timeout);262 reject(err instanceof Error ? err : new Error(String(err)));263 }264 });265}266267export function connectSyncHost(): void {268 if (syncPort) return;269 if (!chrome.runtime.connectNative) {270 throw new Error("native messaging is not available in this browser context");271 }272273 syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);274 syncPort.onMessage.addListener(handleHostMessage);275 syncPort.onDisconnect.addListener(() => {276 syncPort = null;277 rejectAllPending("sync native host disconnected");278 for (const handler of disconnectedHandlers) handler();279 });280}281282export function disconnectSyncHost(): void {283 const port = syncPort;284 syncPort = null;285 rejectAllPending("sync native host disconnected");286 port?.disconnect();287}288289export function isHostConnected(): boolean {290 return syncPort !== null;291}292293export async function getNativeIdentity(): Promise<string> {294 const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(295 { type: "GET_IDENTITY" },296 ["IDENTITY"]297 );298 return response.public_key_hex;299}300301export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {302 const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(303 { type: "GET_TRUSTED_DEVICES" },304 ["TRUSTED_DEVICES"]305 );306 return response.devices.map(mapTrustedDevice);307}308309export async function trustDevice(310 publicKeyHex: string,311 deviceName: string312): Promise<void> {313 await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [314 "TRUST_DEVICE_OK",315 ]);316}317318export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {319 await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [320 "REMOVE_TRUSTED_DEVICE_OK",321 ]);322}323324export async function rotateNativeIdentity(): Promise<string> {325 const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(326 { type: "ROTATE_IDENTITY" },327 ["ROTATE_IDENTITY_OK"]328 );329 return response.public_key_hex;330}331332export async function advertise(options: {333 deviceId: string;334 name: string;335 x25519PublicKey?: string;336 pairing?: boolean;337 port?: number;338}): Promise<number> {339 const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(340 {341 type: "ADVERTISE",342 x25519_public_key: options.x25519PublicKey ?? "",343 device_id: options.deviceId,344 name: options.name,345 pairing: options.pairing ?? false,346 port: options.port ?? 0,347 },348 ["ADVERTISING"]349 );350 return response.port;351}352353export async function stopAdvertising(): Promise<void> {354 await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);355}356357export function discoverPeers(): void {358 sendToHost({ type: "DISCOVER" });359}360361export async function stopDiscovery(): Promise<void> {362 await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);363}364365export async function connectToPeer(366 host: string,367 port: number,368 remoteX25519PublicKey: string369): Promise<string> {370 const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(371 {372 type: "CONNECT",373 host,374 port,375 remote_x25519_public_key: remoteX25519PublicKey,376 377 378 local_x25519_private_key: "",379 },380 ["CONNECTED"]381 );382 return response.connection_id;383}384385export function disconnectFromPeer(connectionId: string): void {386 sendToHost({ type: "DISCONNECT", connection_id: connectionId });387}388389export function sendSyncData(connectionId: string, data: string): void {390 sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });391}392393export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {394 const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(395 { type: "GET_SESSION_KEY", connection_id: connectionId },396 ["SESSION_KEY"]397 );398 if (response.connection_id !== connectionId) {399 throw new Error("sync native host returned session key for unexpected connection");400 }401 const decoded = decodeBase64Url(response.key);402 if (decoded.length !== 32) {403 throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);404 }405 return decoded;406}407408function decodeBase64Url(value: string): Uint8Array {409 const padded = value410 .replace(/-/g, "+")411 .replace(/_/g, "/")412 .padEnd(Math.ceil(value.length / 4) * 4, "=");413 const binary = atob(padded);414 const out = new Uint8Array(binary.length);415 for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);416 return out;417}418419export async function enterPairingMode(): Promise<void> {420 await requestFromHost({ type: "ENTER_PAIRING_MODE" }, ["PAIRING_MODE_ENTERED"]);421}422423export async function exitPairingMode(): Promise<void> {424 await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);425}426427export interface PairingServerInfo {428 tcpPort: number;429 wsPort?: number;430}431432export async function startPairingServer(options: {433 token: string;434 deviceId: string;435 name: string;436}): Promise<PairingServerInfo> {437 const response = await requestFromHost<Extract<HostResponse, { type: "PAIRING_SERVER_STARTED" }>>(438 {439 type: "START_PAIRING_SERVER",440 token: options.token,441 device_id: options.deviceId,442 name: options.name,443 },444 ["PAIRING_SERVER_STARTED"],445 15_000,446 );447 return {448 tcpPort: response.tcp_port,449 wsPort: response.ws_port ?? undefined,450 };451}452453export async function stopPairingServer(): Promise<void> {454 await requestFromHost({ type: "STOP_PAIRING_SERVER" }, ["STOP_PAIRING_SERVER_OK"]);455}456457export async function resolvePeerByCode(458 token: string,459 timeoutMs = 8_000,460): Promise<PeerInfo> {461 const response = await requestFromHost<Extract<HostResponse, { type: "PEER_RESOLVED" }>>(462 { type: "RESOLVE_PEER_BY_CODE", token, timeout_ms: timeoutMs },463 ["PEER_RESOLVED"],464 timeoutMs + 2_000,465 );466 return mapPeer(response.peer);467}468469470471472473474475export async function getIdentityPublicKey(): Promise<string> {476 return getNativeIdentity();477}478479export async function getNativeStatus(): Promise<NativeSyncStatus> {480 const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(481 { type: "STATUS" },482 ["STATUS"]483 );484 return {485 advertising: response.advertising,486 tcpPort: response.tcp_port ?? undefined,487 wsPort: response.ws_port ?? undefined,488 connections: response.connections,489 };490}491492export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {493 return subscribe(peerDiscoveredHandlers, handler);494}495496export function onPeerLost(handler: (deviceId: string) => void): () => void {497 return subscribe(peerLostHandlers, handler);498}499500export function onConnected(handler: (connectionId: string) => void): () => void {501 return subscribe(connectedHandlers, handler);502}503504export function onIncomingConnection(505 handler: (info: IncomingConnectionInfo) => void506): () => void {507 return subscribe(incomingConnectionHandlers, handler);508}509510export function onDisconnected(handler: (connectionId?: string) => void): () => void {511 return subscribe(disconnectedHandlers, handler);512}513514export function onSyncMessage(515 handler: (connectionId: string, data: string) => void516): () => void {517 return subscribe(syncMessageHandlers, handler);518}519520export function onError(521 handler: (error: string, connectionId?: string) => void522): () => void {523 return subscribe(errorHandlers, handler);524}525526function subscribe<T>(handlers: T[], handler: T): () => void {527 handlers.push(handler);528 return () => {529 const index = handlers.indexOf(handler);530 if (index >= 0) handlers.splice(index, 1);531 };532}533534function mapPeer(peer: HostPeer): PeerInfo {535 return {536 host: peer.host,537 port: peer.port,538 wsPort: peer.ws_port ?? undefined,539 name: peer.name,540 pairing: peer.pairing ?? false,541 deviceId: peer.device_id,542 x25519PublicKey: peer.x25519_public_key,543 };544}545546function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {547 return {548 publicKeyHex: device.public_key_hex,549 deviceName: device.device_name,550 pairedAt: device.paired_at,551 lastSeen: device.last_seen,552 };553}