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" };4142type HostPeer = {43 host: string;44 port: number;45 ws_port?: number | null;46 x25519_public_key: string;47 device_id: string;48 name: string;49 pairing?: boolean;50};5152type HostTrustedDevice = {53 public_key_hex: string;54 device_name: string;55 paired_at: number;56 last_seen: number;57};5859type HostResponse =60 | { type: "IDENTITY"; public_key_hex: string }61 | { type: "TRUSTED_DEVICES"; devices: HostTrustedDevice[] }62 | { type: "TRUST_DEVICE_OK" }63 | { type: "REMOVE_TRUSTED_DEVICE_OK" }64 | { type: "ROTATE_IDENTITY_OK"; public_key_hex: string }65 | { type: "ADVERTISING"; port: number; ws_port?: number | null }66 | { type: "STOP_ADVERTISING_OK" }67 | { type: "PEER_DISCOVERED"; peer: HostPeer }68 | { type: "PEER_LOST"; device_id: string }69 | { type: "STOP_DISCOVERY_OK" }70 | { type: "CONNECTED"; connection_id: string }71 | {72 type: "PEER_CONNECTED";73 connection_id: string;74 peer_addr: string;75 is_pairing_only?: boolean;76 peer_public_key_hex?: string;77 }78 | { type: "DISCONNECTED"; connection_id: string }79 | { type: "SYNC_RECEIVED"; connection_id: string; data: string }80 | { type: "SESSION_KEY"; connection_id: string; key: string }81 | {82 type: "STATUS";83 advertising: boolean;84 tcp_port?: number | null;85 ws_port?: number | null;86 connections: string[];87 }88 | { type: "ERROR"; connection_id?: string | null; message: string }89 | { type: "PAIRING_MODE_ENTERED" }90 | { type: "PAIRING_MODE_EXITED" };9192export interface PeerInfo {93 host: string;94 port: number;95 96979899100 wsPort?: number;101 name: string;102 pairing: boolean;103 deviceId: string;104 x25519PublicKey: string;105}106107export interface NativeTrustedDevice {108 publicKeyHex: string;109 deviceName: string;110 pairedAt: number;111 lastSeen: number;112}113114export interface NativeSyncStatus {115 advertising: boolean;116 tcpPort?: number;117 wsPort?: number;118 connections: string[];119}120121type ExpectedType = HostResponse["type"];122type PendingRequest<T extends HostResponse = HostResponse> = {123 expected: readonly ExpectedType[];124 resolve: (response: T) => void;125 reject: (error: Error) => void;126 timeout: ReturnType<typeof setTimeout>;127};128129let syncPort: chrome.runtime.Port | null = null;130let pendingRequests: PendingRequest[] = [];131132const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];133const peerLostHandlers: Array<(deviceId: string) => void> = [];134const connectedHandlers: Array<(connectionId: string) => void> = [];135export interface IncomingConnectionInfo {136 connectionId: string;137 peerAddr: string;138 139 isPairingOnly: boolean;140 141 peerPublicKeyHex?: string;142}143144const incomingConnectionHandlers: Array<(info: IncomingConnectionInfo) => void> = [];145const disconnectedHandlers: Array<(connectionId?: string) => void> = [];146const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];147const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];148149function handleHostMessage(response: HostResponse): void {150 switch (response.type) {151 case "PEER_DISCOVERED": {152 const peer = mapPeer(response.peer);153 for (const handler of peerDiscoveredHandlers) handler(peer);154 break;155 }156 case "PEER_LOST": {157 for (const handler of peerLostHandlers) handler(response.device_id);158 break;159 }160 case "PEER_CONNECTED": {161 const info: IncomingConnectionInfo = {162 connectionId: response.connection_id,163 peerAddr: response.peer_addr,164 isPairingOnly: response.is_pairing_only === true,165 peerPublicKeyHex: response.peer_public_key_hex,166 };167 for (const handler of incomingConnectionHandlers) handler(info);168 break;169 }170 case "DISCONNECTED": {171 for (const handler of disconnectedHandlers) handler(response.connection_id);172 break;173 }174 case "SYNC_RECEIVED": {175 for (const handler of syncMessageHandlers) {176 handler(response.connection_id, response.data);177 }178 break;179 }180 case "ERROR": {181 rejectPending(response.message, response.connection_id ?? undefined);182 for (const handler of errorHandlers) {183 handler(response.message, response.connection_id ?? undefined);184 }185 break;186 }187 default:188 resolveExpected(response);189 if (response.type === "CONNECTED") {190 for (const handler of connectedHandlers) handler(response.connection_id);191 }192 break;193 }194}195196function resolveExpected(response: HostResponse): void {197 const index = pendingRequests.findIndex((pending) =>198 pending.expected.includes(response.type)199 );200 if (index === -1) return;201202 const [pending] = pendingRequests.splice(index, 1);203 clearTimeout(pending.timeout);204 pending.resolve(response);205}206207function rejectPending(message: string, connectionId?: string): void {208 const index = pendingRequests.findIndex((pending) =>209 connectionId ? pending.expected.includes("CONNECTED") : true210 );211 if (index === -1) return;212213 const [pending] = pendingRequests.splice(index, 1);214 clearTimeout(pending.timeout);215 pending.reject(new Error(message));216}217218function rejectAllPending(message: string): void {219 const pending = pendingRequests;220 pendingRequests = [];221 for (const request of pending) {222 clearTimeout(request.timeout);223 request.reject(new Error(message));224 }225}226227function sendToHost(msg: HostMessage): void {228 if (!syncPort) throw new Error("sync native host is not connected");229 syncPort.postMessage(msg);230}231232function requestFromHost<T extends HostResponse>(233 msg: HostMessage,234 expected: readonly ExpectedType[],235 timeoutMs = 5000236): Promise<T> {237 if (!syncPort) connectSyncHost();238239 return new Promise<T>((resolve, reject) => {240 const pending: PendingRequest<T> = {241 expected,242 resolve,243 reject,244 timeout: setTimeout(() => {245 pendingRequests = pendingRequests.filter((entry) => entry !== pending);246 reject(new Error(`sync native host timed out waiting for ${expected.join("/")}`));247 }, timeoutMs),248 };249250 pendingRequests.push(pending as PendingRequest);251 try {252 sendToHost(msg);253 } catch (err) {254 pendingRequests = pendingRequests.filter((entry) => entry !== pending);255 clearTimeout(pending.timeout);256 reject(err instanceof Error ? err : new Error(String(err)));257 }258 });259}260261export function connectSyncHost(): void {262 if (syncPort) return;263 if (!chrome.runtime.connectNative) {264 throw new Error("native messaging is not available in this browser context");265 }266267 syncPort = chrome.runtime.connectNative(SYNC_NATIVE_HOST_NAME);268 syncPort.onMessage.addListener(handleHostMessage);269 syncPort.onDisconnect.addListener(() => {270 syncPort = null;271 rejectAllPending("sync native host disconnected");272 for (const handler of disconnectedHandlers) handler();273 });274}275276export function disconnectSyncHost(): void {277 const port = syncPort;278 syncPort = null;279 rejectAllPending("sync native host disconnected");280 port?.disconnect();281}282283export function isHostConnected(): boolean {284 return syncPort !== null;285}286287export async function getNativeIdentity(): Promise<string> {288 const response = await requestFromHost<Extract<HostResponse, { type: "IDENTITY" }>>(289 { type: "GET_IDENTITY" },290 ["IDENTITY"]291 );292 return response.public_key_hex;293}294295export async function getTrustedDevices(): Promise<NativeTrustedDevice[]> {296 const response = await requestFromHost<Extract<HostResponse, { type: "TRUSTED_DEVICES" }>>(297 { type: "GET_TRUSTED_DEVICES" },298 ["TRUSTED_DEVICES"]299 );300 return response.devices.map(mapTrustedDevice);301}302303export async function trustDevice(304 publicKeyHex: string,305 deviceName: string306): Promise<void> {307 await requestFromHost({ type: "TRUST_DEVICE", public_key_hex: publicKeyHex, device_name: deviceName }, [308 "TRUST_DEVICE_OK",309 ]);310}311312export async function removeTrustedDevice(publicKeyHex: string): Promise<void> {313 await requestFromHost({ type: "REMOVE_TRUSTED_DEVICE", public_key_hex: publicKeyHex }, [314 "REMOVE_TRUSTED_DEVICE_OK",315 ]);316}317318export async function rotateNativeIdentity(): Promise<string> {319 const response = await requestFromHost<Extract<HostResponse, { type: "ROTATE_IDENTITY_OK" }>>(320 { type: "ROTATE_IDENTITY" },321 ["ROTATE_IDENTITY_OK"]322 );323 return response.public_key_hex;324}325326export async function advertise(options: {327 deviceId: string;328 name: string;329 x25519PublicKey?: string;330 pairing?: boolean;331 port?: number;332}): Promise<number> {333 const response = await requestFromHost<Extract<HostResponse, { type: "ADVERTISING" }>>(334 {335 type: "ADVERTISE",336 x25519_public_key: options.x25519PublicKey ?? "",337 device_id: options.deviceId,338 name: options.name,339 pairing: options.pairing ?? false,340 port: options.port ?? 0,341 },342 ["ADVERTISING"]343 );344 return response.port;345}346347export async function stopAdvertising(): Promise<void> {348 await requestFromHost({ type: "STOP_ADVERTISING" }, ["STOP_ADVERTISING_OK"]);349}350351export function discoverPeers(): void {352 sendToHost({ type: "DISCOVER" });353}354355export async function stopDiscovery(): Promise<void> {356 await requestFromHost({ type: "STOP_DISCOVERY" }, ["STOP_DISCOVERY_OK"]);357}358359export async function connectToPeer(360 host: string,361 port: number,362 remoteX25519PublicKey: string363): Promise<string> {364 const response = await requestFromHost<Extract<HostResponse, { type: "CONNECTED" }>>(365 {366 type: "CONNECT",367 host,368 port,369 remote_x25519_public_key: remoteX25519PublicKey,370 371 372 local_x25519_private_key: "",373 },374 ["CONNECTED"]375 );376 return response.connection_id;377}378379export function disconnectFromPeer(connectionId: string): void {380 sendToHost({ type: "DISCONNECT", connection_id: connectionId });381}382383export function sendSyncData(connectionId: string, data: string): void {384 sendToHost({ type: "SYNC_SEND", connection_id: connectionId, data });385}386387export async function getNativeSessionKey(connectionId: string): Promise<Uint8Array> {388 const response = await requestFromHost<Extract<HostResponse, { type: "SESSION_KEY" }>>(389 { type: "GET_SESSION_KEY", connection_id: connectionId },390 ["SESSION_KEY"]391 );392 if (response.connection_id !== connectionId) {393 throw new Error("sync native host returned session key for unexpected connection");394 }395 const decoded = decodeBase64Url(response.key);396 if (decoded.length !== 32) {397 throw new Error(`sync native host returned ${decoded.length}-byte session key (want 32)`);398 }399 return decoded;400}401402function decodeBase64Url(value: string): Uint8Array {403 const padded = value404 .replace(/-/g, "+")405 .replace(/_/g, "/")406 .padEnd(Math.ceil(value.length / 4) * 4, "=");407 const binary = atob(padded);408 const out = new Uint8Array(binary.length);409 for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);410 return out;411}412413export async function enterPairingMode(): Promise<void> {414 await requestFromHost({ type: "ENTER_PAIRING_MODE" }, ["PAIRING_MODE_ENTERED"]);415}416417export async function exitPairingMode(): Promise<void> {418 await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);419}420421422423424425426427export async function getIdentityPublicKey(): Promise<string> {428 return getNativeIdentity();429}430431export async function getNativeStatus(): Promise<NativeSyncStatus> {432 const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(433 { type: "STATUS" },434 ["STATUS"]435 );436 return {437 advertising: response.advertising,438 tcpPort: response.tcp_port ?? undefined,439 wsPort: response.ws_port ?? undefined,440 connections: response.connections,441 };442}443444export function onPeerDiscovered(handler: (peer: PeerInfo) => void): () => void {445 return subscribe(peerDiscoveredHandlers, handler);446}447448export function onPeerLost(handler: (deviceId: string) => void): () => void {449 return subscribe(peerLostHandlers, handler);450}451452export function onConnected(handler: (connectionId: string) => void): () => void {453 return subscribe(connectedHandlers, handler);454}455456export function onIncomingConnection(457 handler: (info: IncomingConnectionInfo) => void458): () => void {459 return subscribe(incomingConnectionHandlers, handler);460}461462export function onDisconnected(handler: (connectionId?: string) => void): () => void {463 return subscribe(disconnectedHandlers, handler);464}465466export function onSyncMessage(467 handler: (connectionId: string, data: string) => void468): () => void {469 return subscribe(syncMessageHandlers, handler);470}471472export function onError(473 handler: (error: string, connectionId?: string) => void474): () => void {475 return subscribe(errorHandlers, handler);476}477478function subscribe<T>(handlers: T[], handler: T): () => void {479 handlers.push(handler);480 return () => {481 const index = handlers.indexOf(handler);482 if (index >= 0) handlers.splice(index, 1);483 };484}485486function mapPeer(peer: HostPeer): PeerInfo {487 return {488 host: peer.host,489 port: peer.port,490 wsPort: peer.ws_port ?? undefined,491 name: peer.name,492 pairing: peer.pairing ?? false,493 deviceId: peer.device_id,494 x25519PublicKey: peer.x25519_public_key,495 };496}497498function mapTrustedDevice(device: HostTrustedDevice): NativeTrustedDevice {499 return {500 publicKeyHex: device.public_key_hex,501 deviceName: device.device_name,502 pairedAt: device.paired_at,503 lastSeen: device.last_seen,504 };505}