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:
@@ -1,5 +1,7 @@
|
|||||||
export { RelayServer } from "./relay/index.js";
|
export { RelayServer } from "./relay/index.js";
|
||||||
export type { RelayServerConfig } 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 {
|
export {
|
||||||
generateKeyPair,
|
generateKeyPair,
|
||||||
@@ -24,16 +26,22 @@ export {
|
|||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
MESSAGE_TYPES,
|
MESSAGE_TYPES,
|
||||||
MAX_OFFLINE_QUEUE,
|
MAX_OFFLINE_QUEUE,
|
||||||
|
MAX_STORED_COOKIES_PER_DEVICE,
|
||||||
PAIRING_CODE_LENGTH,
|
PAIRING_CODE_LENGTH,
|
||||||
PAIRING_TTL_MS,
|
PAIRING_TTL_MS,
|
||||||
|
POLL_INTERVAL_MS,
|
||||||
} from "./protocol/spec.js";
|
} from "./protocol/spec.js";
|
||||||
export type {
|
export type {
|
||||||
Envelope,
|
Envelope,
|
||||||
MessageType,
|
MessageType,
|
||||||
CookieEntry,
|
CookieEntry,
|
||||||
CookieSyncPayload,
|
CookieSyncPayload,
|
||||||
|
EncryptedCookieBlob,
|
||||||
|
DeviceRegisterRequest,
|
||||||
|
DeviceInfo,
|
||||||
PairingRequest,
|
PairingRequest,
|
||||||
PairingAccept,
|
PairingAccept,
|
||||||
PairingResult,
|
PairingResult,
|
||||||
DeviceInfo,
|
AgentTokenRequest,
|
||||||
|
AgentToken,
|
||||||
} from "./protocol/spec.js";
|
} from "./protocol/spec.js";
|
||||||
|
|||||||
@@ -1,54 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* CookieBridge Protocol Specification
|
* CookieBridge Protocol Specification v2
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Architecture (revised):
|
||||||
* Device A <--E2E encrypted--> Relay Server <--E2E encrypted--> Device B
|
* Browser Extension ──────▶ CookieBridge Server
|
||||||
* The relay never sees plaintext cookies. It forwards opaque encrypted blobs.
|
* (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:
|
* Device Identity:
|
||||||
* Each device generates an X25519 keypair for key exchange and an Ed25519
|
* Each device generates an X25519 keypair for key exchange and an Ed25519
|
||||||
* keypair for signing. The device is identified by its Ed25519 public key
|
* keypair for signing. The deviceId is the Ed25519 public key (hex).
|
||||||
* (the "deviceId").
|
*
|
||||||
|
* 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:
|
* Pairing Flow:
|
||||||
* 1. Device A generates a pairing code (random 6-digit + short-lived secret).
|
* 1. Device A creates a pairing session: POST /api/pair
|
||||||
* 2. Device A registers a pairing session with the relay (POST /pair).
|
* 2. Device B accepts with the code: POST /api/pair/accept
|
||||||
* 3. Device B enters the code (or scans QR) and POSTs to /pair/accept.
|
* 3. Server brokers the X25519 key exchange.
|
||||||
* 4. Relay brokers the X25519 public key exchange.
|
* 4. Both devices derive a shared secret locally.
|
||||||
* 5. Both devices derive a shared secret via X25519 ECDH.
|
|
||||||
* 6. Pairing session is deleted from relay. Devices store each other's keys locally.
|
|
||||||
*
|
*
|
||||||
* Sync Protocol:
|
* Cookie Storage:
|
||||||
* Messages are sent over WebSocket as JSON envelopes wrapping encrypted payloads.
|
* Devices push encrypted cookie blobs to the server.
|
||||||
*
|
* The server stores them keyed by (deviceId, domain, cookieName, path).
|
||||||
* Envelope: { type, from, to, nonce, payload, timestamp, sig }
|
* Other paired devices (or AI agents with access) can pull and decrypt.
|
||||||
* - 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.
|
|
||||||
*
|
*
|
||||||
* Conflict Resolution:
|
* Conflict Resolution:
|
||||||
* Last-writer-wins (LWW) per (domain, cookie-name) pair.
|
* Last-writer-wins (LWW) per (domain, cookie-name, path).
|
||||||
* Each cookie sync payload includes a logical timestamp (Lamport clock).
|
* Lamport clock timestamps. Ties broken by deviceId lexicographic order.
|
||||||
* Recipient applies the update only if the incoming timestamp > local timestamp
|
|
||||||
* for that key. Ties broken by deviceId lexicographic order.
|
|
||||||
*
|
*
|
||||||
* Encrypted Payload (after decryption):
|
* Agent Skill API:
|
||||||
* {
|
* AI agents authenticate with an agent token.
|
||||||
* action: "set" | "delete",
|
* GET /api/agent/cookies?domain=... → returns encrypted cookie blobs.
|
||||||
* cookies: [{ domain, name, value, path, secure, httpOnly, sameSite, expiresAt }],
|
* The agent must possess the decryption key (shared by the user).
|
||||||
* lamportTs: number
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// --- Message Types ---
|
// --- Message Types (WebSocket) ---
|
||||||
|
|
||||||
export const MESSAGE_TYPES = {
|
export const MESSAGE_TYPES = {
|
||||||
COOKIE_SYNC: "cookie_sync",
|
COOKIE_SYNC: "cookie_sync",
|
||||||
@@ -61,19 +53,19 @@ export const MESSAGE_TYPES = {
|
|||||||
|
|
||||||
export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES];
|
export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES];
|
||||||
|
|
||||||
// --- Wire Envelope ---
|
// --- Wire Envelope (WebSocket transport) ---
|
||||||
|
|
||||||
export interface Envelope {
|
export interface Envelope {
|
||||||
type: MessageType;
|
type: MessageType;
|
||||||
from: string; // deviceId (Ed25519 pubkey hex)
|
from: string; // deviceId (Ed25519 pubkey hex)
|
||||||
to: string; // recipient deviceId
|
to: string; // recipient deviceId or "server"
|
||||||
nonce: string; // 24-byte hex
|
nonce: string; // 24-byte hex
|
||||||
payload: string; // encrypted ciphertext, base64
|
payload: string; // encrypted ciphertext, base64
|
||||||
timestamp: string; // ISO-8601
|
timestamp: string; // ISO-8601
|
||||||
sig: string; // Ed25519 signature hex
|
sig: string; // Ed25519 signature hex
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Decrypted Payloads ---
|
// --- Cookie Types ---
|
||||||
|
|
||||||
export interface CookieEntry {
|
export interface CookieEntry {
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -92,12 +84,44 @@ export interface CookieSyncPayload {
|
|||||||
lamportTs: number;
|
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 ---
|
// --- Pairing ---
|
||||||
|
|
||||||
export interface PairingRequest {
|
export interface PairingRequest {
|
||||||
deviceId: string; // Ed25519 pubkey hex
|
deviceId: string;
|
||||||
x25519PubKey: string; // X25519 pubkey hex
|
x25519PubKey: string;
|
||||||
pairingCode: string; // 6-digit code
|
pairingCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PairingAccept {
|
export interface PairingAccept {
|
||||||
@@ -111,16 +135,24 @@ export interface PairingResult {
|
|||||||
peerX25519PubKey: string;
|
peerX25519PubKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Device Registration ---
|
// --- Agent Skill ---
|
||||||
|
|
||||||
export interface DeviceInfo {
|
export interface AgentTokenRequest {
|
||||||
deviceId: string; // Ed25519 pubkey hex
|
|
||||||
name: string;
|
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;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Relay Auth ---
|
// --- Relay Auth (WebSocket challenge-response) ---
|
||||||
|
|
||||||
export interface RelayAuthChallenge {
|
export interface RelayAuthChallenge {
|
||||||
challenge: string; // random bytes hex
|
challenge: string; // random bytes hex
|
||||||
@@ -134,10 +166,12 @@ export interface RelayAuthResponse {
|
|||||||
|
|
||||||
// --- Protocol Constants ---
|
// --- 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_OFFLINE_QUEUE = 1000;
|
||||||
|
export const MAX_STORED_COOKIES_PER_DEVICE = 10_000;
|
||||||
export const PAIRING_CODE_LENGTH = 6;
|
export const PAIRING_CODE_LENGTH = 6;
|
||||||
export const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
export const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
export const NONCE_BYTES = 24;
|
export const NONCE_BYTES = 24;
|
||||||
export const PING_INTERVAL_MS = 30_000;
|
export const PING_INTERVAL_MS = 30_000;
|
||||||
export const PONG_TIMEOUT_MS = 10_000;
|
export const PONG_TIMEOUT_MS = 10_000;
|
||||||
|
export const POLL_INTERVAL_MS = 5_000; // HTTP polling default
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export { RelayServer } from "./server.js";
|
export { RelayServer } from "./server.js";
|
||||||
export type { RelayServerConfig } from "./server.js";
|
export type { RelayServerConfig } from "./server.js";
|
||||||
export { ConnectionManager } from "./connections.js";
|
export { ConnectionManager } from "./connections.js";
|
||||||
|
export { CookieBlobStore } from "./store.js";
|
||||||
|
export { DeviceRegistry, AgentRegistry } from "./tokens.js";
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { ConnectionManager } from "./connections.js";
|
|||||||
import { generateChallenge, verifyAuthResponse } from "./auth.js";
|
import { generateChallenge, verifyAuthResponse } from "./auth.js";
|
||||||
import { verify, buildSignablePayload } from "../crypto/signing.js";
|
import { verify, buildSignablePayload } from "../crypto/signing.js";
|
||||||
import { PairingStore } from "../pairing/pairing.js";
|
import { PairingStore } from "../pairing/pairing.js";
|
||||||
|
import { CookieBlobStore } from "./store.js";
|
||||||
|
import { DeviceRegistry, AgentRegistry } from "./tokens.js";
|
||||||
import {
|
import {
|
||||||
type Envelope,
|
type Envelope,
|
||||||
type MessageType,
|
type EncryptedCookieBlob,
|
||||||
MESSAGE_TYPES,
|
MESSAGE_TYPES,
|
||||||
PING_INTERVAL_MS,
|
PING_INTERVAL_MS,
|
||||||
PONG_TIMEOUT_MS,
|
|
||||||
} from "../protocol/spec.js";
|
} from "../protocol/spec.js";
|
||||||
|
|
||||||
export interface RelayServerConfig {
|
export interface RelayServerConfig {
|
||||||
@@ -23,28 +24,42 @@ interface PendingAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CookieBridge Relay Server.
|
* CookieBridge Server (v2).
|
||||||
*
|
*
|
||||||
* HTTP endpoints:
|
* HTTP API:
|
||||||
* POST /pair — initiate a pairing session
|
* POST /api/devices/register — register a device, get API token
|
||||||
* POST /pair/accept — accept a pairing session
|
* POST /api/pair — initiate pairing
|
||||||
* GET /health — health check
|
* 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:
|
* WebSocket:
|
||||||
* /ws — authenticated device connection for message relay
|
* /ws — real-time sync (challenge-response auth or token auth)
|
||||||
*/
|
*/
|
||||||
export class RelayServer {
|
export class RelayServer {
|
||||||
private httpServer: http.Server;
|
private httpServer: http.Server;
|
||||||
private wss: WebSocketServer;
|
private wss: WebSocketServer;
|
||||||
private connections: ConnectionManager;
|
readonly connections: ConnectionManager;
|
||||||
private pairingStore: PairingStore;
|
readonly pairingStore: PairingStore;
|
||||||
|
readonly cookieStore: CookieBlobStore;
|
||||||
|
readonly deviceRegistry: DeviceRegistry;
|
||||||
|
readonly agentRegistry: AgentRegistry;
|
||||||
private pendingAuths = new Map<WebSocket, PendingAuth>();
|
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>>();
|
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
constructor(private config: RelayServerConfig) {
|
constructor(private config: RelayServerConfig) {
|
||||||
this.connections = new ConnectionManager();
|
this.connections = new ConnectionManager();
|
||||||
this.pairingStore = new PairingStore();
|
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.httpServer = http.createServer(this.handleHttp.bind(this));
|
||||||
this.wss = new WebSocketServer({ server: this.httpServer });
|
this.wss = new WebSocketServer({ server: this.httpServer });
|
||||||
@@ -78,44 +93,132 @@ export class RelayServer {
|
|||||||
return this.config.port;
|
return this.config.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HTTP ---
|
// --- HTTP routing ---
|
||||||
|
|
||||||
private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
|
private handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
if (req.method === "GET" && req.url === "/health") {
|
const url = req.url ?? "";
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
const method = req.method ?? "";
|
||||||
res.end(JSON.stringify({ status: "ok", connections: this.connections.connectedCount }));
|
|
||||||
|
// Health
|
||||||
|
if (method === "GET" && url === "/health") {
|
||||||
|
this.json(res, 200, { status: "ok", connections: this.connections.connectedCount });
|
||||||
return;
|
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);
|
this.handlePairCreate(req, res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (method === "POST" && url === "/api/pair/accept") {
|
||||||
if (req.method === "POST" && req.url === "/pair/accept") {
|
|
||||||
this.handlePairAccept(req, res);
|
this.handlePairAccept(req, res);
|
||||||
return;
|
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.writeHead(404);
|
||||||
res.end("Not found");
|
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 {
|
private handlePairCreate(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
this.readBody(req, (body) => {
|
this.readBody(req, (body) => {
|
||||||
try {
|
try {
|
||||||
const { deviceId, x25519PubKey } = JSON.parse(body);
|
const { deviceId, x25519PubKey } = JSON.parse(body);
|
||||||
if (!deviceId || !x25519PubKey) {
|
if (!deviceId || !x25519PubKey) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" });
|
this.json(res, 400, { error: "Missing deviceId or x25519PubKey" });
|
||||||
res.end(JSON.stringify({ error: "Missing deviceId or x25519PubKey" }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = this.pairingStore.create(deviceId, x25519PubKey);
|
const session = this.pairingStore.create(deviceId, x25519PubKey);
|
||||||
res.writeHead(201, { "Content-Type": "application/json" });
|
this.json(res, 201, { pairingCode: session.pairingCode, expiresAt: session.expiresAt });
|
||||||
res.end(JSON.stringify({ pairingCode: session.pairingCode, expiresAt: session.expiresAt }));
|
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" });
|
this.json(res, 400, { error: "Invalid JSON" });
|
||||||
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,69 +228,215 @@ export class RelayServer {
|
|||||||
try {
|
try {
|
||||||
const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body);
|
const { deviceId, x25519PubKey, pairingCode } = JSON.parse(body);
|
||||||
if (!deviceId || !x25519PubKey || !pairingCode) {
|
if (!deviceId || !x25519PubKey || !pairingCode) {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" });
|
this.json(res, 400, { error: "Missing required fields" });
|
||||||
res.end(JSON.stringify({ error: "Missing required fields" }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const session = this.pairingStore.consume(pairingCode);
|
const session = this.pairingStore.consume(pairingCode);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.writeHead(404, { "Content-Type": "application/json" });
|
this.json(res, 404, { error: "Invalid or expired pairing code" });
|
||||||
res.end(JSON.stringify({ error: "Invalid or expired pairing code" }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Return both peers' info
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
// Record the pairing in device registry
|
||||||
res.end(
|
this.deviceRegistry.addPairing(session.deviceId, deviceId);
|
||||||
JSON.stringify({
|
|
||||||
initiator: {
|
this.json(res, 200, {
|
||||||
deviceId: session.deviceId,
|
initiator: { deviceId: session.deviceId, x25519PubKey: session.x25519PubKey },
|
||||||
x25519PubKey: session.x25519PubKey,
|
acceptor: { deviceId, x25519PubKey },
|
||||||
},
|
});
|
||||||
acceptor: {
|
|
||||||
deviceId,
|
|
||||||
x25519PubKey,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" });
|
this.json(res, 400, { error: "Invalid JSON" });
|
||||||
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private readBody(req: http.IncomingMessage, cb: (body: string) => void): void {
|
// --- Cookie Storage (HTTP polling transport) ---
|
||||||
let data = "";
|
|
||||||
req.on("data", (chunk: Buffer) => {
|
private handleCookiePush(
|
||||||
data += chunk.toString();
|
req: http.IncomingMessage,
|
||||||
if (data.length > 64 * 1024) {
|
res: http.ServerResponse,
|
||||||
req.destroy();
|
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 ---
|
// --- WebSocket ---
|
||||||
|
|
||||||
private handleConnection(ws: WebSocket): void {
|
private handleConnection(ws: WebSocket): void {
|
||||||
// Send auth challenge
|
|
||||||
const challenge = generateChallenge();
|
const challenge = generateChallenge();
|
||||||
this.pendingAuths.set(ws, { challenge, createdAt: Date.now() });
|
this.pendingAuths.set(ws, { challenge, createdAt: Date.now() });
|
||||||
ws.send(JSON.stringify({ type: "auth_challenge", challenge: challenge.toString("hex") }));
|
ws.send(JSON.stringify({ type: "auth_challenge", challenge: challenge.toString("hex") }));
|
||||||
|
|
||||||
ws.on("message", (data: Buffer) => {
|
ws.on("message", (data: Buffer) => this.handleMessage(ws, data));
|
||||||
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(() => {
|
setTimeout(() => {
|
||||||
if (this.pendingAuths.has(ws)) {
|
if (this.pendingAuths.has(ws)) {
|
||||||
ws.close(4000, "Auth timeout");
|
ws.close(4000, "Auth timeout");
|
||||||
@@ -205,26 +454,28 @@ export class RelayServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle auth response
|
|
||||||
if (msg.type === "auth_response") {
|
if (msg.type === "auth_response") {
|
||||||
this.handleAuthResponse(ws, msg);
|
this.handleAuthResponse(ws, msg);
|
||||||
return;
|
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);
|
const deviceId = this.authenticatedDevices.get(ws);
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
ws.send(JSON.stringify({ type: "error", error: "Not authenticated" }));
|
ws.send(JSON.stringify({ type: "error", error: "Not authenticated" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle ping/pong
|
|
||||||
if (msg.type === MESSAGE_TYPES.PING) {
|
if (msg.type === MESSAGE_TYPES.PING) {
|
||||||
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PONG }));
|
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PONG }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle relay messages
|
|
||||||
if (
|
if (
|
||||||
msg.type === MESSAGE_TYPES.COOKIE_SYNC ||
|
msg.type === MESSAGE_TYPES.COOKIE_SYNC ||
|
||||||
msg.type === MESSAGE_TYPES.COOKIE_DELETE ||
|
msg.type === MESSAGE_TYPES.COOKIE_DELETE ||
|
||||||
@@ -237,6 +488,32 @@ export class RelayServer {
|
|||||||
ws.send(JSON.stringify({ type: "error", error: "Unknown message type" }));
|
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 {
|
private handleAuthResponse(ws: WebSocket, msg: Record<string, unknown>): void {
|
||||||
const pending = this.pendingAuths.get(ws);
|
const pending = this.pendingAuths.get(ws);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
@@ -259,14 +536,11 @@ export class RelayServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated
|
|
||||||
this.pendingAuths.delete(ws);
|
this.pendingAuths.delete(ws);
|
||||||
this.authenticatedDevices.set(ws, deviceId);
|
this.authenticatedDevices.set(ws, deviceId);
|
||||||
this.connections.register(deviceId, ws);
|
this.connections.register(deviceId, ws);
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: "auth_ok", deviceId }));
|
ws.send(JSON.stringify({ type: "auth_ok", deviceId }));
|
||||||
|
|
||||||
// Start ping interval
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (ws.readyState === 1) {
|
if (ws.readyState === 1) {
|
||||||
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING }));
|
ws.send(JSON.stringify({ type: MESSAGE_TYPES.PING }));
|
||||||
@@ -276,7 +550,6 @@ export class RelayServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleRelayMessage(ws: WebSocket, fromDeviceId: string, envelope: Envelope): void {
|
private handleRelayMessage(ws: WebSocket, fromDeviceId: string, envelope: Envelope): void {
|
||||||
// Verify the 'from' matches the authenticated device
|
|
||||||
if (envelope.from !== fromDeviceId) {
|
if (envelope.from !== fromDeviceId) {
|
||||||
ws.send(JSON.stringify({ type: "error", error: "Sender mismatch" }));
|
ws.send(JSON.stringify({ type: "error", error: "Sender mismatch" }));
|
||||||
return;
|
return;
|
||||||
@@ -299,17 +572,8 @@ export class RelayServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to recipient
|
|
||||||
const delivered = this.connections.send(envelope.to, envelope);
|
const delivered = this.connections.send(envelope.to, envelope);
|
||||||
|
ws.send(JSON.stringify({ type: MESSAGE_TYPES.ACK, ref: envelope.nonce, delivered }));
|
||||||
// Acknowledge to sender
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: MESSAGE_TYPES.ACK,
|
|
||||||
ref: envelope.nonce,
|
|
||||||
delivered,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDisconnect(ws: WebSocket): void {
|
private handleDisconnect(ws: WebSocket): void {
|
||||||
@@ -325,4 +589,20 @@ export class RelayServer {
|
|||||||
this.pingIntervals.delete(ws);
|
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
projects/cookiebridge/src/relay/store.ts
Normal file
103
projects/cookiebridge/src/relay/store.ts
Normal 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
projects/cookiebridge/src/relay/tokens.ts
Normal file
125
projects/cookiebridge/src/relay/tokens.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,36 +4,50 @@ import {
|
|||||||
RelayServer,
|
RelayServer,
|
||||||
generateKeyPair,
|
generateKeyPair,
|
||||||
deviceIdFromKeys,
|
deviceIdFromKeys,
|
||||||
deriveSharedKey,
|
|
||||||
sign,
|
sign,
|
||||||
MESSAGE_TYPES,
|
MESSAGE_TYPES,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
import { buildEnvelope, openEnvelope } from "../src/sync/envelope.js";
|
import { buildEnvelope, openEnvelope } from "../src/sync/envelope.js";
|
||||||
import type { CookieSyncPayload, Envelope } from "../src/protocol/spec.js";
|
import type { CookieSyncPayload, Envelope } from "../src/protocol/spec.js";
|
||||||
|
|
||||||
// Helper: connect and authenticate a device
|
const BASE = (port: number) => `http://127.0.0.1:${port}`;
|
||||||
function connectDevice(
|
|
||||||
|
// 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,
|
port: number,
|
||||||
keys: ReturnType<typeof generateKeyPair>,
|
keys: ReturnType<typeof generateKeyPair>,
|
||||||
): Promise<{ ws: WebSocket; deviceId: string }> {
|
): Promise<{ ws: WebSocket; deviceId: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const deviceId = deviceIdFromKeys(keys);
|
const deviceId = deviceIdFromKeys(keys);
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||||
|
|
||||||
ws.on("error", reject);
|
ws.on("error", reject);
|
||||||
|
|
||||||
ws.on("message", (data: Buffer) => {
|
ws.on("message", (data: Buffer) => {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
if (msg.type === "auth_challenge") {
|
if (msg.type === "auth_challenge") {
|
||||||
const challenge = Buffer.from(msg.challenge, "hex");
|
const challenge = Buffer.from(msg.challenge, "hex");
|
||||||
const sig = sign(challenge, keys.signSec);
|
const sig = sign(challenge, keys.signSec);
|
||||||
ws.send(
|
ws.send(JSON.stringify({ type: "auth_response", deviceId, sig: sig.toString("hex") }));
|
||||||
JSON.stringify({
|
|
||||||
type: "auth_response",
|
|
||||||
deviceId,
|
|
||||||
sig: sig.toString("hex"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (msg.type === "auth_ok") {
|
} else if (msg.type === "auth_ok") {
|
||||||
resolve({ ws, deviceId });
|
resolve({ ws, deviceId });
|
||||||
} else if (msg.type === "error") {
|
} else if (msg.type === "error") {
|
||||||
@@ -43,12 +57,27 @@ function connectDevice(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: wait for next message of a given type
|
// Helper: connect via WebSocket with token auth
|
||||||
function waitForMessage(
|
function connectDeviceWsToken(
|
||||||
ws: WebSocket,
|
port: number,
|
||||||
type: string,
|
token: string,
|
||||||
timeoutMs = 5000,
|
): Promise<{ ws: WebSocket; deviceId: string }> {
|
||||||
): Promise<Record<string, unknown>> {
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
|
||||||
const handler = (data: Buffer) => {
|
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 server: RelayServer;
|
||||||
let port: number;
|
let port: number;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
server = new RelayServer({ port: 0 }); // random port
|
server = new RelayServer({ port: 0 });
|
||||||
await server.start();
|
await server.start();
|
||||||
port = server.port;
|
port = server.port;
|
||||||
});
|
});
|
||||||
@@ -77,18 +106,35 @@ describe("Integration: relay server end-to-end", () => {
|
|||||||
await server.stop();
|
await server.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("health check works", async () => {
|
it("health check", async () => {
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
const res = await fetch(`${BASE(port)}/health`);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.status).toBe("ok");
|
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 () => {
|
it("pairing flow: create and accept", async () => {
|
||||||
const alice = generateKeyPair();
|
const alice = generateKeyPair();
|
||||||
const bob = generateKeyPair();
|
const bob = generateKeyPair();
|
||||||
|
|
||||||
// Alice initiates pairing
|
const createRes = await fetch(`${BASE(port)}/api/pair`, {
|
||||||
const createRes = await fetch(`http://127.0.0.1:${port}/pair`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -98,10 +144,8 @@ describe("Integration: relay server end-to-end", () => {
|
|||||||
});
|
});
|
||||||
expect(createRes.status).toBe(201);
|
expect(createRes.status).toBe(201);
|
||||||
const { pairingCode } = (await createRes.json()) as { pairingCode: string };
|
const { pairingCode } = (await createRes.json()) as { pairingCode: string };
|
||||||
expect(pairingCode).toHaveLength(6);
|
|
||||||
|
|
||||||
// Bob accepts with the code
|
const acceptRes = await fetch(`${BASE(port)}/api/pair/accept`, {
|
||||||
const acceptRes = await fetch(`http://127.0.0.1:${port}/pair/accept`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -112,36 +156,222 @@ describe("Integration: relay server end-to-end", () => {
|
|||||||
});
|
});
|
||||||
expect(acceptRes.status).toBe(200);
|
expect(acceptRes.status).toBe(200);
|
||||||
const result = (await acceptRes.json()) as {
|
const result = (await acceptRes.json()) as {
|
||||||
initiator: { deviceId: string; x25519PubKey: string };
|
initiator: { deviceId: string };
|
||||||
acceptor: { deviceId: string; x25519PubKey: string };
|
acceptor: { deviceId: string };
|
||||||
};
|
};
|
||||||
expect(result.initiator.deviceId).toBe(deviceIdFromKeys(alice));
|
expect(result.initiator.deviceId).toBe(deviceIdFromKeys(alice));
|
||||||
expect(result.acceptor.deviceId).toBe(deviceIdFromKeys(bob));
|
expect(result.acceptor.deviceId).toBe(deviceIdFromKeys(bob));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid pairing code", async () => {
|
// --- Cookie Storage (HTTP) ---
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/pair/accept`, {
|
|
||||||
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
deviceId: "fake",
|
deviceId: aliceReg.deviceId,
|
||||||
x25519PubKey: "fake",
|
x25519PubKey: alice.encPub.toString("hex"),
|
||||||
pairingCode: "000000",
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
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 () => {
|
it("polls for cookie updates since timestamp", async () => {
|
||||||
const alice = generateKeyPair();
|
const keys = generateKeyPair();
|
||||||
const { ws, deviceId } = await connectDevice(port, alice);
|
const { token } = await registerDevice(port, keys, "Poller");
|
||||||
expect(deviceId).toBe(deviceIdFromKeys(alice));
|
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();
|
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 alice = generateKeyPair();
|
||||||
const eve = generateKeyPair(); // wrong keys
|
const eve = generateKeyPair();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
@@ -149,16 +379,9 @@ describe("Integration: relay server end-to-end", () => {
|
|||||||
ws.on("message", (data: Buffer) => {
|
ws.on("message", (data: Buffer) => {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
if (msg.type === "auth_challenge") {
|
if (msg.type === "auth_challenge") {
|
||||||
// Sign with wrong key
|
|
||||||
const challenge = Buffer.from(msg.challenge, "hex");
|
const challenge = Buffer.from(msg.challenge, "hex");
|
||||||
const sig = sign(challenge, eve.signSec);
|
const sig = sign(challenge, eve.signSec);
|
||||||
ws.send(
|
ws.send(JSON.stringify({ type: "auth_response", deviceId: deviceIdFromKeys(alice), sig: sig.toString("hex") }));
|
||||||
JSON.stringify({
|
|
||||||
type: "auth_response",
|
|
||||||
deviceId: deviceIdFromKeys(alice), // claim to be alice
|
|
||||||
sig: sig.toString("hex"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ws.on("close", (code: number) => {
|
ws.on("close", (code: number) => {
|
||||||
@@ -169,132 +392,78 @@ describe("Integration: relay server end-to-end", () => {
|
|||||||
).rejects.toThrow("Auth failed");
|
).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 alice = generateKeyPair();
|
||||||
const bob = generateKeyPair();
|
const bob = generateKeyPair();
|
||||||
|
|
||||||
const aliceConn = await connectDevice(port, alice);
|
const aliceConn = await connectDeviceWs(port, alice);
|
||||||
const bobConn = await connectDevice(port, bob);
|
const bobConn = await connectDeviceWs(port, bob);
|
||||||
|
|
||||||
const aliceDeviceId = deviceIdFromKeys(alice);
|
|
||||||
const bobDeviceId = deviceIdFromKeys(bob);
|
const bobDeviceId = deviceIdFromKeys(bob);
|
||||||
|
|
||||||
// Alice sends a cookie sync to Bob
|
|
||||||
const payload: CookieSyncPayload = {
|
const payload: CookieSyncPayload = {
|
||||||
action: "set",
|
action: "set",
|
||||||
cookies: [
|
cookies: [
|
||||||
{
|
{ domain: "ws-test.com", name: "session", value: "tok_ws", path: "/", secure: true, httpOnly: true, sameSite: "lax", expiresAt: null },
|
||||||
domain: "example.com",
|
|
||||||
name: "session",
|
|
||||||
value: "tok_abc123",
|
|
||||||
path: "/",
|
|
||||||
secure: true,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
expiresAt: null,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
lamportTs: 1,
|
lamportTs: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const envelope = buildEnvelope(
|
const envelope = buildEnvelope(MESSAGE_TYPES.COOKIE_SYNC, payload, alice, bob.encPub, bobDeviceId);
|
||||||
MESSAGE_TYPES.COOKIE_SYNC,
|
const bobMsg = waitForMessage(bobConn.ws, MESSAGE_TYPES.COOKIE_SYNC);
|
||||||
payload,
|
|
||||||
alice,
|
|
||||||
bob.encPub,
|
|
||||||
bobDeviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bob listens for the message
|
|
||||||
const bobMessagePromise = waitForMessage(bobConn.ws, MESSAGE_TYPES.COOKIE_SYNC);
|
|
||||||
|
|
||||||
// Alice sends
|
|
||||||
aliceConn.ws.send(JSON.stringify(envelope));
|
aliceConn.ws.send(JSON.stringify(envelope));
|
||||||
|
|
||||||
// Alice gets ACK
|
|
||||||
const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK);
|
const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK);
|
||||||
expect(ack.delivered).toBe(true);
|
expect(ack.delivered).toBe(true);
|
||||||
|
|
||||||
// Bob receives the encrypted envelope
|
const received = (await bobMsg) as unknown as Envelope;
|
||||||
const received = (await bobMessagePromise) as unknown as Envelope;
|
|
||||||
expect(received.from).toBe(aliceDeviceId);
|
|
||||||
expect(received.to).toBe(bobDeviceId);
|
|
||||||
|
|
||||||
// Bob decrypts
|
|
||||||
const decrypted = openEnvelope(received, bob, alice.encPub);
|
const decrypted = openEnvelope(received, bob, alice.encPub);
|
||||||
expect(decrypted.action).toBe("set");
|
expect(decrypted.cookies[0].value).toBe("tok_ws");
|
||||||
expect(decrypted.cookies).toHaveLength(1);
|
|
||||||
expect(decrypted.cookies[0].domain).toBe("example.com");
|
|
||||||
expect(decrypted.cookies[0].value).toBe("tok_abc123");
|
|
||||||
|
|
||||||
aliceConn.ws.close();
|
aliceConn.ws.close();
|
||||||
bobConn.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 alice = generateKeyPair();
|
||||||
const bob = generateKeyPair();
|
const bob = generateKeyPair();
|
||||||
const bobDeviceId = deviceIdFromKeys(bob);
|
const bobDeviceId = deviceIdFromKeys(bob);
|
||||||
|
|
||||||
// Alice connects, Bob is offline
|
const aliceConn = await connectDeviceWs(port, alice);
|
||||||
const aliceConn = await connectDevice(port, alice);
|
|
||||||
|
|
||||||
const payload: CookieSyncPayload = {
|
const payload: CookieSyncPayload = {
|
||||||
action: "set",
|
action: "set",
|
||||||
cookies: [
|
cookies: [
|
||||||
{
|
{ domain: "offline.com", name: "q", value: "queued", path: "/", secure: true, httpOnly: false, sameSite: "none", expiresAt: null },
|
||||||
domain: "queued.com",
|
|
||||||
name: "token",
|
|
||||||
value: "queued_val",
|
|
||||||
path: "/",
|
|
||||||
secure: true,
|
|
||||||
httpOnly: false,
|
|
||||||
sameSite: "none",
|
|
||||||
expiresAt: null,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
lamportTs: 1,
|
lamportTs: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const envelope = buildEnvelope(
|
const envelope = buildEnvelope(MESSAGE_TYPES.COOKIE_SYNC, payload, alice, bob.encPub, bobDeviceId);
|
||||||
MESSAGE_TYPES.COOKIE_SYNC,
|
|
||||||
payload,
|
|
||||||
alice,
|
|
||||||
bob.encPub,
|
|
||||||
bobDeviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send while Bob is offline
|
|
||||||
aliceConn.ws.send(JSON.stringify(envelope));
|
aliceConn.ws.send(JSON.stringify(envelope));
|
||||||
|
|
||||||
const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK);
|
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
|
// Bob comes online
|
||||||
const bobMessagePromise = new Promise<Envelope>((resolve) => {
|
const bobMsg = new Promise<Envelope>((resolve) => {
|
||||||
const bobWs = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||||
bobWs.on("message", (data: Buffer) => {
|
ws.on("message", (data: Buffer) => {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString());
|
||||||
if (msg.type === "auth_challenge") {
|
if (msg.type === "auth_challenge") {
|
||||||
const challenge = Buffer.from(msg.challenge, "hex");
|
const challenge = Buffer.from(msg.challenge, "hex");
|
||||||
const sig = sign(challenge, bob.signSec);
|
const sig = sign(challenge, bob.signSec);
|
||||||
bobWs.send(
|
ws.send(JSON.stringify({ type: "auth_response", deviceId: bobDeviceId, sig: sig.toString("hex") }));
|
||||||
JSON.stringify({
|
|
||||||
type: "auth_response",
|
|
||||||
deviceId: bobDeviceId,
|
|
||||||
sig: sig.toString("hex"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (msg.type === MESSAGE_TYPES.COOKIE_SYNC) {
|
} else if (msg.type === MESSAGE_TYPES.COOKIE_SYNC) {
|
||||||
resolve(msg as unknown as Envelope);
|
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);
|
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();
|
aliceConn.ws.close();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user