websocket-sync-transport.ts

3.6 KB · raw

1import { noiseNKInitiator, performInitiatorAuth } from './noise-js';2import { bytesToBase64, base64ToBytes } from './sync-wire';34export interface SyncTransport {5  send(data: Uint8Array): Promise<void>;6  receive(): Promise<Uint8Array>;7  close(): Promise<void>;8}910// Binary Noise NK frames are base64-encoded and sent as text WS frames so11// they work over the phone's text-only PeachWebSocketServer. Each frame is12// a plain base64 string (not JSON-wrapped) so the server's else-branch13// forwards it via peach:sync-frame without modification.14export async function createWebSocketSyncTransport(15  url: string,16  remotePublicKeyHex: string,17  ourPrivateKey: Uint8Array,18  ourPublicKey: Uint8Array,19): Promise<SyncTransport> {20  const remotePublicKey = hexToBytes(remotePublicKeyHex);2122  return new Promise((resolve, reject) => {23    const ws = new WebSocket(url);2425    const messageQueue: string[] = [];26    const waiters: ((frame: string) => void)[] = [];2728    const enqueue = (frame: string) => {29      if (waiters.length > 0) {30        waiters.shift()!(frame);31      } else {32        messageQueue.push(frame);33      }34    };3536    ws.onmessage = (event) => enqueue(event.data as string);37    ws.onerror = () => reject(new Error('WebSocket connection failed'));38    ws.onclose = () => { waiters.forEach(w => w('')); waiters.length = 0; };3940    ws.onopen = async () => {41      try {42        const rawSend = (msg: Uint8Array) => {43          ws.send(bytesToBase64(msg));44          return Promise.resolve();45        };46        const rawReceive = (): Promise<Uint8Array> => new Promise((res) => {47          const handle = (frame: string) => {48            if (frame === '') { res(new Uint8Array(0)); return; }49            res(base64ToBytes(frame));50          };51          if (messageQueue.length > 0) handle(messageQueue.shift()!);52          else waiters.push(handle);53        });5455        // Noise NK handshake — desktop is always the initiator.56        const session = await noiseNKInitiator(remotePublicKey, rawSend, rawReceive);5758        // Post-handshake initiator auth round: prove possession of our static59        // private key to the responder so the responder can verify we are a60        // paired device. Plaintext bytes are Noise-encrypted on the wire.61        const encryptedSend = (data: Uint8Array) =>62          rawSend(session.encrypt(data));63        const encryptedReceive = async () => {64          const frame = await rawReceive();65          if (frame.length === 0) return frame;66          return session.decrypt(frame);67        };68        await performInitiatorAuth(69          session,70          ourPrivateKey,71          ourPublicKey,72          remotePublicKey,73          encryptedSend,74          encryptedReceive,75        );7677        resolve({78          send: async (plaintext) => {79            ws.send(bytesToBase64(session.encrypt(plaintext)));80          },81          receive: () => new Promise((res) => {82            const handle = (frame: string) => {83              if (frame === '') { res(new Uint8Array(0)); return; }84              res(session.decrypt(base64ToBytes(frame)));85            };86            if (messageQueue.length > 0) handle(messageQueue.shift()!);87            else waiters.push(handle);88          }),89          close: () => {90            ws.close();91            return Promise.resolve();92          },93        });94      } catch (err) {95        ws.close();96        reject(err);97      }98    };99  });100}101102function hexToBytes(hex: string): Uint8Array {103  const bytes = new Uint8Array(hex.length / 2);104  for (let i = 0; i < hex.length; i += 2) {105    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);106  }107  return bytes;108}