1234567891011121314151617import { registerPlugin, type PluginListenerHandle } from "@capacitor/core";18import {19 noiseNKResponder,20 performResponderAuth,21 type NoiseNKSession,22} from "./noise-js";23import { encodeSyncMessage, decodeSyncMessage } from "./sync-wire";2425const MAX_FRAME_BYTES = 1024 * 1024;26const PAIR_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;2728interface SyncPluginPairing {29 startPairingTcpServer(): Promise<{ tcpPort: number }>;30 startPairingBeacon(opts: {31 token: string;32 tcpPort: number;33 x25519PublicKey: string;34 deviceId: string;35 name: string;36 }): Promise<void>;37 stopPairingServer(): Promise<void>;38 sendPairingChunk(opts: { dataBase64: string }): Promise<void>;39 addListener(40 eventName: "pairingChunk",41 listener: (event: { dataBase64: string }) => void,42 ): Promise<PluginListenerHandle>;43 addListener(44 eventName: "pairingConnected",45 listener: (event: { peerAddr: string }) => void,46 ): Promise<PluginListenerHandle>;47 addListener(48 eventName: "pairingEnded",49 listener: () => void,50 ): Promise<PluginListenerHandle>;51}5253const Sync = registerPlugin<SyncPluginPairing>("Sync");5455export interface CapacitorPairingDeps {56 57 privateKey: Uint8Array;58 publicKey: Uint8Array;59 60 deviceName: string;61 62 deviceId: string;63 64 publicKeyHex: string;65 66 token: string;67 68 onPaired: (paired: {69 deviceId: string;70 displayName: string;71 publicKeyHex: string;72 }) => void | Promise<void>;73 74757677 onPairRequest?: (deviceName: string) => void;78}7980export interface CapacitorPairingHandle {81 82 stop: () => Promise<void>;83}8485function base64ToBytes(b64: string): Uint8Array {86 const binary = atob(b64);87 const out = new Uint8Array(binary.length);88 for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);89 return out;90}9192function bytesToBase64(bytes: Uint8Array): string {93 let binary = "";94 for (const b of bytes) binary += String.fromCharCode(b);95 return btoa(binary);96}979899100101102103104105106export async function startCapacitorPairingServer(107 deps: CapacitorPairingDeps,108): Promise<CapacitorPairingHandle> {109 const { tcpPort } = await Sync.startPairingTcpServer();110 await Sync.startPairingBeacon({111 token: deps.token,112 tcpPort,113 x25519PublicKey: deps.publicKeyHex,114 deviceId: deps.deviceId,115 name: deps.deviceName,116 });117118 119 120 let inboundBuf = new Uint8Array(0);121 const frameWaiters: Array<{122 resolve: (frame: Uint8Array) => void;123 reject: (err: Error) => void;124 }> = [];125 let connectionClosed = false;126 let closeReason: Error | null = null;127 let stopped = false;128129 function appendInbound(chunk: Uint8Array) {130 const next = new Uint8Array(inboundBuf.length + chunk.length);131 next.set(inboundBuf, 0);132 next.set(chunk, inboundBuf.length);133 inboundBuf = next;134 drainFrameWaiters();135 }136137 function tryReadFrame(): Uint8Array | null {138 if (inboundBuf.length < 4) return null;139 const view = new DataView(inboundBuf.buffer, inboundBuf.byteOffset, 4);140 const len = view.getUint32(0, false); 141 if (len > MAX_FRAME_BYTES) {142 throw new Error(`incoming frame too large: ${len}`);143 }144 if (inboundBuf.length < 4 + len) return null;145 const payload = inboundBuf.slice(4, 4 + len);146 inboundBuf = inboundBuf.slice(4 + len);147 return payload;148 }149150 function drainFrameWaiters() {151 while (frameWaiters.length > 0) {152 let frame: Uint8Array | null;153 try {154 frame = tryReadFrame();155 } catch (err) {156 const w = frameWaiters.shift();157 w?.reject(err instanceof Error ? err : new Error(String(err)));158 continue;159 }160 if (!frame) return;161 const w = frameWaiters.shift()!;162 w.resolve(frame);163 }164 }165166 function failPendingWaiters(err: Error) {167 while (frameWaiters.length > 0) {168 const w = frameWaiters.shift()!;169 w.reject(err);170 }171 }172173 function receiveFrame(): Promise<Uint8Array> {174 if (connectionClosed && inboundBuf.length < 4) {175 return Promise.reject(closeReason ?? new Error("connection closed"));176 }177 return new Promise<Uint8Array>((resolve, reject) => {178 frameWaiters.push({ resolve, reject });179 drainFrameWaiters();180 });181 }182183 async function sendFrame(payload: Uint8Array): Promise<void> {184 if (connectionClosed) {185 throw closeReason ?? new Error("connection closed");186 }187 const len = payload.length;188 const framed = new Uint8Array(4 + len);189 new DataView(framed.buffer).setUint32(0, len, false);190 framed.set(payload, 4);191 await Sync.sendPairingChunk({ dataBase64: bytesToBase64(framed) });192 }193194 const chunkHandle = await Sync.addListener("pairingChunk", (event) => {195 if (stopped) return;196 try {197 appendInbound(base64ToBytes(event.dataBase64));198 } catch (err) {199 failPendingWaiters(err instanceof Error ? err : new Error(String(err)));200 }201 });202203 const endHandle = await Sync.addListener("pairingEnded", () => {204 connectionClosed = true;205 closeReason = closeReason ?? new Error("connection closed");206 failPendingWaiters(closeReason);207 });208209 const connectedHandle = await Sync.addListener("pairingConnected", () => {210 void runHandshake();211 });212213 async function runHandshake(): Promise<void> {214 let session: NoiseNKSession;215 try {216 session = await noiseNKResponder(217 deps.privateKey,218 deps.publicKey,219 sendFrame,220 receiveFrame,221 );222 } catch (err) {223 console.warn("[peach-pair] noise responder failed:", err);224 void stop();225 return;226 }227228 const sendEnc = async (data: Uint8Array) => sendFrame(session.encrypt(data));229 const recvEnc = async () => session.decrypt(await receiveFrame());230231 let initiatorPublicKey: Uint8Array;232 try {233 const auth = await performResponderAuth(234 session,235 deps.privateKey,236 deps.publicKey,237 sendEnc,238 recvEnc,239 );240 initiatorPublicKey = auth.initiatorPublicKey;241 } catch (err) {242 console.warn("[peach-pair] auth failed:", err);243 void stop();244 return;245 }246247 248 249 let pairRequest;250 try {251 const frame = await Promise.race([252 recvEnc(),253 new Promise<Uint8Array>((_, reject) =>254 setTimeout(() => reject(new Error("PAIR_REQUEST timeout")), PAIR_REQUEST_TIMEOUT_MS),255 ),256 ]);257 const text = new TextDecoder().decode(frame);258 const message = decodeSyncMessage(text);259 if (message.type !== "PAIR_REQUEST") {260 throw new Error(`unexpected frame: ${message.type}`);261 }262 pairRequest = message;263 } catch (err) {264 console.warn("[peach-pair] PAIR_REQUEST read failed:", err);265 void stop();266 return;267 }268269 270 271 272 const claimedHex = pairRequest.public_key_hex.toLowerCase();273 const authedHex = bytesToHex(initiatorPublicKey);274 if (claimedHex !== authedHex) {275 console.warn(276 "[peach-pair] PAIR_REQUEST public_key_hex did not match authenticated key",277 );278 void stop();279 return;280 }281282 deps.onPairRequest?.(pairRequest.device_name);283284 try {285 await sendEnc(286 new TextEncoder().encode(287 encodeSyncMessage({288 type: "PAIR_ACCEPT",289 public_key_hex: bytesToHex(deps.publicKey),290 device_name: deps.deviceName,291 }),292 ),293 );294 } catch (err) {295 console.warn("[peach-pair] PAIR_ACCEPT send failed:", err);296 void stop();297 return;298 }299300 try {301 await deps.onPaired({302 deviceId: pairRequest.public_key_hex,303 displayName: pairRequest.device_name,304 publicKeyHex: pairRequest.public_key_hex,305 });306 } catch (err) {307 console.warn("[peach-pair] onPaired callback failed:", err);308 }309310 311 312 void stop();313 }314315 async function stop(): Promise<void> {316 if (stopped) return;317 stopped = true;318 try { await chunkHandle.remove(); } catch { }319 try { await endHandle.remove(); } catch { }320 try { await connectedHandle.remove(); } catch { }321 try { await Sync.stopPairingServer(); } catch { }322 connectionClosed = true;323 closeReason = closeReason ?? new Error("pairing server stopped");324 failPendingWaiters(closeReason);325 }326327 return { stop };328}329330function bytesToHex(bytes: Uint8Array): string {331 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");332}