/** * Capacitor pairing server — JS runtime for Android show-mode pairing. * * Kotlin (`SyncPlugin.startPairingTcpServer`) is the byte pump: it accepts * one TCP connection and pumps inbound bytes to JS as `pairingChunk` events * (base64). Outbound bytes go back via `sendPairingChunk`. Bytes are not * framed on the Kotlin side — chunks may split or coalesce frames. * * This module reassembles u32-BE length-prefixed frames (matching * `peach-sync-host/src/frame.rs`), drives the Noise NK responder, runs the * auth challenge/proof round, and processes a single PAIR_REQUEST. On * accept it sends PAIR_ACCEPT, persists the paired device to * `peach_paired_devices`, and writes a `RecentPairing` so the popup can * surface a "Paired with X" toast on next open. */ import { registerPlugin, type PluginListenerHandle } from "@capacitor/core"; import { noiseNKResponder, performResponderAuth, type NoiseNKSession, } from "./noise-js"; import { encodeSyncMessage, decodeSyncMessage } from "./sync-wire"; const MAX_FRAME_BYTES = 1024 * 1024; const PAIR_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; interface SyncPluginPairing { startPairingTcpServer(): Promise<{ tcpPort: number }>; startPairingBeacon(opts: { token: string; tcpPort: number; x25519PublicKey: string; deviceId: string; name: string; }): Promise; stopPairingServer(): Promise; sendPairingChunk(opts: { dataBase64: string }): Promise; addListener( eventName: "pairingChunk", listener: (event: { dataBase64: string }) => void, ): Promise; addListener( eventName: "pairingConnected", listener: (event: { peerAddr: string }) => void, ): Promise; addListener( eventName: "pairingEnded", listener: () => void, ): Promise; } const Sync = registerPlugin("Sync"); export interface CapacitorPairingDeps { /** Our static x25519 keypair. Persisted by `getAndroidSyncIdentity()`. */ privateKey: Uint8Array; publicKey: Uint8Array; /** Local device name shown to the peer in PAIR_ACCEPT. */ deviceName: string; /** base64url-encoded random device id (not the public key). */ deviceId: string; /** Hex-encoded public key, for the UDP beacon's `x25519_public_key`. */ publicKeyHex: string; /** 6-digit pairing code. */ token: string; /** Persists the new paired device on accept. */ onPaired: (paired: { deviceId: string; displayName: string; publicKeyHex: string; }) => void | Promise; /** * Optional notification fired BEFORE persisting — popup uses it to flip to * the success card while the SW is still finishing storage writes. */ onPairRequest?: (deviceName: string) => void; } export interface CapacitorPairingHandle { /** Tear down the server, beacon, and any in-flight handshake. */ stop: () => Promise; } function base64ToBytes(b64: string): Uint8Array { const binary = atob(b64); const out = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); return out; } function bytesToBase64(bytes: Uint8Array): string { let binary = ""; for (const b of bytes) binary += String.fromCharCode(b); return btoa(binary); } /** * Start the show-mode pairing server: bind TCP listener via Kotlin, start * the UDP beacon, then drive Noise NK responder + auth + PAIR_REQUEST as * soon as a peer connects. * * The returned handle's `stop()` is idempotent — call it from popup * cleanup, vault-lock, and post-pair so the kotlin sockets always close. */ export async function startCapacitorPairingServer( deps: CapacitorPairingDeps, ): Promise { const { tcpPort } = await Sync.startPairingTcpServer(); await Sync.startPairingBeacon({ token: deps.token, tcpPort, x25519PublicKey: deps.publicKeyHex, deviceId: deps.deviceId, name: deps.deviceName, }); // Inbound byte buffer — Kotlin sends raw socket reads; we reassemble // u32-BE length-prefixed frames ourselves. let inboundBuf = new Uint8Array(0); const frameWaiters: Array<{ resolve: (frame: Uint8Array) => void; reject: (err: Error) => void; }> = []; let connectionClosed = false; let closeReason: Error | null = null; let stopped = false; function appendInbound(chunk: Uint8Array) { const next = new Uint8Array(inboundBuf.length + chunk.length); next.set(inboundBuf, 0); next.set(chunk, inboundBuf.length); inboundBuf = next; drainFrameWaiters(); } function tryReadFrame(): Uint8Array | null { if (inboundBuf.length < 4) return null; const view = new DataView(inboundBuf.buffer, inboundBuf.byteOffset, 4); const len = view.getUint32(0, false); // big-endian, matches frame.rs if (len > MAX_FRAME_BYTES) { throw new Error(`incoming frame too large: ${len}`); } if (inboundBuf.length < 4 + len) return null; const payload = inboundBuf.slice(4, 4 + len); inboundBuf = inboundBuf.slice(4 + len); return payload; } function drainFrameWaiters() { while (frameWaiters.length > 0) { let frame: Uint8Array | null; try { frame = tryReadFrame(); } catch (err) { const w = frameWaiters.shift(); w?.reject(err instanceof Error ? err : new Error(String(err))); continue; } if (!frame) return; const w = frameWaiters.shift()!; w.resolve(frame); } } function failPendingWaiters(err: Error) { while (frameWaiters.length > 0) { const w = frameWaiters.shift()!; w.reject(err); } } function receiveFrame(): Promise { if (connectionClosed && inboundBuf.length < 4) { return Promise.reject(closeReason ?? new Error("connection closed")); } return new Promise((resolve, reject) => { frameWaiters.push({ resolve, reject }); drainFrameWaiters(); }); } async function sendFrame(payload: Uint8Array): Promise { if (connectionClosed) { throw closeReason ?? new Error("connection closed"); } const len = payload.length; const framed = new Uint8Array(4 + len); new DataView(framed.buffer).setUint32(0, len, false); framed.set(payload, 4); await Sync.sendPairingChunk({ dataBase64: bytesToBase64(framed) }); } const chunkHandle = await Sync.addListener("pairingChunk", (event) => { if (stopped) return; try { appendInbound(base64ToBytes(event.dataBase64)); } catch (err) { failPendingWaiters(err instanceof Error ? err : new Error(String(err))); } }); const endHandle = await Sync.addListener("pairingEnded", () => { connectionClosed = true; closeReason = closeReason ?? new Error("connection closed"); failPendingWaiters(closeReason); }); const connectedHandle = await Sync.addListener("pairingConnected", () => { void runHandshake(); }); async function runHandshake(): Promise { let session: NoiseNKSession; try { session = await noiseNKResponder( deps.privateKey, deps.publicKey, sendFrame, receiveFrame, ); } catch (err) { console.warn("[peach-pair] noise responder failed:", err); void stop(); return; } const sendEnc = async (data: Uint8Array) => sendFrame(session.encrypt(data)); const recvEnc = async () => session.decrypt(await receiveFrame()); let initiatorPublicKey: Uint8Array; try { const auth = await performResponderAuth( session, deps.privateKey, deps.publicKey, sendEnc, recvEnc, ); initiatorPublicKey = auth.initiatorPublicKey; } catch (err) { console.warn("[peach-pair] auth failed:", err); void stop(); return; } // Read exactly one PAIR_REQUEST. Anything else on a code-pairing window // is an attempted bypass — drop the connection. let pairRequest; try { const frame = await Promise.race([ recvEnc(), new Promise((_, reject) => setTimeout(() => reject(new Error("PAIR_REQUEST timeout")), PAIR_REQUEST_TIMEOUT_MS), ), ]); const text = new TextDecoder().decode(frame); const message = decodeSyncMessage(text); if (message.type !== "PAIR_REQUEST") { throw new Error(`unexpected frame: ${message.type}`); } pairRequest = message; } catch (err) { console.warn("[peach-pair] PAIR_REQUEST read failed:", err); void stop(); return; } // The 6-digit token IS the user's consent — auto-accept on the showing // side. The Noise+auth round above already proved the initiator owns // the public key in PAIR_REQUEST; cross-check it with auth here. const claimedHex = pairRequest.public_key_hex.toLowerCase(); const authedHex = bytesToHex(initiatorPublicKey); if (claimedHex !== authedHex) { console.warn( "[peach-pair] PAIR_REQUEST public_key_hex did not match authenticated key", ); void stop(); return; } deps.onPairRequest?.(pairRequest.device_name); try { await sendEnc( new TextEncoder().encode( encodeSyncMessage({ type: "PAIR_ACCEPT", public_key_hex: bytesToHex(deps.publicKey), device_name: deps.deviceName, }), ), ); } catch (err) { console.warn("[peach-pair] PAIR_ACCEPT send failed:", err); void stop(); return; } try { await deps.onPaired({ deviceId: pairRequest.public_key_hex, displayName: pairRequest.device_name, publicKeyHex: pairRequest.public_key_hex, }); } catch (err) { console.warn("[peach-pair] onPaired callback failed:", err); } // One code = one pairing. Drop the connection so a second peer can't // sneak in on the same window. void stop(); } async function stop(): Promise { if (stopped) return; stopped = true; try { await chunkHandle.remove(); } catch { /* best-effort */ } try { await endHandle.remove(); } catch { /* best-effort */ } try { await connectedHandle.remove(); } catch { /* best-effort */ } try { await Sync.stopPairingServer(); } catch { /* best-effort */ } connectionClosed = true; closeReason = closeReason ?? new Error("pairing server stopped"); failPendingWaiters(closeReason); } return { stop }; } function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); }