12345678910111213141516import { noiseNKInitiator } from "./noise-js";17import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";18import { parsePairingCode } from "./pairing-code";19import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";202122export const PAIRING_PORT = 49142;2324export type PairingDialError =25 | "invalid_code" 26 | "no_host_found" 27 | "handshake_failed"; 282930313233export interface PairingSocket {34 send(bytes: Uint8Array): void;35 36 receive(): Promise<Uint8Array>;37 close(): void;38 39 closedBeforeFrame(): boolean;40}4142export interface PairingDialDeps {43 openSocket(ip: string, port: number): Promise<PairingSocket>;44}4546export interface DialledPairingChannel {47 48 channel: PairingTransport;49 50 sas: string;51 52 host: string;53 54 rendezvousSecretHex: string;55 socket: PairingSocket;56}5758export type DialResult =59 | { ok: true; dialled: DialledPairingChannel }60 | { ok: false; error: PairingDialError };6162636465666768export async function dialPairingChannel(69 code: string,70 deps: PairingDialDeps,71): Promise<DialResult> {72 const pairing = parsePairingCode(code);73 if (!pairing) return { ok: false, error: "invalid_code" };74 const { ip: host, port } = pairing;7576 let sock: PairingSocket;77 try {78 sock = await deps.openSocket(host, port);79 } catch {80 return { ok: false, error: "no_host_found" };81 }8283 try {84 sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));8586 87 88 let ackBytes: Uint8Array;89 try {90 ackBytes = await sock.receive();91 } catch {92 sock.close();93 return { ok: false, error: "invalid_code" };94 }95 if (sock.closedBeforeFrame() || ackBytes.length === 0) {96 sock.close();97 return { ok: false, error: "invalid_code" };98 }99100 let ephemeralPublicKeyHex: string;101 try {102 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);103 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {104 105 106 sock.close();107 return { ok: false, error: "invalid_code" };108 }109 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;110 } catch {111 sock.close();112 return { ok: false, error: "invalid_code" };113 }114115 const session = await noiseNKInitiator(116 hexToBytes32(ephemeralPublicKeyHex),117 async (msg) => sock.send(msg),118 () => sock.receive(),119 );120121 const channel: PairingTransport = {122 async send(plaintext) { sock.send(session.encrypt(plaintext)); },123 async receive() { return session.decrypt(await sock.receive()); },124 };125126 return {127 ok: true,128 dialled: {129 channel,130 sas: derivePairingSAS(session.handshakeHash),131 host,132 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),133 socket: sock,134 },135 };136 } catch (err) {137 138 139 if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);140 sock.close();141 return { ok: false, error: "handshake_failed" };142 }143}144145146export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {147 return new Promise((resolve, reject) => {148 let ws: WebSocket;149 try {150 ws = new WebSocket(`ws://${ip}:${port}/`);151 } catch (err) {152 reject(err instanceof Error ? err : new Error("websocket open failed"));153 return;154 }155 ws.binaryType = "arraybuffer";156157 const inbox: Uint8Array[] = [];158 const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];159 let opened = false;160 let closed = false;161 162 163 let sawCloseBeforeFrame = false;164165 ws.onopen = () => {166 opened = true;167 resolve(sock);168 };169 ws.onmessage = (ev: MessageEvent) => {170 const bytes = new Uint8Array(ev.data as ArrayBuffer);171 const r = readers.shift();172 if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }173 else inbox.push(bytes);174 };175 ws.onerror = () => {176 if (!opened) reject(new Error("websocket connection failed"));177 };178 ws.onclose = () => {179 closed = true;180 if (readers.length) {181 sawCloseBeforeFrame = true;182 while (readers.length) readers.shift()!.reject(new Error("socket closed"));183 }184 if (!opened) reject(new Error("socket closed before open"));185 };186187 const sock: PairingSocket = {188 send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },189 receive(): Promise<Uint8Array> {190 const queued = inbox.shift();191 if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }192 if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }193 return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));194 },195 close() { try { ws.close(); } catch { } },196 closedBeforeFrame() { return sawCloseBeforeFrame; },197 };198 });199}200201function encodeJson(obj: unknown): Uint8Array {202 return new TextEncoder().encode(JSON.stringify(obj));203}204205function decodeJson<T>(bytes: Uint8Array): T {206 return JSON.parse(new TextDecoder().decode(bytes)) as T;207}208209function hexToBytes32(hex: string): Uint8Array {210 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");211 const out = new Uint8Array(32);212 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);213 return out;214}