feat: implement CookieBridge M1 — core protocol & relay server

- Protocol spec: encrypted envelope format, device identity (Ed25519 + X25519),
  LWW conflict resolution with Lamport clocks
- E2E encryption: XChaCha20-Poly1305 via sodium-native, X25519 key exchange
- WebSocket relay server: stateless message forwarding, device auth via
  challenge-response, offline message queuing, ping/pong keepalive
- Device pairing: time-limited pairing codes, key exchange broker via HTTP
- Sync protocol: envelope builder/opener, conflict-resolving cookie store
- 31 tests passing (crypto, pairing, conflict resolution, full integration)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 14:56:01 +08:00
commit 4326276505
24 changed files with 3847 additions and 0 deletions

21
src/cli.ts Normal file
View File

@@ -0,0 +1,21 @@
import { RelayServer } from "./relay/index.js";
const port = parseInt(process.env.PORT ?? "8080", 10);
const host = process.env.HOST ?? "0.0.0.0";
const server = new RelayServer({ port, host });
server.start().then(() => {
console.log(`CookieBridge relay server listening on ${host}:${port}`);
});
process.on("SIGINT", async () => {
console.log("\nShutting down...");
await server.stop();
process.exit(0);
});
process.on("SIGTERM", async () => {
await server.stop();
process.exit(0);
});

68
src/crypto/encryption.ts Normal file
View File

@@ -0,0 +1,68 @@
import sodium from "sodium-native";
/**
* Derive a shared secret from our X25519 secret key and peer's X25519 public key.
* Uses crypto_scalarmult (raw X25519 ECDH) then hashes with crypto_generichash
* to produce a 32-byte symmetric key.
*/
export function deriveSharedKey(
ourEncSec: Buffer,
peerEncPub: Buffer,
): Buffer {
const raw = Buffer.alloc(sodium.crypto_scalarmult_BYTES);
sodium.crypto_scalarmult(raw, ourEncSec, peerEncPub);
// Hash the raw shared secret to get a uniform key
const sharedKey = Buffer.alloc(32);
sodium.crypto_generichash(sharedKey, raw);
return sharedKey;
}
/**
* Encrypt plaintext using XChaCha20-Poly1305 with the shared key.
* Returns { nonce, ciphertext } where both are Buffers.
*/
export function encrypt(
plaintext: Buffer,
sharedKey: Buffer,
): { nonce: Buffer; ciphertext: Buffer } {
const nonce = Buffer.alloc(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
sodium.randombytes_buf(nonce);
const ciphertext = Buffer.alloc(
plaintext.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES,
);
sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
ciphertext,
plaintext,
null, // no additional data
null, // unused nsec
nonce,
sharedKey,
);
return { nonce, ciphertext };
}
/**
* Decrypt ciphertext using XChaCha20-Poly1305 with the shared key.
* Returns the plaintext Buffer, or throws on authentication failure.
*/
export function decrypt(
ciphertext: Buffer,
nonce: Buffer,
sharedKey: Buffer,
): Buffer {
const plaintext = Buffer.alloc(
ciphertext.length - sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES,
);
sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
plaintext,
null, // unused nsec
ciphertext,
null, // no additional data
nonce,
sharedKey,
);
return plaintext;
}

11
src/crypto/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export {
generateKeyPair,
deviceIdFromKeys,
serializeKeyPair,
deserializeKeyPair,
} from "./keys.js";
export type { DeviceKeyPair, SerializedKeyPair } from "./keys.js";
export { deriveSharedKey, encrypt, decrypt } from "./encryption.js";
export { sign, verify, buildSignablePayload } from "./signing.js";

55
src/crypto/keys.ts Normal file
View File

@@ -0,0 +1,55 @@
import sodium from "sodium-native";
export interface DeviceKeyPair {
// Ed25519 for signing/identity
signPub: Buffer;
signSec: Buffer;
// X25519 for key exchange
encPub: Buffer;
encSec: Buffer;
}
export interface SerializedKeyPair {
signPub: string; // hex
signSec: string; // hex
encPub: string; // hex
encSec: string; // hex
}
/** Generate a new device keypair (Ed25519 + X25519). */
export function generateKeyPair(): DeviceKeyPair {
const signPub = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES);
const signSec = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES);
sodium.crypto_sign_keypair(signPub, signSec);
const encPub = Buffer.alloc(sodium.crypto_box_PUBLICKEYBYTES);
const encSec = Buffer.alloc(sodium.crypto_box_SECRETKEYBYTES);
sodium.crypto_box_keypair(encPub, encSec);
return { signPub, signSec, encPub, encSec };
}
/** Derive deviceId from signing public key (hex string). */
export function deviceIdFromKeys(keys: DeviceKeyPair): string {
return keys.signPub.toString("hex");
}
/** Serialize keypair for storage. */
export function serializeKeyPair(keys: DeviceKeyPair): SerializedKeyPair {
return {
signPub: keys.signPub.toString("hex"),
signSec: keys.signSec.toString("hex"),
encPub: keys.encPub.toString("hex"),
encSec: keys.encSec.toString("hex"),
};
}
/** Deserialize keypair from storage. */
export function deserializeKeyPair(data: SerializedKeyPair): DeviceKeyPair {
return {
signPub: Buffer.from(data.signPub, "hex"),
signSec: Buffer.from(data.signSec, "hex"),
encPub: Buffer.from(data.encPub, "hex"),
encSec: Buffer.from(data.encSec, "hex"),
};
}

39
src/crypto/signing.ts Normal file
View File

@@ -0,0 +1,39 @@
import sodium from "sodium-native";
/** Sign a message with Ed25519 secret key. Returns the signature (64 bytes). */
export function sign(message: Buffer, signSec: Buffer): Buffer {
const sig = Buffer.alloc(sodium.crypto_sign_BYTES);
sodium.crypto_sign_detached(sig, message, signSec);
return sig;
}
/** Verify an Ed25519 signature. Returns true if valid. */
export function verify(
message: Buffer,
sig: Buffer,
signPub: Buffer,
): boolean {
return sodium.crypto_sign_verify_detached(sig, message, signPub);
}
/**
* Build the signable payload from envelope fields.
* Concatenates: type + from + to + nonce + payload + timestamp
*/
export function buildSignablePayload(fields: {
type: string;
from: string;
to: string;
nonce: string;
payload: string;
timestamp: string;
}): Buffer {
return Buffer.from(
fields.type +
fields.from +
fields.to +
fields.nonce +
fields.payload +
fields.timestamp,
);
}

39
src/index.ts Normal file
View File

@@ -0,0 +1,39 @@
export { RelayServer } from "./relay/index.js";
export type { RelayServerConfig } from "./relay/index.js";
export {
generateKeyPair,
deviceIdFromKeys,
serializeKeyPair,
deserializeKeyPair,
deriveSharedKey,
encrypt,
decrypt,
sign,
verify,
buildSignablePayload,
} from "./crypto/index.js";
export type { DeviceKeyPair, SerializedKeyPair } from "./crypto/index.js";
export { PairingStore, generatePairingCode } from "./pairing/index.js";
export type { PairingSession } from "./pairing/index.js";
export { buildEnvelope, openEnvelope, CookieStore } from "./sync/index.js";
export {
PROTOCOL_VERSION,
MESSAGE_TYPES,
MAX_OFFLINE_QUEUE,
PAIRING_CODE_LENGTH,
PAIRING_TTL_MS,
} from "./protocol/spec.js";
export type {
Envelope,
MessageType,
CookieEntry,
CookieSyncPayload,
PairingRequest,
PairingAccept,
PairingResult,
DeviceInfo,
} from "./protocol/spec.js";

2
src/pairing/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { PairingStore, generatePairingCode } from "./pairing.js";
export type { PairingSession } from "./pairing.js";

70
src/pairing/pairing.ts Normal file
View File

@@ -0,0 +1,70 @@
import sodium from "sodium-native";
import { PAIRING_CODE_LENGTH, PAIRING_TTL_MS } from "../protocol/spec.js";
export interface PairingSession {
pairingCode: string;
deviceId: string;
x25519PubKey: string;
createdAt: number;
expiresAt: number;
}
/** Generate a random numeric pairing code. */
export function generatePairingCode(): string {
const buf = Buffer.alloc(4);
sodium.randombytes_buf(buf);
const num = buf.readUInt32BE(0) % Math.pow(10, PAIRING_CODE_LENGTH);
return num.toString().padStart(PAIRING_CODE_LENGTH, "0");
}
/**
* In-memory pairing session store.
* In production this could be backed by Redis with TTL.
*/
export class PairingStore {
private sessions = new Map<string, PairingSession>();
create(deviceId: string, x25519PubKey: string): PairingSession {
this.pruneExpired();
const code = generatePairingCode();
const now = Date.now();
const session: PairingSession = {
pairingCode: code,
deviceId,
x25519PubKey,
createdAt: now,
expiresAt: now + PAIRING_TTL_MS,
};
this.sessions.set(code, session);
return session;
}
/** Look up a session by code. Returns null if expired or not found. */
find(pairingCode: string): PairingSession | null {
const session = this.sessions.get(pairingCode);
if (!session) return null;
if (Date.now() > session.expiresAt) {
this.sessions.delete(pairingCode);
return null;
}
return session;
}
/** Remove a session after successful pairing. */
consume(pairingCode: string): PairingSession | null {
const session = this.find(pairingCode);
if (session) {
this.sessions.delete(pairingCode);
}
return session;
}
private pruneExpired(): void {
const now = Date.now();
for (const [code, session] of this.sessions) {
if (now > session.expiresAt) {
this.sessions.delete(code);
}
}
}
}

143
src/protocol/spec.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* CookieBridge Protocol Specification
*
* Architecture:
* Device A <--E2E encrypted--> Relay Server <--E2E encrypted--> Device B
* The relay never sees plaintext cookies. It forwards opaque encrypted blobs.
*
* 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").
*
* 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.
*
* 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.
*
* 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.
*
* Encrypted Payload (after decryption):
* {
* action: "set" | "delete",
* cookies: [{ domain, name, value, path, secure, httpOnly, sameSite, expiresAt }],
* lamportTs: number
* }
*/
// --- Message Types ---
export const MESSAGE_TYPES = {
COOKIE_SYNC: "cookie_sync",
COOKIE_DELETE: "cookie_delete",
ACK: "ack",
PING: "ping",
PONG: "pong",
ERROR: "error",
} as const;
export type MessageType = (typeof MESSAGE_TYPES)[keyof typeof MESSAGE_TYPES];
// --- Wire Envelope ---
export interface Envelope {
type: MessageType;
from: string; // deviceId (Ed25519 pubkey hex)
to: string; // recipient deviceId
nonce: string; // 24-byte hex
payload: string; // encrypted ciphertext, base64
timestamp: string; // ISO-8601
sig: string; // Ed25519 signature hex
}
// --- Decrypted Payloads ---
export interface CookieEntry {
domain: string;
name: string;
value: string;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: "strict" | "lax" | "none";
expiresAt: string | null; // ISO-8601 or null for session cookies
}
export interface CookieSyncPayload {
action: "set" | "delete";
cookies: CookieEntry[];
lamportTs: number;
}
// --- Pairing ---
export interface PairingRequest {
deviceId: string; // Ed25519 pubkey hex
x25519PubKey: string; // X25519 pubkey hex
pairingCode: string; // 6-digit code
}
export interface PairingAccept {
deviceId: string;
x25519PubKey: string;
pairingCode: string;
}
export interface PairingResult {
peerDeviceId: string;
peerX25519PubKey: string;
}
// --- Device Registration ---
export interface DeviceInfo {
deviceId: string; // Ed25519 pubkey hex
name: string;
platform: string;
createdAt: string;
}
// --- Relay Auth ---
export interface RelayAuthChallenge {
challenge: string; // random bytes hex
}
export interface RelayAuthResponse {
deviceId: string;
challenge: string;
sig: string; // Ed25519 signature of challenge
}
// --- Protocol Constants ---
export const PROTOCOL_VERSION = "1.0.0";
export const MAX_OFFLINE_QUEUE = 1000;
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;

23
src/relay/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
import sodium from "sodium-native";
import { verify } from "../crypto/signing.js";
/**
* Generate a random authentication challenge.
*/
export function generateChallenge(): Buffer {
const challenge = Buffer.alloc(32);
sodium.randombytes_buf(challenge);
return challenge;
}
/**
* Verify a device's authentication response.
* The device must sign the challenge with its Ed25519 key.
*/
export function verifyAuthResponse(
challenge: Buffer,
sig: Buffer,
deviceSignPub: Buffer,
): boolean {
return verify(challenge, sig, deviceSignPub);
}

93
src/relay/connections.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { WebSocket } from "ws";
import type { Envelope } from "../protocol/spec.js";
import { MAX_OFFLINE_QUEUE } from "../protocol/spec.js";
export interface ConnectedDevice {
deviceId: string;
ws: WebSocket;
authenticatedAt: number;
}
/**
* Manages WebSocket connections and offline message queues.
*/
export class ConnectionManager {
private connections = new Map<string, ConnectedDevice>();
private offlineQueues = new Map<string, Envelope[]>();
/** Register an authenticated device connection. */
register(deviceId: string, ws: WebSocket): void {
// Close any existing connection for this device
const existing = this.connections.get(deviceId);
if (existing && existing.ws !== ws) {
existing.ws.close(4001, "Replaced by new connection");
}
this.connections.set(deviceId, {
deviceId,
ws,
authenticatedAt: Date.now(),
});
// Flush any queued messages
this.flushQueue(deviceId);
}
/** Remove a device connection. */
remove(deviceId: string, ws: WebSocket): void {
const conn = this.connections.get(deviceId);
// Only remove if it's the same WebSocket (not a newer replacement)
if (conn && conn.ws === ws) {
this.connections.delete(deviceId);
}
}
/** Send an envelope to a device. Queues if offline. */
send(to: string, envelope: Envelope): boolean {
const conn = this.connections.get(to);
if (conn && conn.ws.readyState === 1 /* OPEN */) {
conn.ws.send(JSON.stringify(envelope));
return true;
}
// Queue for offline delivery
this.enqueue(to, envelope);
return false;
}
/** Check if a device is currently connected. */
isOnline(deviceId: string): boolean {
const conn = this.connections.get(deviceId);
return conn !== undefined && conn.ws.readyState === 1;
}
/** Get count of connected devices. */
get connectedCount(): number {
return this.connections.size;
}
private enqueue(deviceId: string, envelope: Envelope): void {
let queue = this.offlineQueues.get(deviceId);
if (!queue) {
queue = [];
this.offlineQueues.set(deviceId, queue);
}
if (queue.length >= MAX_OFFLINE_QUEUE) {
queue.shift(); // Drop oldest
}
queue.push(envelope);
}
private flushQueue(deviceId: string): void {
const queue = this.offlineQueues.get(deviceId);
if (!queue || queue.length === 0) return;
const conn = this.connections.get(deviceId);
if (!conn || conn.ws.readyState !== 1) return;
for (const envelope of queue) {
conn.ws.send(JSON.stringify(envelope));
}
this.offlineQueues.delete(deviceId);
}
}

3
src/relay/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { RelayServer } from "./server.js";
export type { RelayServerConfig } from "./server.js";
export { ConnectionManager } from "./connections.js";

328
src/relay/server.ts Normal file
View File

@@ -0,0 +1,328 @@
import { WebSocketServer, WebSocket } from "ws";
import http from "node:http";
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 {
type Envelope,
type MessageType,
MESSAGE_TYPES,
PING_INTERVAL_MS,
PONG_TIMEOUT_MS,
} from "../protocol/spec.js";
export interface RelayServerConfig {
port: number;
host?: string;
}
interface PendingAuth {
challenge: Buffer;
createdAt: number;
}
/**
* CookieBridge Relay Server.
*
* HTTP endpoints:
* POST /pair — initiate a pairing session
* POST /pair/accept — accept a pairing session
* GET /health — health check
*
* WebSocket:
* /ws — authenticated device connection for message relay
*/
export class RelayServer {
private httpServer: http.Server;
private wss: WebSocketServer;
private connections: ConnectionManager;
private pairingStore: PairingStore;
private pendingAuths = new Map<WebSocket, PendingAuth>();
private authenticatedDevices = new Map<WebSocket, string>(); // ws -> deviceId
private pingIntervals = new Map<WebSocket, ReturnType<typeof setInterval>>();
constructor(private config: RelayServerConfig) {
this.connections = new ConnectionManager();
this.pairingStore = new PairingStore();
this.httpServer = http.createServer(this.handleHttp.bind(this));
this.wss = new WebSocketServer({ server: this.httpServer });
this.wss.on("connection", this.handleConnection.bind(this));
}
start(): Promise<void> {
return new Promise((resolve) => {
this.httpServer.listen(
this.config.port,
this.config.host ?? "0.0.0.0",
() => resolve(),
);
});
}
stop(): Promise<void> {
return new Promise((resolve) => {
for (const interval of this.pingIntervals.values()) {
clearInterval(interval);
}
this.wss.close(() => {
this.httpServer.close(() => resolve());
});
});
}
get port(): number {
const addr = this.httpServer.address();
if (addr && typeof addr === "object") return addr.port;
return this.config.port;
}
// --- HTTP ---
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 }));
return;
}
if (req.method === "POST" && req.url === "/pair") {
this.handlePairCreate(req, res);
return;
}
if (req.method === "POST" && req.url === "/pair/accept") {
this.handlePairAccept(req, res);
return;
}
res.writeHead(404);
res.end("Not found");
}
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" }));
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 }));
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON" }));
}
});
}
private handlePairAccept(req: http.IncomingMessage, res: http.ServerResponse): void {
this.readBody(req, (body) => {
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" }));
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" }));
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,
},
}),
);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ 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();
}
});
req.on("end", () => cb(data));
}
// --- 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("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");
this.pendingAuths.delete(ws);
}
}, 10_000);
}
private handleMessage(ws: WebSocket, data: Buffer): void {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(data.toString());
} catch {
ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
return;
}
// Handle auth response
if (msg.type === "auth_response") {
this.handleAuthResponse(ws, msg);
return;
}
// All other messages require authentication
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 ||
msg.type === MESSAGE_TYPES.ACK
) {
this.handleRelayMessage(ws, deviceId, msg as unknown as Envelope);
return;
}
ws.send(JSON.stringify({ type: "error", error: "Unknown message type" }));
}
private handleAuthResponse(ws: WebSocket, msg: Record<string, unknown>): void {
const pending = this.pendingAuths.get(ws);
if (!pending) {
ws.send(JSON.stringify({ type: "error", error: "No pending auth challenge" }));
return;
}
const { deviceId, sig } = msg as { deviceId: string; sig: string };
if (!deviceId || !sig) {
ws.close(4002, "Invalid auth response");
return;
}
const sigBuf = Buffer.from(sig, "hex");
const pubBuf = Buffer.from(deviceId, "hex");
if (!verifyAuthResponse(pending.challenge, sigBuf, pubBuf)) {
ws.close(4003, "Auth failed");
this.pendingAuths.delete(ws);
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 }));
}
}, PING_INTERVAL_MS);
this.pingIntervals.set(ws, interval);
}
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;
}
// Verify signature
const signable = buildSignablePayload({
type: envelope.type,
from: envelope.from,
to: envelope.to,
nonce: envelope.nonce,
payload: envelope.payload,
timestamp: envelope.timestamp,
});
const sigBuf = Buffer.from(envelope.sig, "hex");
const pubBuf = Buffer.from(fromDeviceId, "hex");
if (!verify(signable, sigBuf, pubBuf)) {
ws.send(JSON.stringify({ type: "error", error: "Invalid signature" }));
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,
}),
);
}
private handleDisconnect(ws: WebSocket): void {
const deviceId = this.authenticatedDevices.get(ws);
if (deviceId) {
this.connections.remove(deviceId, ws);
this.authenticatedDevices.delete(ws);
}
this.pendingAuths.delete(ws);
const interval = this.pingIntervals.get(ws);
if (interval) {
clearInterval(interval);
this.pingIntervals.delete(ws);
}
}
}

97
src/sync/conflict.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { CookieEntry, CookieSyncPayload } from "../protocol/spec.js";
type CookieKey = string; // "domain|name|path"
interface TrackedCookie {
entry: CookieEntry;
lamportTs: number;
sourceDeviceId: string;
}
function cookieKey(entry: CookieEntry): CookieKey {
return `${entry.domain}|${entry.name}|${entry.path}`;
}
/**
* Last-Writer-Wins cookie store with Lamport clock conflict resolution.
* Ties broken by deviceId lexicographic order.
*/
export class CookieStore {
private cookies = new Map<CookieKey, TrackedCookie>();
private lamportClock = 0;
/** Get current Lamport timestamp. */
get currentTs(): number {
return this.lamportClock;
}
/** Tick the Lamport clock and return the new value. */
tick(): number {
return ++this.lamportClock;
}
/**
* Apply an incoming sync payload. Returns the list of cookies that were
* actually applied (i.e., won the conflict resolution).
*/
applyRemote(
payload: CookieSyncPayload,
sourceDeviceId: string,
): CookieEntry[] {
// Update our Lamport clock
this.lamportClock = Math.max(this.lamportClock, payload.lamportTs) + 1;
const applied: CookieEntry[] = [];
for (const entry of payload.cookies) {
const key = cookieKey(entry);
const existing = this.cookies.get(key);
if (payload.action === "delete") {
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.delete(key);
applied.push(entry);
}
continue;
}
// action === "set"
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
this.cookies.set(key, {
entry,
lamportTs: payload.lamportTs,
sourceDeviceId,
});
applied.push(entry);
}
}
return applied;
}
/** Record a local cookie change. Returns the Lamport timestamp for the sync payload. */
setLocal(entry: CookieEntry, localDeviceId: string): number {
const ts = this.tick();
const key = cookieKey(entry);
this.cookies.set(key, { entry, lamportTs: ts, sourceDeviceId: localDeviceId });
return ts;
}
/** Get a snapshot of all tracked cookies. */
getAll(): CookieEntry[] {
return Array.from(this.cookies.values()).map((t) => t.entry);
}
private shouldApply(
existing: TrackedCookie | undefined,
incomingTs: number,
incomingDeviceId: string,
): boolean {
if (!existing) return true;
if (incomingTs > existing.lamportTs) return true;
if (incomingTs === existing.lamportTs) {
return incomingDeviceId > existing.sourceDeviceId;
}
return false;
}
}

69
src/sync/envelope.ts Normal file
View File

@@ -0,0 +1,69 @@
import {
type Envelope,
type MessageType,
type CookieSyncPayload,
} from "../protocol/spec.js";
import {
type DeviceKeyPair,
deviceIdFromKeys,
deriveSharedKey,
encrypt,
decrypt,
} from "../crypto/index.js";
import { sign, buildSignablePayload } from "../crypto/signing.js";
/**
* Build a signed, encrypted envelope ready to send over the relay.
*/
export function buildEnvelope(
type: MessageType,
payload: CookieSyncPayload,
senderKeys: DeviceKeyPair,
peerEncPub: Buffer,
peerDeviceId: string,
): Envelope {
const fromId = deviceIdFromKeys(senderKeys);
const sharedKey = deriveSharedKey(senderKeys.encSec, peerEncPub);
const plaintext = Buffer.from(JSON.stringify(payload));
const { nonce, ciphertext } = encrypt(plaintext, sharedKey);
const timestamp = new Date().toISOString();
const nonceHex = nonce.toString("hex");
const payloadB64 = ciphertext.toString("base64");
const signable = buildSignablePayload({
type,
from: fromId,
to: peerDeviceId,
nonce: nonceHex,
payload: payloadB64,
timestamp,
});
const sig = sign(signable, senderKeys.signSec);
return {
type,
from: fromId,
to: peerDeviceId,
nonce: nonceHex,
payload: payloadB64,
timestamp,
sig: sig.toString("hex"),
};
}
/**
* Decrypt and parse an incoming envelope's payload.
*/
export function openEnvelope(
envelope: Envelope,
receiverKeys: DeviceKeyPair,
peerEncPub: Buffer,
): CookieSyncPayload {
const sharedKey = deriveSharedKey(receiverKeys.encSec, peerEncPub);
const nonce = Buffer.from(envelope.nonce, "hex");
const ciphertext = Buffer.from(envelope.payload, "base64");
const plaintext = decrypt(ciphertext, nonce, sharedKey);
return JSON.parse(plaintext.toString()) as CookieSyncPayload;
}

2
src/sync/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { buildEnvelope, openEnvelope } from "./envelope.js";
export { CookieStore } from "./conflict.js";