12345678910111213141516import {17 noiseNKInitiator,18 performInitiatorAuth,19 type NoiseNKSession,20} from "./noise-js";21import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";22import { parsePairingCode } from "./pairing-code";23import {24 pairingInvitationUrl,25 parsePairingInvitation,26} from "./pairing-invitation";27import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";282930export const PAIRING_PORT = 49142;3132export type PairingDialError =33 | "invalid_code" 34 | "no_host_found" 35 | "handshake_failed"; 363738394041export interface PairingSocket {42 send(bytes: Uint8Array): void;43 44 receive(): Promise<Uint8Array>;45 close(): void;46 47 closedBeforeFrame(): boolean;48}4950export interface PairingDialDeps {51 openSocket(ip: string, port: number): Promise<PairingSocket>;52}5354export interface PairingDialOptions {55 flow?: "adoption";56}5758export interface RelayPairingDialDeps {59 openSocket(url: string): Promise<PairingSocket>;60}6162export interface DialledPairingChannel {63 64 channel: PairingTransport;65 66 sas: string;67 68 host: string;69 70 relayUrl?: string;71 72 rendezvousSecretHex: string;73 74 remoteStaticPublicKey: Uint8Array;75 7677787980 authenticateInitiator(81 privateKey: Uint8Array,82 publicKey: Uint8Array,83 ): Promise<void>;84 socket: PairingSocket;85}8687export type DialResult =88 | { ok: true; dialled: DialledPairingChannel }89 | { ok: false; error: PairingDialError };9091929394959697export async function dialPairingChannel(98 code: string,99 deps: PairingDialDeps,100 options: PairingDialOptions = {},101): Promise<DialResult> {102 const pairing = parsePairingCode(code);103 if (!pairing) return { ok: false, error: "invalid_code" };104 const { ip: host, port } = pairing;105106 let sock: PairingSocket;107 try {108 sock = await deps.openSocket(host, port);109 } catch {110 return { ok: false, error: "no_host_found" };111 }112113 return finishPairingHandshake(sock, pairing.tokenHex, host, options.flow);114}115116117118119120export async function dialRelayPairingChannel(121 encodedInvitation: string,122 deps: RelayPairingDialDeps,123): Promise<DialResult> {124 const invitation = parsePairingInvitation(encodedInvitation);125 if (!invitation) return { ok: false, error: "invalid_code" };126127 let sock: PairingSocket;128 try {129 sock = await deps.openSocket(pairingInvitationUrl(invitation));130 } catch {131 return { ok: false, error: "no_host_found" };132 }133 const result = await finishPairingHandshake(134 sock,135 invitation.token,136 new URL(invitation.relayUrl).hostname,137 );138 if (result.ok) result.dialled.relayUrl = invitation.relayUrl;139 return result;140}141142async function finishPairingHandshake(143 sock: PairingSocket,144 token: string,145 host: string,146 flow?: "adoption",147): Promise<DialResult> {148 let noise: NoiseNKSession | undefined;149 try {150 sock.send(encodeJson({151 type: "PEACH_HELLO",152 token,153 ...(flow ? { flow } : {}),154 }));155156 157 158 let ackBytes: Uint8Array;159 try {160 ackBytes = await sock.receive();161 } catch {162 sock.close();163 return { ok: false, error: "invalid_code" };164 }165 if (sock.closedBeforeFrame() || ackBytes.length === 0) {166 sock.close();167 return { ok: false, error: "invalid_code" };168 }169170 let ephemeralPublicKeyHex: string;171 try {172 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);173 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {174 175 176 sock.close();177 return { ok: false, error: "invalid_code" };178 }179 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;180 } catch {181 sock.close();182 return { ok: false, error: "invalid_code" };183 }184185 const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);186 const session = await noiseNKInitiator(187 remoteStaticPublicKey,188 async (msg) => sock.send(msg),189 () => sock.receive(),190 );191 noise = session;192193 const channel: PairingTransport = {194 async send(plaintext) { sock.send(session.encrypt(plaintext)); },195 async receive() { return session.decrypt(await sock.receive()); },196 };197198 return {199 ok: true,200 dialled: {201 channel,202 sas: derivePairingSAS(session.handshakeHash),203 host,204 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),205 remoteStaticPublicKey,206 authenticateInitiator: (privateKey, publicKey) =>207 performInitiatorAuth(208 session,209 privateKey,210 publicKey,211 remoteStaticPublicKey,212 channel.send,213 channel.receive,214 ),215 socket: {216 send: (bytes) => sock.send(bytes),217 receive: () => sock.receive(),218 close() {219 session.close();220 sock.close();221 },222 closedBeforeFrame: () => sock.closedBeforeFrame(),223 },224 },225 };226 } catch (err) {227 228 229 if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);230 noise?.close();231 sock.close();232 return { ok: false, error: "handshake_failed" };233 }234}235236237export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {238 return openPairingSocketUrl(`ws://${ip}:${port}/`);239}240241242export function openRelayPairingSocket(url: string): Promise<PairingSocket> {243 return openPairingSocketUrl(url);244}245246function openPairingSocketUrl(url: string): Promise<PairingSocket> {247 return new Promise((resolve, reject) => {248 let ws: WebSocket;249 try {250 ws = new WebSocket(url);251 } catch (err) {252 reject(err instanceof Error ? err : new Error("websocket open failed"));253 return;254 }255 ws.binaryType = "arraybuffer";256257 const inbox: Uint8Array[] = [];258 const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];259 let opened = false;260 let closed = false;261 262 263 let sawCloseBeforeFrame = false;264265 ws.onopen = () => {266 opened = true;267 resolve(sock);268 };269 ws.onmessage = (ev: MessageEvent) => {270 const bytes = new Uint8Array(ev.data as ArrayBuffer);271 const r = readers.shift();272 if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }273 else inbox.push(bytes);274 };275 ws.onerror = () => {276 if (!opened) reject(new Error("websocket connection failed"));277 };278 ws.onclose = () => {279 closed = true;280 if (readers.length) {281 sawCloseBeforeFrame = true;282 while (readers.length) readers.shift()!.reject(new Error("socket closed"));283 }284 if (!opened) reject(new Error("socket closed before open"));285 };286287 const sock: PairingSocket = {288 send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },289 receive(): Promise<Uint8Array> {290 const queued = inbox.shift();291 if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }292 if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }293 return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));294 },295 close() { try { ws.close(); } catch { } },296 closedBeforeFrame() { return sawCloseBeforeFrame; },297 };298 });299}300301function encodeJson(obj: unknown): Uint8Array {302 return new TextEncoder().encode(JSON.stringify(obj));303}304305function decodeJson<T>(bytes: Uint8Array): T {306 return JSON.parse(new TextDecoder().decode(bytes)) as T;307}308309function hexToBytes32(hex: string): Uint8Array {310 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");311 const out = new Uint8Array(32);312 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);313 return out;314}