feat: rework CookieBridge to v2 architecture per CEO feedback

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 <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 15:26:24 +08:00
parent 4326276505
commit 1bd7a34de8
7 changed files with 978 additions and 257 deletions

View File

@@ -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";

View File

@@ -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

View File

@@ -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";

View File

@@ -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/registerregister 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<WebSocket, PendingAuth>();
private authenticatedDevices = new Map<WebSocket, string>(); // ws -> deviceId
private authenticatedDevices = new Map<WebSocket, string>();
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
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<Omit<EncryptedCookieBlob, "id" | "updatedAt" | "deviceId">>;
};
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<string, unknown>): 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<string, unknown>): 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));
}
}

103
src/relay/store.ts Normal file
View File

@@ -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<EncryptedCookieBlob, "domain" | "cookieName" | "path">): 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<string, Map<CookieKey, EncryptedCookieBlob>>();
/** Upsert an encrypted cookie blob. LWW by lamportTs. */
upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): 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");
}

125
src/relay/tokens.ts Normal file
View File

@@ -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<string, DeviceInfo>(); // deviceId -> info
private tokenToDevice = new Map<string, string>(); // token -> deviceId
// deviceId -> set of paired deviceIds
private pairings = new Map<string, Set<string>>();
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<string, AgentToken>(); // id -> agent
private tokenToAgent = new Map<string, string>(); // token -> agentId
// agentId -> set of deviceIds that granted access
private agentDeviceAccess = new Map<string, Set<string>>();
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);
}
}