12345678910111213141516import { noiseNKInitiator, performInitiatorAuth } 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 56 remoteStaticPublicKey: Uint8Array;57 5859606162 authenticateInitiator(63 privateKey: Uint8Array,64 publicKey: Uint8Array,65 ): Promise<void>;66 socket: PairingSocket;67}6869export type DialResult =70 | { ok: true; dialled: DialledPairingChannel }71 | { ok: false; error: PairingDialError };7273747576777879export async function dialPairingChannel(80 code: string,81 deps: PairingDialDeps,82): Promise<DialResult> {83 const pairing = parsePairingCode(code);84 if (!pairing) return { ok: false, error: "invalid_code" };85 const { ip: host, port } = pairing;8687 let sock: PairingSocket;88 try {89 sock = await deps.openSocket(host, port);90 } catch {91 return { ok: false, error: "no_host_found" };92 }9394 try {95 sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));9697 98 99 let ackBytes: Uint8Array;100 try {101 ackBytes = await sock.receive();102 } catch {103 sock.close();104 return { ok: false, error: "invalid_code" };105 }106 if (sock.closedBeforeFrame() || ackBytes.length === 0) {107 sock.close();108 return { ok: false, error: "invalid_code" };109 }110111 let ephemeralPublicKeyHex: string;112 try {113 const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);114 if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {115 116 117 sock.close();118 return { ok: false, error: "invalid_code" };119 }120 ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;121 } catch {122 sock.close();123 return { ok: false, error: "invalid_code" };124 }125126 const remoteStaticPublicKey = hexToBytes32(ephemeralPublicKeyHex);127 const session = await noiseNKInitiator(128 remoteStaticPublicKey,129 async (msg) => sock.send(msg),130 () => sock.receive(),131 );132133 const channel: PairingTransport = {134 async send(plaintext) { sock.send(session.encrypt(plaintext)); },135 async receive() { return session.decrypt(await sock.receive()); },136 };137138 return {139 ok: true,140 dialled: {141 channel,142 sas: derivePairingSAS(session.handshakeHash),143 host,144 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),145 remoteStaticPublicKey,146 authenticateInitiator: (privateKey, publicKey) =>147 performInitiatorAuth(148 session,149 privateKey,150 publicKey,151 remoteStaticPublicKey,152 channel.send,153 channel.receive,154 ),155 socket: sock,156 },157 };158 } catch (err) {159 160 161 if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);162 sock.close();163 return { ok: false, error: "handshake_failed" };164 }165}166167168export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {169 return new Promise((resolve, reject) => {170 let ws: WebSocket;171 try {172 ws = new WebSocket(`ws://${ip}:${port}/`);173 } catch (err) {174 reject(err instanceof Error ? err : new Error("websocket open failed"));175 return;176 }177 ws.binaryType = "arraybuffer";178179 const inbox: Uint8Array[] = [];180 const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];181 let opened = false;182 let closed = false;183 184 185 let sawCloseBeforeFrame = false;186187 ws.onopen = () => {188 opened = true;189 resolve(sock);190 };191 ws.onmessage = (ev: MessageEvent) => {192 const bytes = new Uint8Array(ev.data as ArrayBuffer);193 const r = readers.shift();194 if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }195 else inbox.push(bytes);196 };197 ws.onerror = () => {198 if (!opened) reject(new Error("websocket connection failed"));199 };200 ws.onclose = () => {201 closed = true;202 if (readers.length) {203 sawCloseBeforeFrame = true;204 while (readers.length) readers.shift()!.reject(new Error("socket closed"));205 }206 if (!opened) reject(new Error("socket closed before open"));207 };208209 const sock: PairingSocket = {210 send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },211 receive(): Promise<Uint8Array> {212 const queued = inbox.shift();213 if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }214 if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }215 return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));216 },217 close() { try { ws.close(); } catch { } },218 closedBeforeFrame() { return sawCloseBeforeFrame; },219 };220 });221}222223function encodeJson(obj: unknown): Uint8Array {224 return new TextEncoder().encode(JSON.stringify(obj));225}226227function decodeJson<T>(bytes: Uint8Array): T {228 return JSON.parse(new TextDecoder().decode(bytes)) as T;229}230231function hexToBytes32(hex: string): Uint8Array {232 if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");233 const out = new Uint8Array(32);234 for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);235 return out;236}