12345678910111213141516import { noiseNKInitiator } from "./noise-js";17import { derivePairingSAS, type PairingTransport } from "./pairing-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 openSocket(ip: string, port: number): Promise<PairingSocket>;43}4445export interface DialledPairingChannel {46 47 channel: PairingTransport;48 49 sas: string;50 51 host: string;52 socket: PairingSocket;53}5455export type DialResult =56 | { ok: true; dialled: DialledPairingChannel }57 | { ok: false; error: PairingDialError };5859606162636465export async function dialPairingChannel(66 code: string,67 deps: PairingDialDeps,68): Promise<DialResult> {69 const pairing = parsePairingCode(code);70 if (!pairing) return { ok: false, error: "invalid_code" };71 const { ip: host, port } = pairing;7273 let sock: PairingSocket;74 try {75 sock = await deps.openSocket(host, port);76 } catch {77 return { ok: false, error: "no_host_found" };78 }7980 try {81 sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));8283 84 85 let ackBytes: Uint8Array;86 try {87 ackBytes = await sock.receive();88 } catch {89 sock.close();90 return { ok: false, error: "invalid_code" };91 }92 if (sock.closedBeforeFrame() || ackBytes.length === 0) {93 sock.close();94 return { ok: false, error: "invalid_code" };95 }9697 let ephemeralPublicKeyHex: string;98 try {99 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);100 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {101 102 103 sock.close();104 return { ok: false, error: "invalid_code" };105 }106 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;107 } catch {108 sock.close();109 return { ok: false, error: "invalid_code" };110 }111112 const session = await noiseNKInitiator(113 hexToBytes32(ephemeralPublicKeyHex),114 async (msg) => sock.send(msg),115 () => sock.receive(),116 );117118 const channel: PairingTransport = {119 async send(plaintext) { sock.send(session.encrypt(plaintext)); },120 async receive() { return session.decrypt(await sock.receive()); },121 };122123 return {124 ok: true,125 dialled: {126 channel,127 sas: derivePairingSAS(session.handshakeHash),128 host,129 socket: sock,130 },131 };132 } catch (err) {133 134 135 if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);136 sock.close();137 return { ok: false, error: "handshake_failed" };138 }139}140141142export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {143 return new Promise((resolve, reject) => {144 let ws: WebSocket;145 try {146 ws = new WebSocket(`ws://${ip}:${port}/`);147 } catch (err) {148 reject(err instanceof Error ? err : new Error("websocket open failed"));149 return;150 }151 ws.binaryType = "arraybuffer";152153 const inbox: Uint8Array[] = [];154 const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];155 let opened = false;156 let closed = false;157 158 159 let sawCloseBeforeFrame = false;160161 ws.onopen = () => {162 opened = true;163 resolve(sock);164 };165 ws.onmessage = (ev: MessageEvent) => {166 const bytes = new Uint8Array(ev.data as ArrayBuffer);167 const r = readers.shift();168 if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }169 else inbox.push(bytes);170 };171 ws.onerror = () => {172 if (!opened) reject(new Error("websocket connection failed"));173 };174 ws.onclose = () => {175 closed = true;176 if (readers.length) {177 sawCloseBeforeFrame = true;178 while (readers.length) readers.shift()!.reject(new Error("socket closed"));179 }180 if (!opened) reject(new Error("socket closed before open"));181 };182183 const sock: PairingSocket = {184 send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },185 receive(): Promise<Uint8Array> {186 const queued = inbox.shift();187 if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }188 if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }189 return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));190 },191 close() { try { ws.close(); } catch { } },192 closedBeforeFrame() { return sawCloseBeforeFrame; },193 };194 });195}196197function encodeJson(obj: unknown): Uint8Array {198 return new TextEncoder().encode(JSON.stringify(obj));199}200201function decodeJson<T>(bytes: Uint8Array): T {202 return JSON.parse(new TextDecoder().decode(bytes)) as T;203}204205function hexToBytes32(hex: string): Uint8Array {206 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");207 const out = new Uint8Array(32);208 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);209 return out;210}