#!/usr/bin/env node import { webcrypto } from "node:crypto"; const wsUrl = process.argv[2] ?? process.env.WS_URL ?? "ws://localhost:8080/ws"; const protocolVersion = 1; const encoder = new TextEncoder(); const decoder = new TextDecoder(); function bytesToBase64(bytes) { return Buffer.from(bytes).toString("base64"); } function base64ToBytes(value) { return Buffer.from(value, "base64"); } async function createSession() { const key = await webcrypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"], ); const rawKey = await webcrypto.subtle.exportKey("raw", key); return { key, keyId: webcrypto.randomUUID(), exportedKey: bytesToBase64(new Uint8Array(rawKey)), }; } async function encryptMessage(session, message) { const nonce = webcrypto.getRandomValues(new Uint8Array(12)); const plaintext = encoder.encode(JSON.stringify(message)); const ciphertext = await webcrypto.subtle.encrypt( { name: "AES-GCM", iv: nonce }, session.key, plaintext, ); return { enc: `aes-256-gcm-v${protocolVersion}`, key_id: session.keyId, nonce: bytesToBase64(nonce), ciphertext: bytesToBase64(new Uint8Array(ciphertext)), }; } async function decryptMessage(session, envelope) { const plaintext = await webcrypto.subtle.decrypt( { name: "AES-GCM", iv: base64ToBytes(envelope.nonce) }, session.key, base64ToBytes(envelope.ciphertext), ); return JSON.parse(decoder.decode(plaintext)); } function waitForOpen(socket) { return new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); }); } function waitForMessage(socket, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { socket.removeEventListener("message", onMessage); reject(new Error(`timeout after ${timeoutMs}ms`)); }, timeoutMs); function onMessage(event) { clearTimeout(timeout); resolve(JSON.parse(event.data)); } socket.addEventListener("message", onMessage, { once: true }); }); } async function main() { const session = await createSession(); const socket = new WebSocket(wsUrl); console.log(`connecting ${wsUrl}`); await waitForOpen(socket); console.log("socket open"); socket.send(JSON.stringify({ type: "hello", payload: { protocol_version: protocolVersion, key_id: session.keyId, session_key: session.exportedKey, }, })); const ack = await waitForMessage(socket); if (ack.type !== "hello_ack") { throw new Error(`expected hello_ack, got ${JSON.stringify(ack)}`); } console.log(`hello_ack protocol=${ack.payload.protocol_version} key=${ack.payload.key_id}`); const firstEncrypted = await waitForMessage(socket); if (firstEncrypted.type !== "encrypted") { throw new Error(`expected encrypted snapshot, got ${JSON.stringify(firstEncrypted)}`); } const snapshot = await decryptMessage(session, firstEncrypted.payload); console.log(`decrypted first server message: ${snapshot.type}`); const subscribe = await encryptMessage(session, { type: "subscribe", payload: { topic: "records" }, }); socket.send(JSON.stringify({ type: "encrypted", payload: subscribe })); console.log("sent encrypted subscribe"); const ping = await encryptMessage(session, { type: "ping" }); socket.send(JSON.stringify({ type: "encrypted", payload: ping })); const pongEnvelope = await waitForMessage(socket); if (pongEnvelope.type !== "encrypted") { throw new Error(`expected encrypted pong, got ${JSON.stringify(pongEnvelope)}`); } const pong = await decryptMessage(session, pongEnvelope.payload); console.log(`decrypted ping response: ${pong.type}`); socket.close(); console.log("communication smoke test ok"); } main().catch((error) => { console.error(error); process.exit(1); });