From 1bd7a34de848c79cb5b73ab8e2bc63641f197e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=9E=AB?= Date: Tue, 17 Mar 2026 15:26:24 +0800 Subject: [PATCH] feat: rework CookieBridge to v2 architecture per CEO feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture changes: - Extension connects directly to server (no local proxy/daemon) - Dual transport: WebSocket (real-time) + HTTP polling (fallback) - Server stores encrypted cookie blobs (E2E encrypted, server-blind) - Device registration with API token auth - Pairing records stored server-side for cross-device cookie access - Agent Skill API: AI agents get tokens to retrieve encrypted cookies with domain-level access control New modules: - src/relay/store.ts — encrypted cookie blob storage (LWW, per-device limits) - src/relay/tokens.ts — device registry, agent registry, pairing tracking - Protocol spec v2 with new types (EncryptedCookieBlob, AgentToken, etc.) 38 tests passing (crypto, pairing, conflict, full integration with HTTP polling, agent API, and WebSocket relay). Co-Authored-By: Paperclip --- src/index.ts | 10 +- src/protocol/spec.ts | 134 +++++++----- src/relay/index.ts | 2 + src/relay/server.ts | 444 +++++++++++++++++++++++++++++++------- src/relay/store.ts | 103 +++++++++ src/relay/tokens.ts | 125 +++++++++++ tests/integration.test.ts | 417 ++++++++++++++++++++++++----------- 7 files changed, 978 insertions(+), 257 deletions(-) create mode 100644 src/relay/store.ts create mode 100644 src/relay/tokens.ts diff --git a/src/index.ts b/src/index.ts index 16955c0..0184d14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export { RelayServer } from "./relay/index.js"; export type { RelayServerConfig } from "./relay/index.js"; +export { CookieBlobStore } from "./relay/store.js"; +export { DeviceRegistry, AgentRegistry } from "./relay/tokens.js"; export { generateKeyPair, @@ -24,16 +26,22 @@ export { PROTOCOL_VERSION, MESSAGE_TYPES, MAX_OFFLINE_QUEUE, + MAX_STORED_COOKIES_PER_DEVICE, PAIRING_CODE_LENGTH, PAIRING_TTL_MS, + POLL_INTERVAL_MS, } from "./protocol/spec.js"; export type { Envelope, MessageType, CookieEntry, CookieSyncPayload, + EncryptedCookieBlob, + DeviceRegisterRequest, + DeviceInfo, PairingRequest, PairingAccept, PairingResult, - DeviceInfo, + AgentTokenRequest, + AgentToken, } from "./protocol/spec.js"; diff --git a/src/protocol/spec.ts b/src/protocol/spec.ts index 53c6aba..e2bc3fe 100644 --- a/src/protocol/spec.ts +++ b/src/protocol/spec.ts @@ -1,54 +1,46 @@ /** - * CookieBridge Protocol Specification + * CookieBridge Protocol Specification v2 * - * Architecture: - * Device A <--E2E encrypted--> Relay Server <--E2E encrypted--> Device B - * The relay never sees plaintext cookies. It forwards opaque encrypted blobs. + * Architecture (revised): + * Browser Extension ──────▶ CookieBridge Server + * (no local proxy/daemon) (stores encrypted blobs) + * + * Transport: WebSocket (real-time) or HTTP polling (compatibility fallback). + * The server stores encrypted cookie data but CANNOT decrypt it. + * AI agents can retrieve encrypted cookies via the Agent Skill API. * * Device Identity: * Each device generates an X25519 keypair for key exchange and an Ed25519 - * keypair for signing. The device is identified by its Ed25519 public key - * (the "deviceId"). + * keypair for signing. The deviceId is the Ed25519 public key (hex). + * + * Auth Flow: + * 1. Device registers: POST /api/devices/register { deviceId, name, platform, signPub, encPub } + * → returns an API token (Bearer token for subsequent requests). + * 2. For WebSocket: connect to /ws with token in query string or first message. + * 3. For HTTP polling: use Bearer token on all endpoints. * * Pairing Flow: - * 1. Device A generates a pairing code (random 6-digit + short-lived secret). - * 2. Device A registers a pairing session with the relay (POST /pair). - * 3. Device B enters the code (or scans QR) and POSTs to /pair/accept. - * 4. Relay brokers the X25519 public key exchange. - * 5. Both devices derive a shared secret via X25519 ECDH. - * 6. Pairing session is deleted from relay. Devices store each other's keys locally. + * 1. Device A creates a pairing session: POST /api/pair + * 2. Device B accepts with the code: POST /api/pair/accept + * 3. Server brokers the X25519 key exchange. + * 4. Both devices derive a shared secret locally. * - * Sync Protocol: - * Messages are sent over WebSocket as JSON envelopes wrapping encrypted payloads. - * - * Envelope: { type, from, to, nonce, payload, timestamp, sig } - * - type: message type (cookie_sync, cookie_delete, ack, ping, pong) - * - from: sender deviceId (Ed25519 pubkey hex) - * - to: recipient deviceId - * - nonce: random 24-byte nonce (hex) - * - payload: XChaCha20-Poly1305 ciphertext (base64) - * - timestamp: ISO-8601 - * - sig: Ed25519 signature over (type + from + to + nonce + payload + timestamp) - * - * The relay authenticates devices by verifying `sig` against `from`. - * The relay routes by `to`. If the recipient is offline, the message is - * queued (up to a configurable limit) and delivered on reconnect. + * Cookie Storage: + * Devices push encrypted cookie blobs to the server. + * The server stores them keyed by (deviceId, domain, cookieName, path). + * Other paired devices (or AI agents with access) can pull and decrypt. * * Conflict Resolution: - * Last-writer-wins (LWW) per (domain, cookie-name) pair. - * Each cookie sync payload includes a logical timestamp (Lamport clock). - * Recipient applies the update only if the incoming timestamp > local timestamp - * for that key. Ties broken by deviceId lexicographic order. + * Last-writer-wins (LWW) per (domain, cookie-name, path). + * Lamport clock timestamps. Ties broken by deviceId lexicographic order. * - * Encrypted Payload (after decryption): - * { - * action: "set" | "delete", - * cookies: [{ domain, name, value, path, secure, httpOnly, sameSite, expiresAt }], - * lamportTs: number - * } + * Agent Skill API: + * AI agents authenticate with an agent token. + * GET /api/agent/cookies?domain=... → returns encrypted cookie blobs. + * The agent must possess the decryption key (shared by the user). */ -// --- Message Types --- +// --- Message Types (WebSocket) --- export const MESSAGE_TYPES = { COOKIE_SYNC: "cookie_sync", @@ -61,19 +53,19 @@ export const MESSAGE_TYPES = { export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES]; -// --- Wire Envelope --- +// --- Wire Envelope (WebSocket transport) --- export interface Envelope { type: MessageType; from: string; // deviceId (Ed25519 pubkey hex) - to: string; // recipient deviceId + to: string; // recipient deviceId or "server" nonce: string; // 24-byte hex payload: string; // encrypted ciphertext, base64 timestamp: string; // ISO-8601 sig: string; // Ed25519 signature hex } -// --- Decrypted Payloads --- +// --- Cookie Types --- export interface CookieEntry { domain: string; @@ -92,12 +84,44 @@ export interface CookieSyncPayload { lamportTs: number; } +// --- Encrypted Cookie Blob (stored on server) --- + +export interface EncryptedCookieBlob { + id: string; // server-assigned + deviceId: string; // uploader + domain: string; // plaintext domain for querying + cookieName: string; // plaintext name for querying + path: string; // plaintext path + nonce: string; // hex + ciphertext: string; // base64 — encrypted CookieEntry.value + metadata + lamportTs: number; + updatedAt: string; // ISO-8601 +} + +// --- Device Registration --- + +export interface DeviceRegisterRequest { + deviceId: string; // Ed25519 pubkey hex + name: string; + platform: string; + encPub: string; // X25519 pubkey hex +} + +export interface DeviceInfo { + deviceId: string; + name: string; + platform: string; + encPub: string; + token: string; // API bearer token + createdAt: string; +} + // --- Pairing --- export interface PairingRequest { - deviceId: string; // Ed25519 pubkey hex - x25519PubKey: string; // X25519 pubkey hex - pairingCode: string; // 6-digit code + deviceId: string; + x25519PubKey: string; + pairingCode: string; } export interface PairingAccept { @@ -111,16 +135,24 @@ export interface PairingResult { peerX25519PubKey: string; } -// --- Device Registration --- +// --- Agent Skill --- -export interface DeviceInfo { - deviceId: string; // Ed25519 pubkey hex +export interface AgentTokenRequest { name: string; - platform: string; + encPub: string; // agent's X25519 pubkey for decryption + allowedDomains: string[]; // domains the agent can access (empty = all) +} + +export interface AgentToken { + id: string; + name: string; + token: string; // Bearer token + encPub: string; + allowedDomains: string[]; createdAt: string; } -// --- Relay Auth --- +// --- Relay Auth (WebSocket challenge-response) --- export interface RelayAuthChallenge { challenge: string; // random bytes hex @@ -134,10 +166,12 @@ export interface RelayAuthResponse { // --- Protocol Constants --- -export const PROTOCOL_VERSION = "1.0.0"; +export const PROTOCOL_VERSION = "2.0.0"; export const MAX_OFFLINE_QUEUE = 1000; +export const MAX_STORED_COOKIES_PER_DEVICE = 10_000; export const PAIRING_CODE_LENGTH = 6; export const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 minutes export const NONCE_BYTES = 24; export const PING_INTERVAL_MS = 30_000; export const PONG_TIMEOUT_MS = 10_000; +export const POLL_INTERVAL_MS = 5_000; // HTTP polling default diff --git a/src/relay/index.ts b/src/relay/index.ts index 85c9330..2fd930e 100644 --- a/src/relay/index.ts +++ b/src/relay/index.ts @@ -1,3 +1,5 @@ export { RelayServer } from "./server.js"; export type { RelayServerConfig } from "./server.js"; export { ConnectionManager } from "./connections.js"; +export { CookieBlobStore } from "./store.js"; +export { DeviceRegistry, AgentRegistry } from "./tokens.js"; diff --git a/src/relay/server.ts b/src/relay/server.ts index 84a9265..853ffe5 100644 --- a/src/relay/server.ts +++ b/src/relay/server.ts @@ -4,12 +4,13 @@ import { ConnectionManager } from "./connections.js"; import { generateChallenge, verifyAuthResponse } from "./auth.js"; import { verify, buildSignablePayload } from "../crypto/signing.js"; import { PairingStore } from "../pairing/pairing.js"; +import { CookieBlobStore } from "./store.js"; +import { DeviceRegistry, AgentRegistry } from "./tokens.js"; import { type Envelope, - type MessageType, + type EncryptedCookieBlob, MESSAGE_TYPES, PING_INTERVAL_MS, - PONG_TIMEOUT_MS, } from "../protocol/spec.js"; export interface RelayServerConfig { @@ -23,28 +24,42 @@ interface PendingAuth { } /** - * CookieBridge Relay Server. + * CookieBridge Server (v2). * - * HTTP endpoints: - * POST /pair — initiate a pairing session - * POST /pair/accept — accept a pairing session - * GET /health — health check + * HTTP API: + * POST /api/devices/register — register a device, get API token + * POST /api/pair — initiate pairing + * POST /api/pair/accept — accept pairing + * POST /api/cookies — push encrypted cookie blobs + * GET /api/cookies — pull encrypted cookie blobs (polling) + * DELETE /api/cookies — delete a cookie blob + * GET /api/cookies/updates — poll for updates since timestamp + * POST /api/agent/tokens — create an agent API token + * POST /api/agent/grant — grant agent access to device cookies + * GET /api/agent/cookies — agent retrieves encrypted cookies + * GET /health — health check * * WebSocket: - * /ws — authenticated device connection for message relay + * /ws — real-time sync (challenge-response auth or token auth) */ export class RelayServer { private httpServer: http.Server; private wss: WebSocketServer; - private connections: ConnectionManager; - private pairingStore: PairingStore; + readonly connections: ConnectionManager; + readonly pairingStore: PairingStore; + readonly cookieStore: CookieBlobStore; + readonly deviceRegistry: DeviceRegistry; + readonly agentRegistry: AgentRegistry; private pendingAuths = new Map(); - private authenticatedDevices = new Map(); // ws -> deviceId + private authenticatedDevices = new Map(); private pingIntervals = new Map>(); constructor(private config: RelayServerConfig) { this.connections = new ConnectionManager(); this.pairingStore = new PairingStore(); + this.cookieStore = new CookieBlobStore(); + this.deviceRegistry = new DeviceRegistry(); + this.agentRegistry = new AgentRegistry(); this.httpServer = http.createServer(this.handleHttp.bind(this)); this.wss = new WebSocketServer({ server: this.httpServer }); @@ -78,44 +93,132 @@ export class RelayServer { return this.config.port; } - // --- HTTP --- + // --- HTTP routing --- private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void { - if (req.method === "GET" && req.url === "/health") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "ok", connections: this.connections.connectedCount })); + const url = req.url ?? ""; + const method = req.method ?? ""; + + // Health + if (method === "GET" && url === "/health") { + this.json(res, 200, { status: "ok", connections: this.connections.connectedCount }); return; } - if (req.method === "POST" && req.url === "/pair") { + // Device registration + if (method === "POST" && url === "/api/devices/register") { + this.handleDeviceRegister(req, res); + return; + } + + // Pairing + if (method === "POST" && url === "/api/pair") { this.handlePairCreate(req, res); return; } - - if (req.method === "POST" && req.url === "/pair/accept") { + if (method === "POST" && url === "/api/pair/accept") { this.handlePairAccept(req, res); return; } + // Cookie storage (device auth required) + if (method === "POST" && url === "/api/cookies") { + this.withDeviceAuth(req, res, (device) => this.handleCookiePush(req, res, device)); + return; + } + if (method === "GET" && url.startsWith("/api/cookies/updates")) { + this.withDeviceAuth(req, res, (device) => this.handleCookiePoll(req, res, device)); + return; + } + if (method === "GET" && url.startsWith("/api/cookies")) { + this.withDeviceAuth(req, res, (device) => this.handleCookiePull(req, res, device)); + return; + } + if (method === "DELETE" && url === "/api/cookies") { + this.withDeviceAuth(req, res, (device) => this.handleCookieDelete(req, res, device)); + return; + } + + // Agent Skill API + if (method === "POST" && url === "/api/agent/tokens") { + this.withDeviceAuth(req, res, (device) => this.handleAgentTokenCreate(req, res, device)); + return; + } + if (method === "POST" && url === "/api/agent/grant") { + this.withDeviceAuth(req, res, (device) => this.handleAgentGrant(req, res, device)); + return; + } + if (method === "GET" && url.startsWith("/api/agent/cookies")) { + this.handleAgentCookies(req, res); + return; + } + res.writeHead(404); res.end("Not found"); } + // --- Auth middleware --- + + private withDeviceAuth( + req: http.IncomingMessage, + res: http.ServerResponse, + handler: (device: { deviceId: string }) => void, + ): void { + const token = this.extractBearerToken(req); + if (!token) { + this.json(res, 401, { error: "Missing Authorization header" }); + return; + } + const device = this.deviceRegistry.getByToken(token); + if (!device) { + this.json(res, 401, { error: "Invalid token" }); + return; + } + handler({ deviceId: device.deviceId }); + } + + private extractBearerToken(req: http.IncomingMessage): string | null { + const auth = req.headers.authorization; + if (!auth?.startsWith("Bearer ")) return null; + return auth.slice(7); + } + + // --- Device Registration --- + + private handleDeviceRegister(req: http.IncomingMessage, res: http.ServerResponse): void { + this.readBody(req, (body) => { + try { + const { deviceId, name, platform, encPub } = JSON.parse(body); + if (!deviceId || !name || !platform || !encPub) { + this.json(res, 400, { error: "Missing required fields: deviceId, name, platform, encPub" }); + return; + } + const info = this.deviceRegistry.register(deviceId, name, platform, encPub); + this.json(res, 201, { + deviceId: info.deviceId, + token: info.token, + createdAt: info.createdAt, + }); + } catch { + this.json(res, 400, { error: "Invalid JSON" }); + } + }); + } + + // --- Pairing --- + private handlePairCreate(req: http.IncomingMessage, res: http.ServerResponse): void { this.readBody(req, (body) => { try { const { deviceId, x25519PubKey } = JSON.parse(body); if (!deviceId || !x25519PubKey) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing deviceId or x25519PubKey" })); + this.json(res, 400, { error: "Missing deviceId or x25519PubKey" }); return; } const session = this.pairingStore.create(deviceId, x25519PubKey); - res.writeHead(201, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ pairingCode: session.pairingCode, expiresAt: session.expiresAt })); + this.json(res, 201, { pairingCode: session.pairingCode, expiresAt: session.expiresAt }); } catch { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid JSON" })); + this.json(res, 400, { error: "Invalid JSON" }); } }); } @@ -125,69 +228,215 @@ export class RelayServer { try { const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body); if (!deviceId || !x25519PubKey || !pairingCode) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Missing required fields" })); + this.json(res, 400, { error: "Missing required fields" }); return; } const session = this.pairingStore.consume(pairingCode); if (!session) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid or expired pairing code" })); + this.json(res, 404, { error: "Invalid or expired pairing code" }); return; } - // Return both peers' info - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - initiator: { - deviceId: session.deviceId, - x25519PubKey: session.x25519PubKey, - }, - acceptor: { - deviceId, - x25519PubKey, - }, - }), - ); + + // Record the pairing in device registry + this.deviceRegistry.addPairing(session.deviceId, deviceId); + + this.json(res, 200, { + initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey }, + acceptor: { deviceId, x25519PubKey }, + }); } catch { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Invalid JSON" })); + this.json(res, 400, { error: "Invalid JSON" }); } }); } - private readBody(req: http.IncomingMessage, cb: (body: string) => void): void { - let data = ""; - req.on("data", (chunk: Buffer) => { - data += chunk.toString(); - if (data.length > 64 * 1024) { - req.destroy(); + // --- Cookie Storage (HTTP polling transport) --- + + private handleCookiePush( + req: http.IncomingMessage, + res: http.ServerResponse, + device: { deviceId: string }, + ): void { + this.readBody(req, (body) => { + try { + const { cookies } = JSON.parse(body) as { + cookies: Array>; + }; + if (!cookies || !Array.isArray(cookies)) { + this.json(res, 400, { error: "Missing cookies array" }); + return; + } + + const stored = cookies.map((c) => + this.cookieStore.upsert({ ...c, deviceId: device.deviceId }), + ); + + // Notify paired devices via WebSocket if connected + const pairedDevices = this.deviceRegistry.getPairedDevices(device.deviceId); + for (const peerId of pairedDevices) { + if (this.connections.isOnline(peerId)) { + this.connections.send(peerId, { + type: MESSAGE_TYPES.COOKIE_SYNC, + from: device.deviceId, + to: peerId, + nonce: "", + payload: JSON.stringify({ blobs: stored }), + timestamp: new Date().toISOString(), + sig: "", + }); + } + } + + this.json(res, 200, { stored }); + } catch { + this.json(res, 400, { error: "Invalid JSON" }); } }); - req.on("end", () => cb(data)); + } + + private handleCookiePull( + _req: http.IncomingMessage, + res: http.ServerResponse, + device: { deviceId: string }, + ): void { + const url = new URL(_req.url ?? "", `http://${_req.headers.host}`); + const domain = url.searchParams.get("domain") ?? undefined; + + // Get cookies from all paired devices + const group = this.deviceRegistry.getPairingGroup(device.deviceId); + const blobs = this.cookieStore.getByDevices(group, domain); + + this.json(res, 200, { cookies: blobs }); + } + + private handleCookiePoll( + _req: http.IncomingMessage, + res: http.ServerResponse, + device: { deviceId: string }, + ): void { + const url = new URL(_req.url ?? "", `http://${_req.headers.host}`); + const since = url.searchParams.get("since"); + if (!since) { + this.json(res, 400, { error: "Missing 'since' query parameter" }); + return; + } + + const group = this.deviceRegistry.getPairingGroup(device.deviceId); + const blobs = this.cookieStore.getUpdatedSince(group, since); + + this.json(res, 200, { cookies: blobs, serverTime: new Date().toISOString() }); + } + + private handleCookieDelete( + req: http.IncomingMessage, + res: http.ServerResponse, + device: { deviceId: string }, + ): void { + this.readBody(req, (body) => { + try { + const { domain, cookieName, path } = JSON.parse(body); + if (!domain || !cookieName || !path) { + this.json(res, 400, { error: "Missing domain, cookieName, or path" }); + return; + } + const deleted = this.cookieStore.delete(device.deviceId, domain, cookieName, path); + this.json(res, 200, { deleted }); + } catch { + this.json(res, 400, { error: "Invalid JSON" }); + } + }); + } + + // --- Agent Skill API --- + + private handleAgentTokenCreate( + req: http.IncomingMessage, + res: http.ServerResponse, + _device: { deviceId: string }, + ): void { + this.readBody(req, (body) => { + try { + const { name, encPub, allowedDomains } = JSON.parse(body); + if (!name || !encPub) { + this.json(res, 400, { error: "Missing name or encPub" }); + return; + } + const agent = this.agentRegistry.create(name, encPub, allowedDomains ?? []); + + // Automatically grant the creating device's access + this.agentRegistry.grantAccess(agent.id, _device.deviceId); + + // Also grant access to all paired devices + const paired = this.deviceRegistry.getPairedDevices(_device.deviceId); + for (const peerId of paired) { + this.agentRegistry.grantAccess(agent.id, peerId); + } + + this.json(res, 201, { id: agent.id, token: agent.token, name: agent.name }); + } catch { + this.json(res, 400, { error: "Invalid JSON" }); + } + }); + } + + private handleAgentGrant( + req: http.IncomingMessage, + res: http.ServerResponse, + device: { deviceId: string }, + ): void { + this.readBody(req, (body) => { + try { + const { agentId } = JSON.parse(body); + if (!agentId) { + this.json(res, 400, { error: "Missing agentId" }); + return; + } + this.agentRegistry.grantAccess(agentId, device.deviceId); + this.json(res, 200, { granted: true }); + } catch { + this.json(res, 400, { error: "Invalid JSON" }); + } + }); + } + + private handleAgentCookies(req: http.IncomingMessage, res: http.ServerResponse): void { + const token = this.extractBearerToken(req); + if (!token) { + this.json(res, 401, { error: "Missing Authorization header" }); + return; + } + const agent = this.agentRegistry.getByToken(token); + if (!agent) { + this.json(res, 401, { error: "Invalid agent token" }); + return; + } + + const url = new URL(req.url ?? "", `http://${req.headers.host}`); + const domain = url.searchParams.get("domain") ?? undefined; + + // Check domain access + if (domain && agent.allowedDomains.length > 0 && !agent.allowedDomains.includes(domain)) { + this.json(res, 403, { error: "Domain not in allowed list" }); + return; + } + + const deviceIds = this.agentRegistry.getAccessibleDevices(agent.id); + const blobs = this.cookieStore.getByDevices(deviceIds, domain); + + this.json(res, 200, { cookies: blobs, agentEncPub: agent.encPub }); } // --- WebSocket --- private handleConnection(ws: WebSocket): void { - // Send auth challenge const challenge = generateChallenge(); this.pendingAuths.set(ws, { challenge, createdAt: Date.now() }); ws.send(JSON.stringify({ type: "auth_challenge", challenge: challenge.toString("hex") })); - ws.on("message", (data: Buffer) => { - this.handleMessage(ws, data); - }); + ws.on("message", (data: Buffer) => this.handleMessage(ws, data)); + ws.on("close", () => this.handleDisconnect(ws)); + ws.on("error", () => this.handleDisconnect(ws)); - ws.on("close", () => { - this.handleDisconnect(ws); - }); - - ws.on("error", () => { - this.handleDisconnect(ws); - }); - - // Auth timeout — disconnect if not authenticated within 10s setTimeout(() => { if (this.pendingAuths.has(ws)) { ws.close(4000, "Auth timeout"); @@ -205,26 +454,28 @@ export class RelayServer { return; } - // Handle auth response if (msg.type === "auth_response") { this.handleAuthResponse(ws, msg); return; } - // All other messages require authentication + // Token-based auth for extensions + if (msg.type === "auth_token") { + this.handleTokenAuth(ws, msg); + return; + } + const deviceId = this.authenticatedDevices.get(ws); if (!deviceId) { ws.send(JSON.stringify({ type: "error", error: "Not authenticated" })); return; } - // Handle ping/pong if (msg.type === MESSAGE_TYPES.PING) { ws.send(JSON.stringify({ type: MESSAGE_TYPES.PONG })); return; } - // Handle relay messages if ( msg.type === MESSAGE_TYPES.COOKIE_SYNC || msg.type === MESSAGE_TYPES.COOKIE_DELETE || @@ -237,6 +488,32 @@ export class RelayServer { ws.send(JSON.stringify({ type: "error", error: "Unknown message type" })); } + private handleTokenAuth(ws: WebSocket, msg: Record): void { + const { token } = msg as { token: string }; + if (!token) { + ws.close(4002, "Missing token"); + return; + } + + const device = this.deviceRegistry.getByToken(token); + if (!device) { + ws.close(4003, "Invalid token"); + return; + } + + this.pendingAuths.delete(ws); + this.authenticatedDevices.set(ws, device.deviceId); + this.connections.register(device.deviceId, ws); + ws.send(JSON.stringify({ type: "auth_ok", deviceId: device.deviceId })); + + const interval = setInterval(() => { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING })); + } + }, PING_INTERVAL_MS); + this.pingIntervals.set(ws, interval); + } + private handleAuthResponse(ws: WebSocket, msg: Record): void { const pending = this.pendingAuths.get(ws); if (!pending) { @@ -259,14 +536,11 @@ export class RelayServer { return; } - // Authenticated this.pendingAuths.delete(ws); this.authenticatedDevices.set(ws, deviceId); this.connections.register(deviceId, ws); - ws.send(JSON.stringify({ type: "auth_ok", deviceId })); - // Start ping interval const interval = setInterval(() => { if (ws.readyState === 1) { ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING })); @@ -276,7 +550,6 @@ export class RelayServer { } private handleRelayMessage(ws: WebSocket, fromDeviceId: string, envelope: Envelope): void { - // Verify the 'from' matches the authenticated device if (envelope.from !== fromDeviceId) { ws.send(JSON.stringify({ type: "error", error: "Sender mismatch" })); return; @@ -299,17 +572,8 @@ export class RelayServer { return; } - // Route to recipient const delivered = this.connections.send(envelope.to, envelope); - - // Acknowledge to sender - ws.send( - JSON.stringify({ - type: MESSAGE_TYPES.ACK, - ref: envelope.nonce, - delivered, - }), - ); + ws.send(JSON.stringify({ type: MESSAGE_TYPES.ACK, ref: envelope.nonce, delivered })); } private handleDisconnect(ws: WebSocket): void { @@ -325,4 +589,20 @@ export class RelayServer { this.pingIntervals.delete(ws); } } + + // --- Helpers --- + + private json(res: http.ServerResponse, status: number, data: unknown): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); + } + + private readBody(req: http.IncomingMessage, cb: (body: string) => void): void { + let data = ""; + req.on("data", (chunk: Buffer) => { + data += chunk.toString(); + if (data.length > 64 * 1024) req.destroy(); + }); + req.on("end", () => cb(data)); + } } diff --git a/src/relay/store.ts b/src/relay/store.ts new file mode 100644 index 0000000..3db6992 --- /dev/null +++ b/src/relay/store.ts @@ -0,0 +1,103 @@ +import sodium from "sodium-native"; +import type { EncryptedCookieBlob } from "../protocol/spec.js"; +import { MAX_STORED_COOKIES_PER_DEVICE } from "../protocol/spec.js"; + +type CookieKey = string; // "domain|name|path" + +function blobKey(blob: Pick): CookieKey { + return `${blob.domain}|${blob.cookieName}|${blob.path}`; +} + +/** + * In-memory encrypted cookie storage. + * The server stores opaque ciphertext — it cannot read cookie values. + * Keyed by (deviceId, domain, cookieName, path). + */ +export class CookieBlobStore { + // deviceId -> (cookieKey -> blob) + private store = new Map>(); + + /** Upsert an encrypted cookie blob. LWW by lamportTs. */ + upsert(blob: Omit): EncryptedCookieBlob { + let deviceMap = this.store.get(blob.deviceId); + if (!deviceMap) { + deviceMap = new Map(); + this.store.set(blob.deviceId, deviceMap); + } + + const key = blobKey(blob); + const existing = deviceMap.get(key); + + if (existing && existing.lamportTs >= blob.lamportTs) { + return existing; // stale update + } + + const stored: EncryptedCookieBlob = { + ...blob, + id: existing?.id ?? generateId(), + updatedAt: new Date().toISOString(), + }; + + deviceMap.set(key, stored); + + // Enforce per-device limit + if (deviceMap.size > MAX_STORED_COOKIES_PER_DEVICE) { + // Remove oldest by updatedAt + let oldest: { key: CookieKey; time: string } | null = null; + for (const [k, v] of deviceMap) { + if (!oldest || v.updatedAt < oldest.time) { + oldest = { key: k, time: v.updatedAt }; + } + } + if (oldest) deviceMap.delete(oldest.key); + } + + return stored; + } + + /** Delete a cookie blob. */ + delete(deviceId: string, domain: string, cookieName: string, path: string): boolean { + const deviceMap = this.store.get(deviceId); + if (!deviceMap) return false; + return deviceMap.delete(blobKey({ domain, cookieName, path })); + } + + /** Get all blobs for a device, optionally filtered by domain. */ + getByDevice(deviceId: string, domain?: string): EncryptedCookieBlob[] { + const deviceMap = this.store.get(deviceId); + if (!deviceMap) return []; + const blobs = Array.from(deviceMap.values()); + if (domain) return blobs.filter((b) => b.domain === domain); + return blobs; + } + + /** Get blobs across all paired devices for a set of deviceIds. */ + getByDevices(deviceIds: string[], domain?: string): EncryptedCookieBlob[] { + const result: EncryptedCookieBlob[] = []; + for (const id of deviceIds) { + result.push(...this.getByDevice(id, domain)); + } + return result; + } + + /** Get all blobs updated after a given timestamp (for polling). */ + getUpdatedSince(deviceIds: string[], since: string): EncryptedCookieBlob[] { + const result: EncryptedCookieBlob[] = []; + for (const id of deviceIds) { + const deviceMap = this.store.get(id); + if (!deviceMap) continue; + for (const blob of deviceMap.values()) { + if (blob.updatedAt > since) { + result.push(blob); + } + } + } + return result; + } +} + +function generateId(): string { + const buf = Buffer.alloc(16); + sodium.randombytes_buf(buf); + return buf.toString("hex"); +} diff --git a/src/relay/tokens.ts b/src/relay/tokens.ts new file mode 100644 index 0000000..6e44bbb --- /dev/null +++ b/src/relay/tokens.ts @@ -0,0 +1,125 @@ +import sodium from "sodium-native"; +import type { DeviceInfo, AgentToken } from "../protocol/spec.js"; + +function generateToken(): string { + const buf = Buffer.alloc(32); + sodium.randombytes_buf(buf); + return "cb_" + buf.toString("hex"); +} + +function generateId(): string { + const buf = Buffer.alloc(16); + sodium.randombytes_buf(buf); + return buf.toString("hex"); +} + +/** + * Manages device registrations and API tokens. + */ +export class DeviceRegistry { + private devices = new Map(); // deviceId -> info + private tokenToDevice = new Map(); // token -> deviceId + // deviceId -> set of paired deviceIds + private pairings = new Map>(); + + register(deviceId: string, name: string, platform: string, encPub: string): DeviceInfo { + const existing = this.devices.get(deviceId); + if (existing) return existing; + + const token = generateToken(); + const info: DeviceInfo = { + deviceId, + name, + platform, + encPub, + token, + createdAt: new Date().toISOString(), + }; + this.devices.set(deviceId, info); + this.tokenToDevice.set(token, deviceId); + return info; + } + + getByToken(token: string): DeviceInfo | null { + const deviceId = this.tokenToDevice.get(token); + if (!deviceId) return null; + return this.devices.get(deviceId) ?? null; + } + + getById(deviceId: string): DeviceInfo | null { + return this.devices.get(deviceId) ?? null; + } + + /** Record that two devices are paired. */ + addPairing(deviceIdA: string, deviceIdB: string): void { + let setA = this.pairings.get(deviceIdA); + if (!setA) { setA = new Set(); this.pairings.set(deviceIdA, setA); } + setA.add(deviceIdB); + + let setB = this.pairings.get(deviceIdB); + if (!setB) { setB = new Set(); this.pairings.set(deviceIdB, setB); } + setB.add(deviceIdA); + } + + /** Get all deviceIds paired with a given device. */ + getPairedDevices(deviceId: string): string[] { + const set = this.pairings.get(deviceId); + return set ? Array.from(set) : []; + } + + /** Get all deviceIds in the same pairing group (including self). */ + getPairingGroup(deviceId: string): string[] { + const paired = this.getPairedDevices(deviceId); + return [deviceId, ...paired]; + } +} + +/** + * Manages agent API tokens for the Agent Skill API. + */ +export class AgentRegistry { + private agents = new Map(); // id -> agent + private tokenToAgent = new Map(); // token -> agentId + // agentId -> set of deviceIds that granted access + private agentDeviceAccess = new Map>(); + + create(name: string, encPub: string, allowedDomains: string[]): AgentToken { + const id = generateId(); + const token = generateToken(); + const agent: AgentToken = { + id, + name, + token, + encPub, + allowedDomains, + createdAt: new Date().toISOString(), + }; + this.agents.set(id, agent); + this.tokenToAgent.set(token, id); + return agent; + } + + getByToken(token: string): AgentToken | null { + const agentId = this.tokenToAgent.get(token); + if (!agentId) return null; + return this.agents.get(agentId) ?? null; + } + + /** Grant an agent access to a device's cookies. */ + grantAccess(agentId: string, deviceId: string): void { + let set = this.agentDeviceAccess.get(agentId); + if (!set) { set = new Set(); this.agentDeviceAccess.set(agentId, set); } + set.add(deviceId); + } + + /** Get deviceIds an agent has access to. */ + getAccessibleDevices(agentId: string): string[] { + const set = this.agentDeviceAccess.get(agentId); + return set ? Array.from(set) : []; + } + + revokeAccess(agentId: string, deviceId: string): void { + const set = this.agentDeviceAccess.get(agentId); + if (set) set.delete(deviceId); + } +} diff --git a/tests/integration.test.ts b/tests/integration.test.ts index b2d1cc1..51e5fa0 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -4,36 +4,50 @@ import { RelayServer, generateKeyPair, deviceIdFromKeys, - deriveSharedKey, sign, MESSAGE_TYPES, } from "../src/index.js"; import { buildEnvelope, openEnvelope } from "../src/sync/envelope.js"; import type { CookieSyncPayload, Envelope } from "../src/protocol/spec.js"; -// Helper: connect and authenticate a device -function connectDevice( +const BASE = (port: number) => `http://127.0.0.1:${port}`; + +// Helper: register a device and return its token +async function registerDevice( + port: number, + keys: ReturnType, + name: string, +): Promise<{ token: string; deviceId: string }> { + const deviceId = deviceIdFromKeys(keys); + const res = await fetch(`${BASE(port)}/api/devices/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + deviceId, + name, + platform: "test", + encPub: keys.encPub.toString("hex"), + }), + }); + const body = (await res.json()) as { token: string; deviceId: string }; + return { token: body.token, deviceId }; +} + +// Helper: connect via WebSocket with challenge-response auth +function connectDeviceWs( port: number, keys: ReturnType, ): Promise<{ ws: WebSocket; deviceId: string }> { return new Promise((resolve, reject) => { const deviceId = deviceIdFromKeys(keys); const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); - ws.on("error", reject); - ws.on("message", (data: Buffer) => { const msg = JSON.parse(data.toString()); if (msg.type === "auth_challenge") { const challenge = Buffer.from(msg.challenge, "hex"); const sig = sign(challenge, keys.signSec); - ws.send( - JSON.stringify({ - type: "auth_response", - deviceId, - sig: sig.toString("hex"), - }), - ); + ws.send(JSON.stringify({ type: "auth_response", deviceId, sig: sig.toString("hex") })); } else if (msg.type === "auth_ok") { resolve({ ws, deviceId }); } else if (msg.type === "error") { @@ -43,12 +57,27 @@ function connectDevice( }); } -// Helper: wait for next message of a given type -function waitForMessage( - ws: WebSocket, - type: string, - timeoutMs = 5000, -): Promise> { +// Helper: connect via WebSocket with token auth +function connectDeviceWsToken( + port: number, + token: string, +): Promise<{ ws: WebSocket; deviceId: string }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + ws.on("error", reject); + ws.on("message", (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "auth_challenge") { + // Use token auth instead + ws.send(JSON.stringify({ type: "auth_token", token })); + } else if (msg.type === "auth_ok") { + resolve({ ws, deviceId: msg.deviceId as string }); + } + }); + }); +} + +function waitForMessage(ws: WebSocket, type: string, timeoutMs = 5000): Promise> { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs); const handler = (data: Buffer) => { @@ -63,12 +92,12 @@ function waitForMessage( }); } -describe("Integration: relay server end-to-end", () => { +describe("Integration: CookieBridge server v2", () => { let server: RelayServer; let port: number; beforeAll(async () => { - server = new RelayServer({ port: 0 }); // random port + server = new RelayServer({ port: 0 }); await server.start(); port = server.port; }); @@ -77,18 +106,35 @@ describe("Integration: relay server end-to-end", () => { await server.stop(); }); - it("health check works", async () => { - const res = await fetch(`http://127.0.0.1:${port}/health`); + it("health check", async () => { + const res = await fetch(`${BASE(port)}/health`); const body = await res.json(); expect(body.status).toBe("ok"); }); + // --- Device Registration --- + + it("registers a device and returns a token", async () => { + const keys = generateKeyPair(); + const { token, deviceId } = await registerDevice(port, keys, "My Laptop"); + expect(token).toMatch(/^cb_/); + expect(deviceId).toBe(deviceIdFromKeys(keys)); + }); + + it("returns existing device on re-register", async () => { + const keys = generateKeyPair(); + const first = await registerDevice(port, keys, "Phone"); + const second = await registerDevice(port, keys, "Phone"); + expect(first.token).toBe(second.token); + }); + + // --- Pairing --- + it("pairing flow: create and accept", async () => { const alice = generateKeyPair(); const bob = generateKeyPair(); - // Alice initiates pairing - const createRes = await fetch(`http://127.0.0.1:${port}/pair`, { + const createRes = await fetch(`${BASE(port)}/api/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -98,10 +144,8 @@ describe("Integration: relay server end-to-end", () => { }); expect(createRes.status).toBe(201); const { pairingCode } = (await createRes.json()) as { pairingCode: string }; - expect(pairingCode).toHaveLength(6); - // Bob accepts with the code - const acceptRes = await fetch(`http://127.0.0.1:${port}/pair/accept`, { + const acceptRes = await fetch(`${BASE(port)}/api/pair/accept`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -112,36 +156,222 @@ describe("Integration: relay server end-to-end", () => { }); expect(acceptRes.status).toBe(200); const result = (await acceptRes.json()) as { - initiator: { deviceId: string; x25519PubKey: string }; - acceptor: { deviceId: string; x25519PubKey: string }; + initiator: { deviceId: string }; + acceptor: { deviceId: string }; }; expect(result.initiator.deviceId).toBe(deviceIdFromKeys(alice)); expect(result.acceptor.deviceId).toBe(deviceIdFromKeys(bob)); }); - it("rejects invalid pairing code", async () => { - const res = await fetch(`http://127.0.0.1:${port}/pair/accept`, { + // --- Cookie Storage (HTTP) --- + + it("pushes and pulls encrypted cookies via HTTP", async () => { + const keys = generateKeyPair(); + const { token } = await registerDevice(port, keys, "Test Device"); + + // Push + const pushRes = await fetch(`${BASE(port)}/api/cookies`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + cookies: [ + { + domain: "example.com", + cookieName: "session", + path: "/", + nonce: "aabbcc", + ciphertext: "ZW5jcnlwdGVk", // base64 of "encrypted" + lamportTs: 1, + }, + ], + }), + }); + expect(pushRes.status).toBe(200); + + // Pull + const pullRes = await fetch(`${BASE(port)}/api/cookies?domain=example.com`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(pullRes.status).toBe(200); + const pullBody = (await pullRes.json()) as { cookies: Array<{ domain: string; cookieName: string }> }; + expect(pullBody.cookies).toHaveLength(1); + expect(pullBody.cookies[0].domain).toBe("example.com"); + expect(pullBody.cookies[0].cookieName).toBe("session"); + }); + + it("paired devices can see each other's cookies", async () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const aliceReg = await registerDevice(port, alice, "Alice Laptop"); + const bobReg = await registerDevice(port, bob, "Bob Phone"); + + // Pair them + const createRes = await fetch(`${BASE(port)}/api/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - deviceId: "fake", - x25519PubKey: "fake", - pairingCode: "000000", + deviceId: aliceReg.deviceId, + x25519PubKey: alice.encPub.toString("hex"), }), }); - expect(res.status).toBe(404); + const { pairingCode } = (await createRes.json()) as { pairingCode: string }; + await fetch(`${BASE(port)}/api/pair/accept`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + deviceId: bobReg.deviceId, + x25519PubKey: bob.encPub.toString("hex"), + pairingCode, + }), + }); + + // Alice pushes a cookie + await fetch(`${BASE(port)}/api/cookies`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${aliceReg.token}`, + }, + body: JSON.stringify({ + cookies: [ + { domain: "shared.com", cookieName: "tok", path: "/", nonce: "112233", ciphertext: "c2hhcmVk", lamportTs: 1 }, + ], + }), + }); + + // Bob can pull it + const pullRes = await fetch(`${BASE(port)}/api/cookies?domain=shared.com`, { + headers: { Authorization: `Bearer ${bobReg.token}` }, + }); + const body = (await pullRes.json()) as { cookies: Array<{ cookieName: string }> }; + expect(body.cookies).toHaveLength(1); + expect(body.cookies[0].cookieName).toBe("tok"); }); - it("authenticates devices via WebSocket", async () => { - const alice = generateKeyPair(); - const { ws, deviceId } = await connectDevice(port, alice); - expect(deviceId).toBe(deviceIdFromKeys(alice)); + it("polls for cookie updates since timestamp", async () => { + const keys = generateKeyPair(); + const { token } = await registerDevice(port, keys, "Poller"); + const since = new Date(Date.now() - 1000).toISOString(); // 1s in the past + + // Push after timestamp + await fetch(`${BASE(port)}/api/cookies`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + cookies: [ + { domain: "poll.com", cookieName: "s", path: "/", nonce: "ff", ciphertext: "cA==", lamportTs: 1 }, + ], + }), + }); + + const pollRes = await fetch(`${BASE(port)}/api/cookies/updates?since=${since}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(pollRes.status).toBe(200); + const body = (await pollRes.json()) as { cookies: unknown[]; serverTime: string }; + expect(body.cookies.length).toBeGreaterThanOrEqual(1); + expect(body.serverTime).toBeTruthy(); + }); + + // --- Agent Skill API --- + + it("creates agent token and retrieves cookies", async () => { + const keys = generateKeyPair(); + const agentKeys = generateKeyPair(); + const { token: deviceToken } = await registerDevice(port, keys, "Agent Host"); + + // Push a cookie first + await fetch(`${BASE(port)}/api/cookies`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${deviceToken}`, + }, + body: JSON.stringify({ + cookies: [ + { domain: "agent-test.com", cookieName: "auth", path: "/", nonce: "aa", ciphertext: "dGVzdA==", lamportTs: 1 }, + ], + }), + }); + + // Create agent token + const agentRes = await fetch(`${BASE(port)}/api/agent/tokens`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${deviceToken}`, + }, + body: JSON.stringify({ + name: "My AI Assistant", + encPub: agentKeys.encPub.toString("hex"), + allowedDomains: ["agent-test.com"], + }), + }); + expect(agentRes.status).toBe(201); + const agentBody = (await agentRes.json()) as { id: string; token: string }; + expect(agentBody.token).toMatch(/^cb_/); + + // Agent retrieves cookies + const cookieRes = await fetch(`${BASE(port)}/api/agent/cookies?domain=agent-test.com`, { + headers: { Authorization: `Bearer ${agentBody.token}` }, + }); + expect(cookieRes.status).toBe(200); + const cookieBody = (await cookieRes.json()) as { cookies: Array<{ domain: string }> }; + expect(cookieBody.cookies).toHaveLength(1); + expect(cookieBody.cookies[0].domain).toBe("agent-test.com"); + }); + + it("agent domain restriction works", async () => { + const keys = generateKeyPair(); + const agentKeys = generateKeyPair(); + const { token: deviceToken } = await registerDevice(port, keys, "Restricted Host"); + + const agentRes = await fetch(`${BASE(port)}/api/agent/tokens`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${deviceToken}`, + }, + body: JSON.stringify({ + name: "Restricted Agent", + encPub: agentKeys.encPub.toString("hex"), + allowedDomains: ["allowed.com"], + }), + }); + const agentBody = (await agentRes.json()) as { token: string }; + + const res = await fetch(`${BASE(port)}/api/agent/cookies?domain=forbidden.com`, { + headers: { Authorization: `Bearer ${agentBody.token}` }, + }); + expect(res.status).toBe(403); + }); + + // --- WebSocket --- + + it("authenticates via challenge-response", async () => { + const keys = generateKeyPair(); + const { ws, deviceId } = await connectDeviceWs(port, keys); + expect(deviceId).toBe(deviceIdFromKeys(keys)); ws.close(); }); - it("rejects bad auth signatures", async () => { + it("authenticates via token", async () => { + const keys = generateKeyPair(); + const { token } = await registerDevice(port, keys, "WS Token"); + const { ws, deviceId } = await connectDeviceWsToken(port, token); + expect(deviceId).toBe(deviceIdFromKeys(keys)); + ws.close(); + }); + + it("rejects bad auth", async () => { const alice = generateKeyPair(); - const eve = generateKeyPair(); // wrong keys + const eve = generateKeyPair(); await expect( new Promise((resolve, reject) => { @@ -149,16 +379,9 @@ describe("Integration: relay server end-to-end", () => { ws.on("message", (data: Buffer) => { const msg = JSON.parse(data.toString()); if (msg.type === "auth_challenge") { - // Sign with wrong key const challenge = Buffer.from(msg.challenge, "hex"); const sig = sign(challenge, eve.signSec); - ws.send( - JSON.stringify({ - type: "auth_response", - deviceId: deviceIdFromKeys(alice), // claim to be alice - sig: sig.toString("hex"), - }), - ); + ws.send(JSON.stringify({ type: "auth_response", deviceId: deviceIdFromKeys(alice), sig: sig.toString("hex") })); } }); ws.on("close", (code: number) => { @@ -169,132 +392,78 @@ describe("Integration: relay server end-to-end", () => { ).rejects.toThrow("Auth failed"); }); - it("relays encrypted cookie sync between two devices", async () => { + it("relays encrypted cookie sync between two devices over WebSocket", async () => { const alice = generateKeyPair(); const bob = generateKeyPair(); - const aliceConn = await connectDevice(port, alice); - const bobConn = await connectDevice(port, bob); - - const aliceDeviceId = deviceIdFromKeys(alice); + const aliceConn = await connectDeviceWs(port, alice); + const bobConn = await connectDeviceWs(port, bob); const bobDeviceId = deviceIdFromKeys(bob); - // Alice sends a cookie sync to Bob const payload: CookieSyncPayload = { action: "set", cookies: [ - { - domain: "example.com", - name: "session", - value: "tok_abc123", - path: "/", - secure: true, - httpOnly: true, - sameSite: "lax", - expiresAt: null, - }, + { domain: "ws-test.com", name: "session", value: "tok_ws", path: "/", secure: true, httpOnly: true, sameSite: "lax", expiresAt: null }, ], lamportTs: 1, }; - const envelope = buildEnvelope( - MESSAGE_TYPES.COOKIE_SYNC, - payload, - alice, - bob.encPub, - bobDeviceId, - ); + const envelope = buildEnvelope(MESSAGE_TYPES.COOKIE_SYNC, payload, alice, bob.encPub, bobDeviceId); + const bobMsg = waitForMessage(bobConn.ws, MESSAGE_TYPES.COOKIE_SYNC); - // Bob listens for the message - const bobMessagePromise = waitForMessage(bobConn.ws, MESSAGE_TYPES.COOKIE_SYNC); - - // Alice sends aliceConn.ws.send(JSON.stringify(envelope)); - // Alice gets ACK const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK); expect(ack.delivered).toBe(true); - // Bob receives the encrypted envelope - const received = (await bobMessagePromise) as unknown as Envelope; - expect(received.from).toBe(aliceDeviceId); - expect(received.to).toBe(bobDeviceId); - - // Bob decrypts + const received = (await bobMsg) as unknown as Envelope; const decrypted = openEnvelope(received, bob, alice.encPub); - expect(decrypted.action).toBe("set"); - expect(decrypted.cookies).toHaveLength(1); - expect(decrypted.cookies[0].domain).toBe("example.com"); - expect(decrypted.cookies[0].value).toBe("tok_abc123"); + expect(decrypted.cookies[0].value).toBe("tok_ws"); aliceConn.ws.close(); bobConn.ws.close(); }); - it("queues messages for offline devices and delivers on reconnect", async () => { + it("queues messages for offline devices", async () => { const alice = generateKeyPair(); const bob = generateKeyPair(); const bobDeviceId = deviceIdFromKeys(bob); - // Alice connects, Bob is offline - const aliceConn = await connectDevice(port, alice); + const aliceConn = await connectDeviceWs(port, alice); const payload: CookieSyncPayload = { action: "set", cookies: [ - { - domain: "queued.com", - name: "token", - value: "queued_val", - path: "/", - secure: true, - httpOnly: false, - sameSite: "none", - expiresAt: null, - }, + { domain: "offline.com", name: "q", value: "queued", path: "/", secure: true, httpOnly: false, sameSite: "none", expiresAt: null }, ], lamportTs: 1, }; - const envelope = buildEnvelope( - MESSAGE_TYPES.COOKIE_SYNC, - payload, - alice, - bob.encPub, - bobDeviceId, - ); - - // Send while Bob is offline + const envelope = buildEnvelope(MESSAGE_TYPES.COOKIE_SYNC, payload, alice, bob.encPub, bobDeviceId); aliceConn.ws.send(JSON.stringify(envelope)); const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK); - expect(ack.delivered).toBe(false); // queued, not delivered + expect(ack.delivered).toBe(false); - // Bob comes online — should receive the queued message - const bobMessagePromise = new Promise((resolve) => { - const bobWs = new WebSocket(`ws://127.0.0.1:${port}/ws`); - bobWs.on("message", (data: Buffer) => { + // Bob comes online + const bobMsg = new Promise((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + ws.on("message", (data: Buffer) => { const msg = JSON.parse(data.toString()); if (msg.type === "auth_challenge") { const challenge = Buffer.from(msg.challenge, "hex"); const sig = sign(challenge, bob.signSec); - bobWs.send( - JSON.stringify({ - type: "auth_response", - deviceId: bobDeviceId, - sig: sig.toString("hex"), - }), - ); + ws.send(JSON.stringify({ type: "auth_response", deviceId: bobDeviceId, sig: sig.toString("hex") })); } else if (msg.type === MESSAGE_TYPES.COOKIE_SYNC) { resolve(msg as unknown as Envelope); - bobWs.close(); + ws.close(); } }); }); - const received = await bobMessagePromise; + const received = await bobMsg; const decrypted = openEnvelope(received, bob, alice.encPub); - expect(decrypted.cookies[0].value).toBe("queued_val"); + expect(decrypted.cookies[0].value).toBe("queued"); aliceConn.ws.close(); });