feat: persist draft messages and UI refinements
- Add dedicated draft-store with AsyncStorage persistence for drafts that survive app refresh/crash - Reduce markdown bold weight from bold to medium (500) - Various theme and component style updates - Server bootstrap and config improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Cloudflare Durable Objects adapter for the relay.
|
||||
*
|
||||
* This module provides a Durable Object class that can be deployed to
|
||||
* Cloudflare Workers. It uses WebSocket hibernation for cost efficiency.
|
||||
*
|
||||
* Each session gets its own Durable Object instance, identified by session ID.
|
||||
*
|
||||
* Wrangler config:
|
||||
* ```jsonc
|
||||
* {
|
||||
* "durable_objects": {
|
||||
* "bindings": [{ "name": "RELAY", "class_name": "RelayDurableObject" }]
|
||||
* },
|
||||
* "migrations": [{ "tag": "v1", "new_classes": ["RelayDurableObject"] }]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
||||
|
||||
type WebSocketPair = {
|
||||
0: WebSocket;
|
||||
1: WebSocket;
|
||||
};
|
||||
|
||||
interface DurableObjectState {
|
||||
acceptWebSocket(ws: WebSocket, tags?: string[]): void;
|
||||
getWebSockets(tag?: string): WebSocket[];
|
||||
}
|
||||
|
||||
interface WebSocketWithAttachment extends WebSocket {
|
||||
serializeAttachment(value: unknown): void;
|
||||
deserializeAttachment(): unknown;
|
||||
}
|
||||
|
||||
interface Env {
|
||||
RELAY: DurableObjectNamespace;
|
||||
}
|
||||
|
||||
interface DurableObjectNamespace {
|
||||
idFromName(name: string): DurableObjectId;
|
||||
get(id: DurableObjectId): DurableObjectStub;
|
||||
}
|
||||
|
||||
interface DurableObjectId {
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
interface DurableObjectStub {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Object that handles WebSocket relay for a single session.
|
||||
*
|
||||
* Two WebSockets connect to this DO:
|
||||
* - role=server: The Paseo daemon
|
||||
* - role=client: The mobile/web app
|
||||
*
|
||||
* Messages are forwarded between them. The DO hibernates when idle.
|
||||
*/
|
||||
interface CFResponseInit extends ResponseInit {
|
||||
webSocket?: WebSocket;
|
||||
}
|
||||
|
||||
export class RelayDurableObject {
|
||||
private state: DurableObjectState;
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.state = state;
|
||||
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const role = url.searchParams.get("role") as ConnectionRole | null;
|
||||
const sessionId = url.searchParams.get("session");
|
||||
|
||||
if (!role || (role !== "server" && role !== "client")) {
|
||||
return new Response("Missing or invalid role parameter", { status: 400 });
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response("Missing session parameter", { status: 400 });
|
||||
}
|
||||
|
||||
const upgradeHeader = request.headers.get("Upgrade");
|
||||
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
|
||||
return new Response("Expected WebSocket upgrade", { status: 426 });
|
||||
}
|
||||
|
||||
// Close any existing connection with the same role
|
||||
const existingConnections = this.state.getWebSockets(role);
|
||||
for (const ws of existingConnections) {
|
||||
ws.close(1008, "Replaced by new connection");
|
||||
}
|
||||
|
||||
// Create WebSocket pair
|
||||
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
|
||||
const [client, server] = [pair[0], pair[1]];
|
||||
|
||||
// Accept with hibernation support, tagged by role
|
||||
this.state.acceptWebSocket(server, [role]);
|
||||
|
||||
// Store attachment for hibernation recovery
|
||||
const attachment: RelaySessionAttachment = {
|
||||
sessionId,
|
||||
role,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
(server as WebSocketWithAttachment).serializeAttachment(attachment);
|
||||
|
||||
console.log(`[Relay DO] ${role} connected to session ${sessionId}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
} as CFResponseInit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a WebSocket message is received (wakes from hibernation).
|
||||
*/
|
||||
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
if (!attachment) {
|
||||
console.error("[Relay DO] Message from WebSocket without attachment");
|
||||
return;
|
||||
}
|
||||
|
||||
const { role } = attachment;
|
||||
const targetRole = role === "server" ? "client" : "server";
|
||||
const targets = this.state.getWebSockets(targetRole);
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
target.send(message);
|
||||
} catch (error) {
|
||||
console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a WebSocket closes (wakes from hibernation).
|
||||
*/
|
||||
webSocketClose(
|
||||
ws: WebSocket,
|
||||
code: number,
|
||||
reason: string,
|
||||
_wasClean: boolean
|
||||
): void {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
console.log(
|
||||
`[Relay DO] ${attachment.role} disconnected from session ${attachment.sessionId} (${code}: ${reason})`
|
||||
);
|
||||
|
||||
// Note: The other connection remains open.
|
||||
// The client/server will detect the peer is gone when they try to send.
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on WebSocket error.
|
||||
*/
|
||||
webSocketError(ws: WebSocket, error: unknown): void {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
console.error(
|
||||
`[Relay DO] WebSocket error for ${attachment?.role ?? "unknown"}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker entry point that routes requests to the appropriate Durable Object.
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Health check
|
||||
if (url.pathname === "/health") {
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Relay endpoint
|
||||
if (url.pathname === "/ws") {
|
||||
const sessionId = url.searchParams.get("session");
|
||||
if (!sessionId) {
|
||||
return new Response("Missing session parameter", { status: 400 });
|
||||
}
|
||||
|
||||
// Route to Durable Object instance for this session
|
||||
const id = env.RELAY.idFromName(sessionId);
|
||||
const stub = env.RELAY.get(id);
|
||||
return stub.fetch(request);
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
describe("crypto", () => {
|
||||
describe("generateKeyPair", () => {
|
||||
it("generates a valid ECDH keypair", async () => {
|
||||
const keypair = await generateKeyPair();
|
||||
expect(keypair.privateKey).toBeDefined();
|
||||
expect(keypair.publicKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportPublicKey / importPublicKey", () => {
|
||||
it("roundtrips public key through base64", async () => {
|
||||
const keypair = await generateKeyPair();
|
||||
const exported = await exportPublicKey(keypair.publicKey);
|
||||
|
||||
expect(typeof exported).toBe("string");
|
||||
expect(exported.length).toBeGreaterThan(0);
|
||||
|
||||
const imported = await importPublicKey(exported);
|
||||
expect(imported).toBeDefined();
|
||||
|
||||
// Re-export should match
|
||||
const reExported = await exportPublicKey(imported);
|
||||
expect(reExported).toBe(exported);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSharedKey", () => {
|
||||
it("derives the same key on both sides", async () => {
|
||||
// Simulate daemon and client
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
|
||||
// Export public keys (what would go over the wire)
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
// Import peer's public key
|
||||
const daemonSeesClientPubKey = await importPublicKey(clientPubKeyB64);
|
||||
const clientSeesDaemonPubKey = await importPublicKey(daemonPubKeyB64);
|
||||
|
||||
// Derive shared keys
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
daemonSeesClientPubKey
|
||||
);
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
clientSeesDaemonPubKey
|
||||
);
|
||||
|
||||
// Both should derive the same key - test by encrypting with one, decrypting with other
|
||||
const testMessage = "Hello, encrypted world!";
|
||||
const encrypted = await encrypt(daemonSharedKey, testMessage);
|
||||
const decrypted = await decrypt(clientSharedKey, encrypted);
|
||||
|
||||
expect(decrypted).toBe(testMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt / decrypt", () => {
|
||||
it("roundtrips a string message", async () => {
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const sharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientKeyPair.publicKey
|
||||
);
|
||||
|
||||
const plaintext = "Test message with unicode: 你好世界 🎉";
|
||||
const ciphertext = await encrypt(sharedKey, plaintext);
|
||||
|
||||
expect(ciphertext).toBeInstanceOf(ArrayBuffer);
|
||||
expect(ciphertext.byteLength).toBeGreaterThan(plaintext.length);
|
||||
|
||||
const decrypted = await decrypt(sharedKey, ciphertext);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("roundtrips binary data", async () => {
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const sharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientKeyPair.publicKey
|
||||
);
|
||||
|
||||
const binary = new Uint8Array([0, 1, 2, 255, 254, 253]);
|
||||
const ciphertext = await encrypt(sharedKey, binary.buffer);
|
||||
|
||||
const decrypted = await decrypt(sharedKey, ciphertext);
|
||||
expect(new Uint8Array(decrypted as ArrayBuffer)).toEqual(binary);
|
||||
});
|
||||
|
||||
it("fails to decrypt with wrong key", async () => {
|
||||
const keypair1 = await generateKeyPair();
|
||||
const keypair2 = await generateKeyPair();
|
||||
const keypair3 = await generateKeyPair();
|
||||
|
||||
const correctKey = await deriveSharedKey(
|
||||
keypair1.privateKey,
|
||||
keypair2.publicKey
|
||||
);
|
||||
const wrongKey = await deriveSharedKey(
|
||||
keypair1.privateKey,
|
||||
keypair3.publicKey
|
||||
);
|
||||
|
||||
const ciphertext = await encrypt(correctKey, "secret");
|
||||
|
||||
await expect(decrypt(wrongKey, ciphertext)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("produces different ciphertext for same plaintext (random IV)", async () => {
|
||||
const keypair1 = await generateKeyPair();
|
||||
const keypair2 = await generateKeyPair();
|
||||
const sharedKey = await deriveSharedKey(
|
||||
keypair1.privateKey,
|
||||
keypair2.publicKey
|
||||
);
|
||||
|
||||
const plaintext = "Same message";
|
||||
const ciphertext1 = await encrypt(sharedKey, plaintext);
|
||||
const ciphertext2 = await encrypt(sharedKey, plaintext);
|
||||
|
||||
// Should be different due to random IV
|
||||
const arr1 = new Uint8Array(ciphertext1);
|
||||
const arr2 = new Uint8Array(ciphertext2);
|
||||
expect(arr1).not.toEqual(arr2);
|
||||
|
||||
// But both should decrypt to same plaintext
|
||||
expect(await decrypt(sharedKey, ciphertext1)).toBe(plaintext);
|
||||
expect(await decrypt(sharedKey, ciphertext2)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full handshake simulation", () => {
|
||||
it("simulates complete daemon<->client key exchange", async () => {
|
||||
// === DAEMON SIDE (generates session) ===
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
// QR code would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
|
||||
|
||||
// === CLIENT SIDE (scans QR) ===
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
// Client imports daemon's public key from QR
|
||||
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
|
||||
|
||||
// Client derives shared key
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
daemonPubKeyOnClient
|
||||
);
|
||||
|
||||
// Client sends hello: { type: "hello", key: clientPubKeyB64 }
|
||||
|
||||
// === DAEMON SIDE (receives hello) ===
|
||||
// Daemon imports client's public key from hello message
|
||||
const clientPubKeyOnDaemon = await importPublicKey(clientPubKeyB64);
|
||||
|
||||
// Daemon derives shared key
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKeyOnDaemon
|
||||
);
|
||||
|
||||
// === VERIFY BOTH HAVE SAME KEY ===
|
||||
const testFromDaemon = "Message from daemon";
|
||||
const testFromClient = "Message from client";
|
||||
|
||||
// Daemon encrypts, client decrypts
|
||||
const encryptedFromDaemon = await encrypt(daemonSharedKey, testFromDaemon);
|
||||
expect(await decrypt(clientSharedKey, encryptedFromDaemon)).toBe(
|
||||
testFromDaemon
|
||||
);
|
||||
|
||||
// Client encrypts, daemon decrypts
|
||||
const encryptedFromClient = await encrypt(clientSharedKey, testFromClient);
|
||||
expect(await decrypt(daemonSharedKey, encryptedFromClient)).toBe(
|
||||
testFromClient
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* E2EE crypto primitives using WebCrypto API.
|
||||
*
|
||||
* - ECDH P-256 for key exchange
|
||||
* - HKDF for key derivation
|
||||
* - AES-256-GCM for authenticated encryption
|
||||
*/
|
||||
|
||||
const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" };
|
||||
const AES_ALGORITHM = { name: "AES-GCM", length: 256 };
|
||||
const IV_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Generate an ECDH P-256 keypair for key exchange.
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<CryptoKeyPair> {
|
||||
return crypto.subtle.generateKey(ECDH_ALGORITHM, true, ["deriveBits"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a public key to base64 string (for transmission).
|
||||
*/
|
||||
export async function exportPublicKey(key: CryptoKey): Promise<string> {
|
||||
const raw = await crypto.subtle.exportKey("raw", key);
|
||||
return arrayBufferToBase64(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a public key from base64 string.
|
||||
*/
|
||||
export async function importPublicKey(base64: string): Promise<CryptoKey> {
|
||||
const raw = base64ToArrayBuffer(base64);
|
||||
return crypto.subtle.importKey("raw", raw, ECDH_ALGORITHM, true, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a shared AES-256-GCM key from ECDH key exchange.
|
||||
*
|
||||
* Uses HKDF with SHA-256 to derive the final key.
|
||||
*/
|
||||
export async function deriveSharedKey(
|
||||
privateKey: CryptoKey,
|
||||
peerPublicKey: CryptoKey
|
||||
): Promise<CryptoKey> {
|
||||
// Perform ECDH to get shared secret bits
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: "ECDH", public: peerPublicKey },
|
||||
privateKey,
|
||||
256
|
||||
);
|
||||
|
||||
// Import shared bits as HKDF key material
|
||||
const hkdfKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedBits,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
|
||||
// Derive AES-256-GCM key using HKDF
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array(0),
|
||||
info: new TextEncoder().encode("paseo-e2ee-v1"),
|
||||
},
|
||||
hkdfKey,
|
||||
AES_ALGORITHM,
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data with AES-256-GCM.
|
||||
*
|
||||
* Returns: [IV (12 bytes)][ciphertext + auth tag]
|
||||
*/
|
||||
export async function encrypt(
|
||||
key: CryptoKey,
|
||||
data: string | ArrayBuffer
|
||||
): Promise<ArrayBuffer> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const plaintext =
|
||||
typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
|
||||
// Prepend IV to ciphertext
|
||||
const result = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
||||
result.set(iv, 0);
|
||||
result.set(new Uint8Array(ciphertext), IV_LENGTH);
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data with AES-256-GCM.
|
||||
*
|
||||
* Input format: [IV (12 bytes)][ciphertext + auth tag]
|
||||
* Returns string if original was string, ArrayBuffer if binary.
|
||||
*/
|
||||
export async function decrypt(
|
||||
key: CryptoKey,
|
||||
data: ArrayBuffer
|
||||
): Promise<string | ArrayBuffer> {
|
||||
const dataArray = new Uint8Array(data);
|
||||
const iv = dataArray.slice(0, IV_LENGTH);
|
||||
const ciphertext = dataArray.slice(IV_LENGTH);
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
// Try to decode as UTF-8 string, fall back to ArrayBuffer
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
|
||||
} catch {
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { createRelayServer, RelayServer } from "./node-adapter.js";
|
||||
import {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
const TEST_PORT = 19999;
|
||||
|
||||
describe("E2E Relay with E2EE", () => {
|
||||
let relay: RelayServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
relay = createRelayServer({ port: TEST_PORT, host: "127.0.0.1" });
|
||||
await relay.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await relay.stop();
|
||||
});
|
||||
|
||||
it("full flow: daemon and client exchange encrypted messages through relay", async () => {
|
||||
const sessionId = "test-session-" + Date.now();
|
||||
|
||||
// === DAEMON SIDE ===
|
||||
// Generate keypair (public key goes in QR)
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
// QR would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
|
||||
|
||||
// Daemon connects to relay as "server" role
|
||||
const daemonWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
daemonWs.on("open", resolve);
|
||||
daemonWs.on("error", reject);
|
||||
});
|
||||
|
||||
// === CLIENT SIDE ===
|
||||
// Client scans QR, gets daemon's public key and session ID
|
||||
// Client generates own keypair
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
// Client imports daemon's public key and derives shared secret
|
||||
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
daemonPubKeyOnClient
|
||||
);
|
||||
|
||||
// Client connects to relay as "client" role
|
||||
const clientWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
clientWs.on("open", resolve);
|
||||
clientWs.on("error", reject);
|
||||
});
|
||||
|
||||
// Client sends hello with its public key (this message is NOT encrypted - it's the handshake)
|
||||
const helloMsg = JSON.stringify({ type: "hello", key: clientPubKeyB64 });
|
||||
clientWs.send(helloMsg);
|
||||
|
||||
// === DAEMON RECEIVES HELLO ===
|
||||
const daemonReceivedHello = await new Promise<string>((resolve) => {
|
||||
daemonWs.once("message", (data) => resolve(data.toString()));
|
||||
});
|
||||
|
||||
const hello = JSON.parse(daemonReceivedHello);
|
||||
expect(hello.type).toBe("hello");
|
||||
expect(hello.key).toBe(clientPubKeyB64);
|
||||
|
||||
// Daemon imports client's public key and derives shared secret
|
||||
const clientPubKeyOnDaemon = await importPublicKey(hello.key);
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKeyOnDaemon
|
||||
);
|
||||
|
||||
// === VERIFY BOTH HAVE SAME KEY - Exchange encrypted messages ===
|
||||
|
||||
// Daemon sends encrypted "ready" message
|
||||
const readyPlaintext = JSON.stringify({ type: "ready" });
|
||||
const readyCiphertext = await encrypt(daemonSharedKey, readyPlaintext);
|
||||
daemonWs.send(Buffer.from(readyCiphertext));
|
||||
|
||||
// Client receives and decrypts
|
||||
const clientReceivedReady = await new Promise<Buffer>((resolve) => {
|
||||
clientWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
const decryptedReady = await decrypt(
|
||||
clientSharedKey,
|
||||
clientReceivedReady.buffer.slice(
|
||||
clientReceivedReady.byteOffset,
|
||||
clientReceivedReady.byteOffset + clientReceivedReady.byteLength
|
||||
)
|
||||
);
|
||||
expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" });
|
||||
|
||||
// Client sends encrypted message
|
||||
const clientMessage = "Hello from client!";
|
||||
const clientCiphertext = await encrypt(clientSharedKey, clientMessage);
|
||||
clientWs.send(Buffer.from(clientCiphertext));
|
||||
|
||||
// Daemon receives and decrypts
|
||||
const daemonReceivedMsg = await new Promise<Buffer>((resolve) => {
|
||||
daemonWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
const decryptedClientMsg = await decrypt(
|
||||
daemonSharedKey,
|
||||
daemonReceivedMsg.buffer.slice(
|
||||
daemonReceivedMsg.byteOffset,
|
||||
daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength
|
||||
)
|
||||
);
|
||||
expect(decryptedClientMsg).toBe(clientMessage);
|
||||
|
||||
// Daemon sends encrypted response
|
||||
const daemonMessage = "Hello from daemon!";
|
||||
const daemonCiphertext = await encrypt(daemonSharedKey, daemonMessage);
|
||||
daemonWs.send(Buffer.from(daemonCiphertext));
|
||||
|
||||
// Client receives and decrypts
|
||||
const clientReceivedMsg = await new Promise<Buffer>((resolve) => {
|
||||
clientWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
const decryptedDaemonMsg = await decrypt(
|
||||
clientSharedKey,
|
||||
clientReceivedMsg.buffer.slice(
|
||||
clientReceivedMsg.byteOffset,
|
||||
clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength
|
||||
)
|
||||
);
|
||||
expect(decryptedDaemonMsg).toBe(daemonMessage);
|
||||
|
||||
// Cleanup
|
||||
daemonWs.close();
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("relay only sees opaque bytes after handshake", async () => {
|
||||
const sessionId = "opaque-test-" + Date.now();
|
||||
|
||||
// Setup keys
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
const clientPubKey = await importPublicKey(clientPubKeyB64);
|
||||
const daemonPubKey = await importPublicKey(daemonPubKeyB64);
|
||||
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKey
|
||||
);
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
daemonPubKey
|
||||
);
|
||||
|
||||
// Connect both
|
||||
const daemonWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
|
||||
);
|
||||
const clientWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
new Promise<void>((r) => daemonWs.on("open", r)),
|
||||
new Promise<void>((r) => clientWs.on("open", r)),
|
||||
]);
|
||||
|
||||
// Send encrypted secret
|
||||
const secret = "This is a secret that relay cannot read";
|
||||
const ciphertext = await encrypt(clientSharedKey, secret);
|
||||
clientWs.send(Buffer.from(ciphertext));
|
||||
|
||||
// Daemon receives
|
||||
const received = await new Promise<Buffer>((resolve) => {
|
||||
daemonWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
|
||||
// The raw bytes don't contain the plaintext
|
||||
const rawString = received.toString("utf-8");
|
||||
expect(rawString).not.toContain(secret);
|
||||
|
||||
// But daemon can decrypt
|
||||
const decrypted = await decrypt(
|
||||
daemonSharedKey,
|
||||
received.buffer.slice(
|
||||
received.byteOffset,
|
||||
received.byteOffset + received.byteLength
|
||||
)
|
||||
);
|
||||
expect(decrypted).toBe(secret);
|
||||
|
||||
daemonWs.close();
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("wrong key cannot decrypt", async () => {
|
||||
const sessionId = "wrong-key-test-" + Date.now();
|
||||
|
||||
// Setup - daemon and client with correct keys
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const attackerKeyPair = await generateKeyPair();
|
||||
|
||||
const clientPubKey = await importPublicKey(
|
||||
await exportPublicKey(clientKeyPair.publicKey)
|
||||
);
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKey
|
||||
);
|
||||
|
||||
// Attacker tries to derive key with their own keypair
|
||||
const attackerPubKey = await importPublicKey(
|
||||
await exportPublicKey(attackerKeyPair.publicKey)
|
||||
);
|
||||
const attackerKey = await deriveSharedKey(
|
||||
attackerKeyPair.privateKey,
|
||||
attackerPubKey
|
||||
);
|
||||
|
||||
// Encrypt with daemon's key
|
||||
const secret = "Top secret message";
|
||||
const ciphertext = await encrypt(daemonSharedKey, secret);
|
||||
|
||||
// Attacker cannot decrypt
|
||||
await expect(decrypt(attackerKey, ciphertext)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
createClientChannel,
|
||||
createDaemonChannel,
|
||||
Transport,
|
||||
} from "./encrypted-channel.js";
|
||||
import { generateKeyPair, exportPublicKey } from "./crypto.js";
|
||||
|
||||
/**
|
||||
* Creates a pair of connected mock transports.
|
||||
* Messages sent on one are received on the other.
|
||||
*/
|
||||
function createMockTransportPair(): [Transport, Transport] {
|
||||
const transportA: Transport = {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
const transportB: Transport = {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
// Wire them together
|
||||
(transportA.send as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(data: string | ArrayBuffer) => {
|
||||
setTimeout(() => transportB.onmessage?.(data), 0);
|
||||
}
|
||||
);
|
||||
|
||||
(transportB.send as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(data: string | ArrayBuffer) => {
|
||||
setTimeout(() => transportA.onmessage?.(data), 0);
|
||||
}
|
||||
);
|
||||
|
||||
return [transportA, transportB];
|
||||
}
|
||||
|
||||
describe("EncryptedChannel", () => {
|
||||
it("establishes encrypted channel between daemon and client", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
// Daemon generates keypair (public key goes in QR)
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
// Start daemon waiting for client
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||
|
||||
// Client connects (scanned QR, got daemon's public key)
|
||||
const clientChannel = await createClientChannel(
|
||||
clientTransport,
|
||||
daemonPubKeyB64
|
||||
);
|
||||
|
||||
// Daemon receives hello and completes handshake
|
||||
const daemonChannel = await daemonChannelPromise;
|
||||
|
||||
expect(clientChannel.isOpen()).toBe(true);
|
||||
expect(daemonChannel.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("exchanges encrypted messages bidirectionally", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
const daemonMessages: (string | ArrayBuffer)[] = [];
|
||||
const clientMessages: (string | ArrayBuffer)[] = [];
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(
|
||||
daemonTransport,
|
||||
daemonKeyPair,
|
||||
{ onmessage: (data) => daemonMessages.push(data) }
|
||||
);
|
||||
|
||||
const clientChannel = await createClientChannel(
|
||||
clientTransport,
|
||||
daemonPubKeyB64,
|
||||
{ onmessage: (data) => clientMessages.push(data) }
|
||||
);
|
||||
|
||||
const daemonChannel = await daemonChannelPromise;
|
||||
|
||||
// Send messages both directions
|
||||
await clientChannel.send("Hello from client");
|
||||
await daemonChannel.send("Hello from daemon");
|
||||
await clientChannel.send("Second message from client");
|
||||
|
||||
// Wait for async delivery
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(daemonMessages).toEqual([
|
||||
"Hello from client",
|
||||
"Second message from client",
|
||||
]);
|
||||
expect(clientMessages).toEqual(["Hello from daemon"]);
|
||||
});
|
||||
|
||||
it("encrypted messages are opaque to transport", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||
const clientChannel = await createClientChannel(
|
||||
clientTransport,
|
||||
daemonPubKeyB64
|
||||
);
|
||||
await daemonChannelPromise;
|
||||
|
||||
// Clear mock call history
|
||||
(clientTransport.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Send a plaintext message
|
||||
const plaintext = "Secret message";
|
||||
await clientChannel.send(plaintext);
|
||||
|
||||
// Check what was actually sent over the transport
|
||||
expect(clientTransport.send).toHaveBeenCalledTimes(1);
|
||||
const sentData = (clientTransport.send as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Should be base64 string (encrypted)
|
||||
expect(typeof sentData).toBe("string");
|
||||
// Should NOT contain the plaintext
|
||||
expect(sentData).not.toContain(plaintext);
|
||||
// Should be significantly longer than plaintext (IV + auth tag overhead)
|
||||
expect(sentData.length).toBeGreaterThan(plaintext.length + 20);
|
||||
});
|
||||
|
||||
it("fails handshake on invalid hello", async () => {
|
||||
const [daemonTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||
|
||||
// Send invalid hello
|
||||
setTimeout(() => {
|
||||
daemonTransport.onmessage?.('{"type":"invalid"}');
|
||||
}, 0);
|
||||
|
||||
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Encrypted channel that wraps a WebSocket-like transport.
|
||||
*
|
||||
* Handles ECDH handshake and encrypts/decrypts all messages.
|
||||
* Works identically for daemon and client sides.
|
||||
*/
|
||||
|
||||
import {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
export interface Transport {
|
||||
send(data: string | ArrayBuffer): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
onmessage: ((data: string | ArrayBuffer) => void) | null;
|
||||
onclose: ((code: number, reason: string) => void) | null;
|
||||
onerror: ((error: Error) => void) | null;
|
||||
}
|
||||
|
||||
export interface EncryptedChannelEvents {
|
||||
onopen?: () => void;
|
||||
onmessage?: (data: string | ArrayBuffer) => void;
|
||||
onclose?: (code: number, reason: string) => void;
|
||||
onerror?: (error: Error) => void;
|
||||
}
|
||||
|
||||
type ChannelState = "connecting" | "handshaking" | "open" | "closed";
|
||||
|
||||
interface HelloMessage {
|
||||
type: "hello";
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encrypted channel as the initiator (client).
|
||||
*
|
||||
* The client:
|
||||
* 1. Receives daemon's public key via QR code
|
||||
* 2. Generates own keypair
|
||||
* 3. Sends hello with own public key
|
||||
* 4. Derives shared key and starts encrypted communication
|
||||
*/
|
||||
export async function createClientChannel(
|
||||
transport: Transport,
|
||||
daemonPublicKeyB64: string,
|
||||
events: EncryptedChannelEvents = {}
|
||||
): Promise<EncryptedChannel> {
|
||||
const keyPair = await generateKeyPair();
|
||||
const daemonPublicKey = await importPublicKey(daemonPublicKeyB64);
|
||||
const sharedKey = await deriveSharedKey(keyPair.privateKey, daemonPublicKey);
|
||||
|
||||
const channel = new EncryptedChannel(transport, sharedKey, events);
|
||||
|
||||
// Send hello with our public key
|
||||
const ourPublicKeyB64 = await exportPublicKey(keyPair.publicKey);
|
||||
const hello: HelloMessage = { type: "hello", key: ourPublicKeyB64 };
|
||||
transport.send(JSON.stringify(hello));
|
||||
|
||||
channel.setState("open");
|
||||
events.onopen?.();
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encrypted channel as the responder (daemon).
|
||||
*
|
||||
* The daemon:
|
||||
* 1. Has pre-generated keypair (public key was in QR)
|
||||
* 2. Waits for client's hello with their public key
|
||||
* 3. Derives shared key and starts encrypted communication
|
||||
*/
|
||||
export async function createDaemonChannel(
|
||||
transport: Transport,
|
||||
daemonKeyPair: CryptoKeyPair,
|
||||
events: EncryptedChannelEvents = {}
|
||||
): Promise<EncryptedChannel> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Handshake timeout"));
|
||||
}, 10000);
|
||||
|
||||
transport.onmessage = async (data) => {
|
||||
try {
|
||||
if (typeof data !== "string") {
|
||||
throw new Error("Expected string hello message");
|
||||
}
|
||||
|
||||
const msg = JSON.parse(data) as HelloMessage;
|
||||
if (msg.type !== "hello" || !msg.key) {
|
||||
throw new Error("Invalid hello message");
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
const clientPublicKey = await importPublicKey(msg.key);
|
||||
const sharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPublicKey
|
||||
);
|
||||
|
||||
const channel = new EncryptedChannel(transport, sharedKey, events);
|
||||
channel.setState("open");
|
||||
events.onopen?.();
|
||||
|
||||
resolve(channel);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
transport.onerror = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transport.onclose = (code, reason) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted channel that wraps a transport with E2EE.
|
||||
*/
|
||||
export class EncryptedChannel {
|
||||
private transport: Transport;
|
||||
private sharedKey: CryptoKey;
|
||||
private state: ChannelState = "handshaking";
|
||||
private events: EncryptedChannelEvents;
|
||||
|
||||
constructor(
|
||||
transport: Transport,
|
||||
sharedKey: CryptoKey,
|
||||
events: EncryptedChannelEvents = {}
|
||||
) {
|
||||
this.transport = transport;
|
||||
this.sharedKey = sharedKey;
|
||||
this.events = events;
|
||||
|
||||
transport.onmessage = (data) => this.handleMessage(data);
|
||||
transport.onclose = (code, reason) => {
|
||||
this.state = "closed";
|
||||
this.events.onclose?.(code, reason);
|
||||
};
|
||||
transport.onerror = (error) => {
|
||||
this.events.onerror?.(error);
|
||||
};
|
||||
}
|
||||
|
||||
setState(state: ChannelState): void {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
private async handleMessage(data: string | ArrayBuffer): Promise<void> {
|
||||
if (this.state !== "open") return;
|
||||
|
||||
try {
|
||||
const ciphertext =
|
||||
typeof data === "string" ? base64ToArrayBuffer(data) : data;
|
||||
const plaintext = await decrypt(this.sharedKey, ciphertext);
|
||||
this.events.onmessage?.(plaintext);
|
||||
} catch (error) {
|
||||
this.events.onerror?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async send(data: string | ArrayBuffer): Promise<void> {
|
||||
if (this.state !== "open") {
|
||||
throw new Error("Channel not open");
|
||||
}
|
||||
|
||||
const ciphertext = await encrypt(this.sharedKey, data);
|
||||
// Send as base64 for WebSocket text compatibility
|
||||
this.transport.send(arrayBufferToBase64(ciphertext));
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "Normal closure"): void {
|
||||
this.state = "closed";
|
||||
this.transport.close(code, reason);
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.state === "open";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export { Relay } from "./relay.js";
|
||||
export type {
|
||||
ConnectionRole,
|
||||
RelayConnection,
|
||||
RelayEvents,
|
||||
RelaySession,
|
||||
RelaySessionAttachment,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
export {
|
||||
createClientChannel,
|
||||
createDaemonChannel,
|
||||
EncryptedChannel,
|
||||
} from "./encrypted-channel.js";
|
||||
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
|
||||
@@ -0,0 +1,135 @@
|
||||
import http from "http";
|
||||
import { WebSocketServer, WebSocket as NodeWebSocket } from "ws";
|
||||
import { Relay } from "./relay.js";
|
||||
import type { ConnectionRole, RelayConnection } from "./types.js";
|
||||
|
||||
export interface NodeRelayServerConfig {
|
||||
port: number;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone Node.js relay server for self-hosting.
|
||||
*
|
||||
* This is a separate process that bridges daemon↔client connections.
|
||||
* Use this when you want to self-host a relay instead of using Cloudflare.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const server = createRelayServer({ port: 8080 });
|
||||
* await server.start();
|
||||
* ```
|
||||
*
|
||||
* Clients connect via:
|
||||
* - ws://host:port/ws?session=abc&role=server
|
||||
* - ws://host:port/ws?session=abc&role=client
|
||||
*/
|
||||
export interface RelayServer {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getRelay(): Relay;
|
||||
}
|
||||
|
||||
export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
|
||||
const { port, host = "0.0.0.0" } = config;
|
||||
|
||||
const relay = new Relay({
|
||||
onSessionCreated: (id) => console.log(`[Relay] Session created: ${id}`),
|
||||
onSessionBridged: (id) => console.log(`[Relay] Session bridged: ${id}`),
|
||||
onSessionClosed: (id) => console.log(`[Relay] Session closed: ${id}`),
|
||||
onError: (id, err) => console.error(`[Relay] Session ${id} error:`, err),
|
||||
});
|
||||
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
if (req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server: httpServer,
|
||||
path: "/ws",
|
||||
verifyClient: ({ req }, callback) => {
|
||||
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
const session = url.searchParams.get("session");
|
||||
const role = url.searchParams.get("role");
|
||||
|
||||
if (!session || !role || (role !== "server" && role !== "client")) {
|
||||
callback(false, 400, "Missing or invalid session/role parameters");
|
||||
return;
|
||||
}
|
||||
|
||||
callback(true);
|
||||
},
|
||||
});
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("session")!;
|
||||
const role = url.searchParams.get("role") as ConnectionRole;
|
||||
|
||||
const connection = wrapWebSocket(ws);
|
||||
relay.addConnection(sessionId, role, connection);
|
||||
|
||||
ws.on("message", (data) => {
|
||||
const message =
|
||||
data instanceof Buffer
|
||||
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
||||
: String(data);
|
||||
relay.forward(sessionId, role, message as string | ArrayBuffer);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
relay.removeConnection(sessionId, role);
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[Relay] WebSocket error for ${sessionId}/${role}:`, error);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpServer.on("error", reject);
|
||||
httpServer.listen(port, host, () => {
|
||||
console.log(`[Relay] Listening on ${host}:${port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
return new Promise((resolve) => {
|
||||
for (const session of relay.listSessions()) {
|
||||
relay.closeSession(session.id, 1001, "Server shutting down");
|
||||
}
|
||||
wss.close(() => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getRelay() {
|
||||
return relay;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapWebSocket(ws: NodeWebSocket): RelayConnection {
|
||||
return {
|
||||
role: "server",
|
||||
send: (data) => {
|
||||
if (ws.readyState === NodeWebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
},
|
||||
close: (code, reason) => {
|
||||
ws.close(code, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
ConnectionRole,
|
||||
RelayConnection,
|
||||
RelayEvents,
|
||||
RelaySession,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Core relay logic for bridging server and client WebSocket connections.
|
||||
*
|
||||
* This class is platform-agnostic and works with any WebSocket implementation
|
||||
* that conforms to the RelayConnection interface.
|
||||
*/
|
||||
export class Relay {
|
||||
private sessions = new Map<string, RelaySession>();
|
||||
private events: RelayEvents;
|
||||
|
||||
constructor(events: RelayEvents = {}) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a connection for a session.
|
||||
* If both server and client are connected, messages are bridged.
|
||||
*/
|
||||
addConnection(
|
||||
sessionId: string,
|
||||
role: ConnectionRole,
|
||||
connection: RelayConnection
|
||||
): void {
|
||||
let session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
session = {
|
||||
id: sessionId,
|
||||
server: null,
|
||||
client: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
this.events.onSessionCreated?.(sessionId);
|
||||
}
|
||||
|
||||
const existingConnection = session[role];
|
||||
if (existingConnection) {
|
||||
existingConnection.close(1008, "Replaced by new connection");
|
||||
}
|
||||
|
||||
session[role] = connection;
|
||||
|
||||
if (session.server && session.client) {
|
||||
this.events.onSessionBridged?.(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a connection from a session.
|
||||
* If both connections are gone, the session is cleaned up.
|
||||
*/
|
||||
removeConnection(sessionId: string, role: ConnectionRole): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session[role] = null;
|
||||
|
||||
if (!session.server && !session.client) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.events.onSessionClosed?.(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a message from one side to the other.
|
||||
*/
|
||||
forward(
|
||||
sessionId: string,
|
||||
fromRole: ConnectionRole,
|
||||
data: string | ArrayBuffer
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const target = fromRole === "server" ? session.client : session.server;
|
||||
if (target) {
|
||||
try {
|
||||
target.send(data);
|
||||
} catch (error) {
|
||||
this.events.onError?.(
|
||||
sessionId,
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session info for debugging/monitoring.
|
||||
*/
|
||||
getSession(sessionId: string): RelaySession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active sessions.
|
||||
*/
|
||||
listSessions(): RelaySession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a session and both connections.
|
||||
*/
|
||||
closeSession(sessionId: string, code = 1000, reason = "Session closed"): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.server?.close(code, reason);
|
||||
session.client?.close(code, reason);
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
this.events.onSessionClosed?.(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore sessions from persisted state (for Durable Objects hibernation).
|
||||
*/
|
||||
restoreSessions(sessions: RelaySession[]): void {
|
||||
for (const session of sessions) {
|
||||
this.sessions.set(session.id, session);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Relay connection types and interfaces.
|
||||
*
|
||||
* The relay bridges two WebSocket connections:
|
||||
* - Server (daemon): The Paseo server connecting to the relay
|
||||
* - Client (app): The mobile/web app connecting to the relay
|
||||
*
|
||||
* Messages are forwarded bidirectionally without modification.
|
||||
*/
|
||||
|
||||
export type ConnectionRole = "server" | "client";
|
||||
|
||||
export interface RelaySession {
|
||||
id: string;
|
||||
server: RelayConnection | null;
|
||||
client: RelayConnection | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RelayConnection {
|
||||
role: ConnectionRole;
|
||||
send(data: string | ArrayBuffer): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
export interface RelaySessionAttachment {
|
||||
sessionId: string;
|
||||
role: ConnectionRole;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RelayEvents {
|
||||
onSessionCreated?(sessionId: string): void;
|
||||
onSessionBridged?(sessionId: string): void;
|
||||
onSessionClosed?(sessionId: string): void;
|
||||
onError?(sessionId: string, error: Error): void;
|
||||
}
|
||||
Reference in New Issue
Block a user