12345678910111213141516import { noiseNKInitiator, performInitiatorAuth } from "./noise-js";17import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";18import { parsePairingCode } from "./pairing-code";19import {20 pairingInvitationUrl,21 parsePairingInvitation,22} from "./pairing-invitation";23import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";242526export const PAIRING_PORT = 49142;2728export type PairingDialError =29 | "invalid_code" 30 | "no_host_found" 31 | "handshake_failed"; 323334353637export interface PairingSocket {38 send(bytes: Uint8Array): void;39 40 receive(): Promise<Uint8Array>;41 close(): void;42 43 closedBeforeFrame(): boolean;44}4546export interface PairingDialDeps {47 openSocket(ip: string, port: number): Promise<PairingSocket>;48}4950export interface PairingDialOptions {51 flow?: "adoption";52}5354export interface RelayPairingDialDeps {55 openSocket(url: string): Promise<PairingSocket>;56}5758export interface DialledPairingChannel {59 60 channel: PairingTransport;61 62 sas: string;63 64 host: string;65 66 relayUrl?: string;67 68 rendezvousSecretHex: string;69 70 remoteStaticPublicKey: Uint8Array;71 7273747576 authenticateInitiator(77 privateKey: Uint8Array,78 publicKey: Uint8Array,79 ): Promise<void>;80 socket: PairingSocket;81}8283export type DialResult =84 | { ok: true; dialled: DialledPairingChannel }85 | { ok: false; error: PairingDialError };8687888990919293export async function dialPairingChannel(94 code: string,95 deps: PairingDialDeps,96 options: PairingDialOptions = {},97): Promise<DialResult> {98 const pairing = parsePairingCode(code);99 if (!pairing) return { ok: false, error: "invalid_code" };100 const { ip: host, port } = pairing;101102 let sock: PairingSocket;103 try {104 sock = await deps.openSocket(host, port);105 } catch {106 return { ok: false, error: "no_host_found" };107 }108109 return finishPairingHandshake(sock, pairing.tokenHex, host, options.flow);110}111112113114115116export async function dialRelayPairingChannel(117 encodedInvitation: string,118 deps: RelayPairingDialDeps,119): Promise<DialResult> {120 const invitation = parsePairingInvitation(encodedInvitation);121 if (!invitation) return { ok: false, error: "invalid_code" };122123 let sock: PairingSocket;124 try {125 sock = await deps.openSocket(pairingInvitationUrl(invitation));126 } catch {127 return { ok: false, error: "no_host_found" };128 }129 const result = await finishPairingHandshake(130 sock,131 invitation.token,132 new URL(invitation.relayUrl).hostname,133 );134 if (result.ok) result.dialled.relayUrl = invitation.relayUrl;135 return result;136}137138async function finishPairingHandshake(139 sock: PairingSocket,140 token: string,141 host: string,142 flow?: "adoption",143): Promise<DialResult> {144 try {145 sock.send(encodeJson({146 type: "PEACH_HELLO",147 token,148 ...(flow ? { flow } : {}),149 }));150151 152 153 let ackBytes: Uint8Array;154 try {155 ackBytes = await sock.receive();156 } catch {157 sock.close();158 return { ok: false, error: "invalid_code" };159 }160 if (sock.closedBeforeFrame() || ackBytes.length === 0) {161 sock.close();162 return { ok: false, error: "invalid_code" };163 }164165 let ephemeralPublicKeyHex: string;166 try {167 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);168 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {169 170 171 sock.close();172 return { ok: false, error: "invalid_code" };173 }174 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;175 } catch {176 sock.close();177 return { ok: false, error: "invalid_code" };178 }179180 const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);181 const session = await noiseNKInitiator(182 remoteStaticPublicKey,183 async (msg) => sock.send(msg),184 () => sock.receive(),185 );186187 const channel: PairingTransport = {188 async send(plaintext) { sock.send(session.encrypt(plaintext)); },189 async receive() { return session.decrypt(await sock.receive()); },190 };191192 return {193 ok: true,194 dialled: {195 channel,196 sas: derivePairingSAS(session.handshakeHash),197 host,198 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),199 remoteStaticPublicKey,200 authenticateInitiator: (privateKey, publicKey) =>201 performInitiatorAuth(202 session,203 privateKey,204 publicKey,205 remoteStaticPublicKey,206 channel.send,207 channel.receive,208 ),209 socket: sock,210 },211 };212 } catch (err) {213 214 215 if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);216 sock.close();217 return { ok: false, error: "handshake_failed" };218 }219}220221222export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {223 return openPairingSocketUrl(`ws://${ip}:${port}/`);224}225226227export function openRelayPairingSocket(url: string): Promise<PairingSocket> {228 return openPairingSocketUrl(url);229}230231function openPairingSocketUrl(url: string): Promise<PairingSocket> {232 return new Promise((resolve, reject) => {233 let ws: WebSocket;234 try {235 ws = new WebSocket(url);236 } catch (err) {237 reject(err instanceof Error ? err : new Error("websocket open failed"));238 return;239 }240 ws.binaryType = "arraybuffer";241242 const inbox: Uint8Array[] = [];243 const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];244 let opened = false;245 let closed = false;246 247 248 let sawCloseBeforeFrame = false;249250 ws.onopen = () => {251 opened = true;252 resolve(sock);253 };254 ws.onmessage = (ev: MessageEvent) => {255 const bytes = new Uint8Array(ev.data as ArrayBuffer);256 const r = readers.shift();257 if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }258 else inbox.push(bytes);259 };260 ws.onerror = () => {261 if (!opened) reject(new Error("websocket connection failed"));262 };263 ws.onclose = () => {264 closed = true;265 if (readers.length) {266 sawCloseBeforeFrame = true;267 while (readers.length) readers.shift()!.reject(new Error("socket closed"));268 }269 if (!opened) reject(new Error("socket closed before open"));270 };271272 const sock: PairingSocket = {273 send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },274 receive(): Promise<Uint8Array> {275 const queued = inbox.shift();276 if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }277 if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }278 return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));279 },280 close() { try { ws.close(); } catch { } },281 closedBeforeFrame() { return sawCloseBeforeFrame; },282 };283 });284}285286function encodeJson(obj: unknown): Uint8Array {287 return new TextEncoder().encode(JSON.stringify(obj));288}289290function decodeJson<T>(bytes: Uint8Array): T {291 return JSON.parse(new TextDecoder().decode(bytes)) as T;292}293294function hexToBytes32(hex: string): Uint8Array {295 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");296 const out = new Uint8Array(32);297 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);298 return out;299}