12345678910111213141516import { noiseNKInitiator } from "./noise-js";17import { derivePairingSAS, type PairingTransport } from "./chirp-handshake";18import { parsePairingCode } from "./pairing-code";192021export const PAIRING_PORT = 49142;2223export type PairingDialError =24 | "invalid_code" 25 | "no_host_found" 26 | "handshake_failed"; 272829303132export interface PairingSocket {33 send(bytes: Uint8Array): void;34 35 receive(): Promise<Uint8Array>;36 close(): void;37 38 closedBeforeFrame(): boolean;39}4041export interface PairingDialDeps {42 4344454647 findHost?(port: number): Promise<string | null>;48 openSocket(ip: string, port: number): Promise<PairingSocket>;49}5051export interface DialledPairingChannel {52 53 channel: PairingTransport;54 55 sas: string;56 57 host: string;58 socket: PairingSocket;59}6061export type DialResult =62 | { ok: true; dialled: DialledPairingChannel }63 | { ok: false; error: PairingDialError };6465666768697071export async function dialPairingChannel(72 code: string,73 deps: PairingDialDeps,74): Promise<DialResult> {75 const pairing = parsePairingCode(code);7677 78 79 const port = pairing?.port ?? PAIRING_PORT;80 const host = pairing?.ip ?? (await deps.findHost?.(PAIRING_PORT)) ?? null;81 if (!host) return { ok: false, error: "no_host_found" };8283 let sock: PairingSocket;84 try {85 sock = await deps.openSocket(host, port);86 } catch {87 return { ok: false, error: "no_host_found" };88 }8990 try {91 92 93 94 sock.send(encodeJson(95 pairing96 ? { type: "PEACH_HELLO", token: pairing.tokenHex }97 : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) },98 ));99100 101 102 let ackBytes: Uint8Array;103 try {104 ackBytes = await sock.receive();105 } catch {106 sock.close();107 return { ok: false, error: "invalid_code" };108 }109 if (sock.closedBeforeFrame() || ackBytes.length === 0) {110 sock.close();111 return { ok: false, error: "invalid_code" };112 }113114 let ephemeralPublicKeyHex: string;115 try {116 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);117 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {118 119 120 sock.close();121 return { ok: false, error: "invalid_code" };122 }123 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;124 } catch {125 sock.close();126 return { ok: false, error: "invalid_code" };127 }128129 const session = await noiseNKInitiator(130 hexToBytes32(ephemeralPublicKeyHex),131 async (msg) => sock.send(msg),132 () => sock.receive(),133 );134135 const channel: PairingTransport = {136 async send(plaintext) { sock.send(session.encrypt(plaintext)); },137 async receive() { return session.decrypt(await sock.receive()); },138 };139140 return {141 ok: true,142 dialled: {143 channel,144 sas: derivePairingSAS(session.handshakeHash),145 host,146 socket: sock,147 },148 };149 } catch (err) {150 151 152 if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);153 sock.close();154 return { ok: false, error: "handshake_failed" };155 }156}157158159export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {160 return new Promise((resolve, reject) => {161 let ws: WebSocket;162 try {163 ws = new WebSocket(`ws://${ip}:${port}/`);164 } catch (err) {165 reject(err instanceof Error ? err : new Error("websocket open failed"));166 return;167 }168 ws.binaryType = "arraybuffer";169170 const inbox: Uint8Array[] = [];171 const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];172 let opened = false;173 let closed = false;174 175 176 let sawCloseBeforeFrame = false;177178 ws.onopen = () => {179 opened = true;180 resolve(sock);181 };182 ws.onmessage = (ev: MessageEvent) => {183 const bytes = new Uint8Array(ev.data as ArrayBuffer);184 const r = readers.shift();185 if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }186 else inbox.push(bytes);187 };188 ws.onerror = () => {189 if (!opened) reject(new Error("websocket connection failed"));190 };191 ws.onclose = () => {192 closed = true;193 if (readers.length) {194 sawCloseBeforeFrame = true;195 while (readers.length) readers.shift()!.reject(new Error("socket closed"));196 }197 if (!opened) reject(new Error("socket closed before open"));198 };199200 const sock: PairingSocket = {201 send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },202 receive(): Promise<Uint8Array> {203 const queued = inbox.shift();204 if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }205 if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }206 return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));207 },208 close() { try { ws.close(); } catch { } },209 closedBeforeFrame() { return sawCloseBeforeFrame; },210 };211 });212}213214function encodeJson(obj: unknown): Uint8Array {215 return new TextEncoder().encode(JSON.stringify(obj));216}217218function decodeJson<T>(bytes: Uint8Array): T {219 return JSON.parse(new TextDecoder().decode(bytes)) as T;220}221222function hexToBytes32(hex: string): Uint8Array {223 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");224 const out = new Uint8Array(32);225 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);226 return out;227}