import { noiseNKInitiator, performInitiatorAuth } from './noise-js'; import { bytesToBase64, base64ToBytes } from './sync-wire'; export interface SyncTransport { send(data: Uint8Array): Promise; receive(): Promise; close(): Promise; } // Binary Noise NK frames are base64-encoded and sent as text WS frames so // they work over the phone's text-only PeachWebSocketServer. Each frame is // a plain base64 string (not JSON-wrapped) so the server's else-branch // forwards it via peach:sync-frame without modification. export async function createWebSocketSyncTransport( url: string, remotePublicKeyHex: string, ourPrivateKey: Uint8Array, ourPublicKey: Uint8Array, ): Promise { const remotePublicKey = hexToBytes(remotePublicKeyHex); return new Promise((resolve, reject) => { const ws = new WebSocket(url); const messageQueue: string[] = []; const waiters: ((frame: string) => void)[] = []; const enqueue = (frame: string) => { if (waiters.length > 0) { waiters.shift()!(frame); } else { messageQueue.push(frame); } }; ws.onmessage = (event) => enqueue(event.data as string); ws.onerror = () => reject(new Error('WebSocket connection failed')); ws.onclose = () => { waiters.forEach(w => w('')); waiters.length = 0; }; ws.onopen = async () => { try { const rawSend = (msg: Uint8Array) => { ws.send(bytesToBase64(msg)); return Promise.resolve(); }; const rawReceive = (): Promise => new Promise((res) => { const handle = (frame: string) => { if (frame === '') { res(new Uint8Array(0)); return; } res(base64ToBytes(frame)); }; if (messageQueue.length > 0) handle(messageQueue.shift()!); else waiters.push(handle); }); // Noise NK handshake — desktop is always the initiator. const session = await noiseNKInitiator(remotePublicKey, rawSend, rawReceive); // Post-handshake initiator auth round: prove possession of our static // private key to the responder so the responder can verify we are a // paired device. Plaintext bytes are Noise-encrypted on the wire. const encryptedSend = (data: Uint8Array) => rawSend(session.encrypt(data)); const encryptedReceive = async () => { const frame = await rawReceive(); if (frame.length === 0) return frame; return session.decrypt(frame); }; await performInitiatorAuth( session, ourPrivateKey, ourPublicKey, remotePublicKey, encryptedSend, encryptedReceive, ); resolve({ send: async (plaintext) => { ws.send(bytesToBase64(session.encrypt(plaintext))); }, receive: () => new Promise((res) => { const handle = (frame: string) => { if (frame === '') { res(new Uint8Array(0)); return; } res(session.decrypt(base64ToBytes(frame))); }; if (messageQueue.length > 0) handle(messageQueue.shift()!); else waiters.push(handle); }), close: () => { ws.close(); return Promise.resolve(); }, }); } catch (err) { ws.close(); reject(err); } }; }); } function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); } return bytes; }