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);
}
}

View File

@@ -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<typeof generateKeyPair>,
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<typeof generateKeyPair>,
): 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<Record<string, unknown>> {
// 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<Record<string, unknown>> {
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<void>((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<Envelope>((resolve) => {
const bobWs = new WebSocket(`ws://127.0.0.1:${port}/ws`);
bobWs.on("message", (data: Buffer) => {
// Bob comes online
const bobMsg = new Promise<Envelope>((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();
});