12345678910111213141516import {17 noiseNKInitiator,18 performInitiatorAuth,19 type NoiseNKSession,20} from "./noise-js";21import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";22import {23 pairingInvitationUrl,24 parsePairingInvitation,25} from "./pairing-invitation";26import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2728293031323334export const LEGACY_LAN_PORT = 49142;3536export type PairingDialError =37 | "invalid_code" 38 | "no_host_found" 39 | "handshake_failed"; 404142434445export interface PairingSocket {46 send(bytes: Uint8Array): void;47 48 receive(): Promise<Uint8Array>;49 close(): void;50 51 closedBeforeFrame(): boolean;52}5354export interface PairingDialDeps {55 openSocket(ip: string, port: number): Promise<PairingSocket>;56}5758export interface PairingDialOptions {59 flow?: "adoption";60}6162export interface RelayPairingDialDeps {63 openSocket(url: string): Promise<PairingSocket>;64}6566export interface DialledPairingChannel {67 68 channel: PairingTransport;69 70 sas: string;71 72 host: string;73 74 relayUrl?: string;75 76 rendezvousSecretHex: string;77 78 remoteStaticPublicKey: Uint8Array;79 8081828384 authenticateInitiator(85 privateKey: Uint8Array,86 publicKey: Uint8Array,87 ): Promise<void>;88 socket: PairingSocket;89}9091export type DialResult =92 | { ok: true; dialled: DialledPairingChannel }93 | { ok: false; error: PairingDialError };9495export async function dialRelayPairingChannel(96 encodedInvitation: string,97 deps: RelayPairingDialDeps,98): Promise<DialResult> {99 const invitation = parsePairingInvitation(encodedInvitation);100 if (!invitation) return { ok: false, error: "invalid_code" };101102 let sock: PairingSocket;103 try {104 sock = await deps.openSocket(pairingInvitationUrl(invitation));105 } catch {106 return { ok: false, error: "no_host_found" };107 }108 const result = await finishPairingHandshake(109 sock,110 invitation.token,111 new URL(invitation.relayUrl).hostname,112 );113 if (result.ok) result.dialled.relayUrl = invitation.relayUrl;114 return result;115}116117async function finishPairingHandshake(118 sock: PairingSocket,119 token: string,120 host: string,121 flow?: "adoption",122): Promise<DialResult> {123 let noise: NoiseNKSession | undefined;124 try {125 sock.send(encodeJson({126 type: "PEACH_HELLO",127 token,128 ...(flow ? { flow } : {}),129 }));130131 132 133 let ackBytes: Uint8Array;134 try {135 ackBytes = await sock.receive();136 } catch {137 sock.close();138 return { ok: false, error: "invalid_code" };139 }140 if (sock.closedBeforeFrame() || ackBytes.length === 0) {141 sock.close();142 return { ok: false, error: "invalid_code" };143 }144145 let ephemeralPublicKeyHex: string;146 try {147 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);148 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {149 150 151 sock.close();152 return { ok: false, error: "invalid_code" };153 }154 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;155 } catch {156 sock.close();157 return { ok: false, error: "invalid_code" };158 }159160 const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);161 const session = await noiseNKInitiator(162 remoteStaticPublicKey,163 async (msg) => sock.send(msg),164 () => sock.receive(),165 );166 noise = session;167168 const channel: PairingTransport = {169 async send(plaintext) { sock.send(session.encrypt(plaintext)); },170 async receive() { return session.decrypt(await sock.receive()); },171 };172173 return {174 ok: true,175 dialled: {176 channel,177 sas: derivePairingSAS(session.handshakeHash),178 host,179 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),180 remoteStaticPublicKey,181 authenticateInitiator: (privateKey, publicKey) =>182 performInitiatorAuth(183 session,184 privateKey,185 publicKey,186 remoteStaticPublicKey,187 channel.send,188 channel.receive,189 ),190 socket: {191 send: (bytes) => sock.send(bytes),192 receive: () => sock.receive(),193 close() {194 session.close();195 sock.close();196 },197 closedBeforeFrame: () => sock.closedBeforeFrame(),198 },199 },200 };201 } catch (err) {202 203 204 if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);205 noise?.close();206 sock.close();207 return { ok: false, error: "handshake_failed" };208 }209}210211212export function openRelayPairingSocket(url: string): Promise<PairingSocket> {213 return openPairingSocketUrl(url);214}215216function openPairingSocketUrl(url: string): Promise<PairingSocket> {217 return new Promise((resolve, reject) => {218 let ws: WebSocket;219 try {220 ws = new WebSocket(url);221 } catch (err) {222 reject(err instanceof Error ? err : new Error("websocket open failed"));223 return;224 }225 ws.binaryType = "arraybuffer";226227 const inbox: Uint8Array[] = [];228 const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];229 let opened = false;230 let closed = false;231 232 233 let sawCloseBeforeFrame = false;234235 ws.onopen = () => {236 opened = true;237 resolve(sock);238 };239 ws.onmessage = (ev: MessageEvent) => {240 const bytes = new Uint8Array(ev.data as ArrayBuffer);241 const r = readers.shift();242 if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }243 else inbox.push(bytes);244 };245 ws.onerror = () => {246 if (!opened) reject(new Error("websocket connection failed"));247 };248 ws.onclose = () => {249 closed = true;250 if (readers.length) {251 sawCloseBeforeFrame = true;252 while (readers.length) readers.shift()!.reject(new Error("socket closed"));253 }254 if (!opened) reject(new Error("socket closed before open"));255 };256257 const sock: PairingSocket = {258 send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },259 receive(): Promise<Uint8Array> {260 const queued = inbox.shift();261 if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }262 if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }263 return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));264 },265 close() { try { ws.close(); } catch { } },266 closedBeforeFrame() { return sawCloseBeforeFrame; },267 };268 });269}270271function encodeJson(obj: unknown): Uint8Array {272 return new TextEncoder().encode(JSON.stringify(obj));273}274275function decodeJson<T>(bytes: Uint8Array): T {276 return JSON.parse(new TextDecoder().decode(bytes)) as T;277}278279function hexToBytes32(hex: string): Uint8Array {280 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");281 const out = new Uint8Array(32);282 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);283 return out;284}