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

2105
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "cookiebridge",
"version": "0.1.0",
"description": "Cross-device cookie synchronization with E2E encryption",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"start": "tsx src/cli.ts",
"dev": "tsx --watch src/cli.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"keywords": ["cookies", "sync", "encryption", "browser-extension"],
"author": "Rc707Agency",
"license": "MIT",
"type": "commonjs",
"dependencies": {
"sodium-native": "^5.1.0",
"typescript": "^5.9.3",
"uuid": "^13.0.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@types/sodium-native": "^2.3.9",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"tsx": "^4.21.0",
"vitest": "^4.1.0"
}
}

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

138
tests/conflict.test.ts Normal file
View File

@@ -0,0 +1,138 @@
import { describe, it, expect } from "vitest";
import { CookieStore } from "../src/sync/conflict.js";
import type { CookieEntry, CookieSyncPayload } from "../src/protocol/spec.js";
function makeCookie(overrides: Partial<CookieEntry> = {}): CookieEntry {
return {
domain: "example.com",
name: "session",
value: "abc123",
path: "/",
secure: true,
httpOnly: true,
sameSite: "lax",
expiresAt: null,
...overrides,
};
}
describe("CookieStore — conflict resolution", () => {
it("applies first write", () => {
const store = new CookieStore();
const payload: CookieSyncPayload = {
action: "set",
cookies: [makeCookie({ value: "v1" })],
lamportTs: 1,
};
const applied = store.applyRemote(payload, "device-a");
expect(applied).toHaveLength(1);
expect(store.getAll()).toHaveLength(1);
expect(store.getAll()[0].value).toBe("v1");
});
it("last-writer-wins: higher timestamp wins", () => {
const store = new CookieStore();
store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "old" })], lamportTs: 1 },
"device-a",
);
store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "new" })], lamportTs: 5 },
"device-b",
);
expect(store.getAll()[0].value).toBe("new");
});
it("rejects stale update", () => {
const store = new CookieStore();
store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "newer" })], lamportTs: 10 },
"device-a",
);
const applied = store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "older" })], lamportTs: 3 },
"device-b",
);
expect(applied).toHaveLength(0);
expect(store.getAll()[0].value).toBe("newer");
});
it("breaks ties by deviceId (lexicographic)", () => {
const store = new CookieStore();
store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "from-a" })], lamportTs: 5 },
"aaa",
);
// Same timestamp, higher deviceId wins
const applied = store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "from-b" })], lamportTs: 5 },
"bbb",
);
expect(applied).toHaveLength(1);
expect(store.getAll()[0].value).toBe("from-b");
});
it("tie-break: lower deviceId loses", () => {
const store = new CookieStore();
store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "from-z" })], lamportTs: 5 },
"zzz",
);
const applied = store.applyRemote(
{ action: "set", cookies: [makeCookie({ value: "from-a" })], lamportTs: 5 },
"aaa",
);
expect(applied).toHaveLength(0);
expect(store.getAll()[0].value).toBe("from-z");
});
it("handles delete action", () => {
const store = new CookieStore();
store.applyRemote(
{ action: "set", cookies: [makeCookie()], lamportTs: 1 },
"device-a",
);
expect(store.getAll()).toHaveLength(1);
store.applyRemote(
{ action: "delete", cookies: [makeCookie()], lamportTs: 5 },
"device-a",
);
expect(store.getAll()).toHaveLength(0);
});
it("tracks local changes with Lamport clock", () => {
const store = new CookieStore();
const cookie = makeCookie({ value: "local" });
const ts = store.setLocal(cookie, "my-device");
expect(ts).toBe(1);
expect(store.getAll()).toHaveLength(1);
expect(store.currentTs).toBe(1);
const ts2 = store.setLocal(makeCookie({ name: "other", value: "x" }), "my-device");
expect(ts2).toBe(2);
});
it("advances Lamport clock on remote apply", () => {
const store = new CookieStore();
store.applyRemote(
{ action: "set", cookies: [makeCookie()], lamportTs: 100 },
"device-x",
);
// Clock should be max(0, 100) + 1 = 101
expect(store.currentTs).toBe(101);
});
it("tracks different cookies by domain|name|path", () => {
const store = new CookieStore();
store.applyRemote(
{ action: "set", cookies: [makeCookie({ domain: "a.com", name: "s1" })], lamportTs: 1 },
"d1",
);
store.applyRemote(
{ action: "set", cookies: [makeCookie({ domain: "b.com", name: "s1" })], lamportTs: 1 },
"d1",
);
expect(store.getAll()).toHaveLength(2);
});
});

124
tests/crypto.test.ts Normal file
View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import {
generateKeyPair,
deviceIdFromKeys,
serializeKeyPair,
deserializeKeyPair,
deriveSharedKey,
encrypt,
decrypt,
sign,
verify,
buildSignablePayload,
} from "../src/crypto/index.js";
describe("Key generation", () => {
it("generates unique keypairs", () => {
const kp1 = generateKeyPair();
const kp2 = generateKeyPair();
expect(kp1.signPub).not.toEqual(kp2.signPub);
expect(kp1.encPub).not.toEqual(kp2.encPub);
});
it("derives deviceId from signing public key", () => {
const kp = generateKeyPair();
const id = deviceIdFromKeys(kp);
expect(id).toBe(kp.signPub.toString("hex"));
expect(id).toHaveLength(64); // 32 bytes hex
});
it("serializes and deserializes keypair", () => {
const kp = generateKeyPair();
const serialized = serializeKeyPair(kp);
const restored = deserializeKeyPair(serialized);
expect(restored.signPub).toEqual(kp.signPub);
expect(restored.signSec).toEqual(kp.signSec);
expect(restored.encPub).toEqual(kp.encPub);
expect(restored.encSec).toEqual(kp.encSec);
});
});
describe("Encryption", () => {
it("encrypts and decrypts with shared key", () => {
const alice = generateKeyPair();
const bob = generateKeyPair();
const aliceShared = deriveSharedKey(alice.encSec, bob.encPub);
const bobShared = deriveSharedKey(bob.encSec, alice.encPub);
// Both sides derive the same shared key
expect(aliceShared).toEqual(bobShared);
const plaintext = Buffer.from("hello cookies");
const { nonce, ciphertext } = encrypt(plaintext, aliceShared);
const decrypted = decrypt(ciphertext, nonce, bobShared);
expect(decrypted.toString()).toBe("hello cookies");
});
it("fails to decrypt with wrong key", () => {
const alice = generateKeyPair();
const bob = generateKeyPair();
const eve = generateKeyPair();
const sharedKey = deriveSharedKey(alice.encSec, bob.encPub);
const wrongKey = deriveSharedKey(eve.encSec, bob.encPub);
const plaintext = Buffer.from("secret");
const { nonce, ciphertext } = encrypt(plaintext, sharedKey);
expect(() => decrypt(ciphertext, nonce, wrongKey)).toThrow();
});
it("produces different ciphertexts for same plaintext (random nonce)", () => {
const alice = generateKeyPair();
const bob = generateKeyPair();
const shared = deriveSharedKey(alice.encSec, bob.encPub);
const plaintext = Buffer.from("same message");
const r1 = encrypt(plaintext, shared);
const r2 = encrypt(plaintext, shared);
expect(r1.nonce).not.toEqual(r2.nonce);
expect(r1.ciphertext).not.toEqual(r2.ciphertext);
});
});
describe("Signing", () => {
it("signs and verifies", () => {
const kp = generateKeyPair();
const msg = Buffer.from("test message");
const sig = sign(msg, kp.signSec);
expect(verify(msg, sig, kp.signPub)).toBe(true);
});
it("rejects tampered message", () => {
const kp = generateKeyPair();
const msg = Buffer.from("original");
const sig = sign(msg, kp.signSec);
const tampered = Buffer.from("tampered");
expect(verify(tampered, sig, kp.signPub)).toBe(false);
});
it("rejects wrong signer", () => {
const alice = generateKeyPair();
const bob = generateKeyPair();
const msg = Buffer.from("from alice");
const sig = sign(msg, alice.signSec);
expect(verify(msg, sig, bob.signPub)).toBe(false);
});
it("builds deterministic signable payload", () => {
const fields = {
type: "cookie_sync",
from: "aaa",
to: "bbb",
nonce: "ccc",
payload: "ddd",
timestamp: "2024-01-01T00:00:00.000Z",
};
const p1 = buildSignablePayload(fields);
const p2 = buildSignablePayload(fields);
expect(p1).toEqual(p2);
});
});

301
tests/integration.test.ts Normal file
View File

@@ -0,0 +1,301 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import WebSocket from "ws";
import {
RelayServer,
generateKeyPair,
deviceIdFromKeys,
deriveSharedKey,
sign,
MESSAGE_TYPES,
} from "../src/index.js";
import { buildEnvelope, openEnvelope } from "../src/sync/envelope.js";
import type { CookieSyncPayload, Envelope } from "../src/protocol/spec.js";
// Helper: connect and authenticate a device
function connectDevice(
port: number,
keys: ReturnType<typeof generateKeyPair>,
): Promise<{ ws: WebSocket; deviceId: string }> {
return new Promise((resolve, reject) => {
const deviceId = deviceIdFromKeys(keys);
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
ws.on("error", reject);
ws.on("message", (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === "auth_challenge") {
const challenge = Buffer.from(msg.challenge, "hex");
const sig = sign(challenge, keys.signSec);
ws.send(
JSON.stringify({
type: "auth_response",
deviceId,
sig: sig.toString("hex"),
}),
);
} else if (msg.type === "auth_ok") {
resolve({ ws, deviceId });
} else if (msg.type === "error") {
reject(new Error(msg.error));
}
});
});
}
// Helper: wait for next message of a given type
function waitForMessage(
ws: WebSocket,
type: string,
timeoutMs = 5000,
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout waiting for ${type}`)), timeoutMs);
const handler = (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === type) {
clearTimeout(timer);
ws.off("message", handler);
resolve(msg);
}
};
ws.on("message", handler);
});
}
describe("Integration: relay server end-to-end", () => {
let server: RelayServer;
let port: number;
beforeAll(async () => {
server = new RelayServer({ port: 0 }); // random port
await server.start();
port = server.port;
});
afterAll(async () => {
await server.stop();
});
it("health check works", async () => {
const res = await fetch(`http://127.0.0.1:${port}/health`);
const body = await res.json();
expect(body.status).toBe("ok");
});
it("pairing flow: create and accept", async () => {
const alice = generateKeyPair();
const bob = generateKeyPair();
// Alice initiates pairing
const createRes = await fetch(`http://127.0.0.1:${port}/pair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deviceId: deviceIdFromKeys(alice),
x25519PubKey: alice.encPub.toString("hex"),
}),
});
expect(createRes.status).toBe(201);
const { pairingCode } = (await createRes.json()) as { pairingCode: string };
expect(pairingCode).toHaveLength(6);
// Bob accepts with the code
const acceptRes = await fetch(`http://127.0.0.1:${port}/pair/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deviceId: deviceIdFromKeys(bob),
x25519PubKey: bob.encPub.toString("hex"),
pairingCode,
}),
});
expect(acceptRes.status).toBe(200);
const result = (await acceptRes.json()) as {
initiator: { deviceId: string; x25519PubKey: string };
acceptor: { deviceId: string; x25519PubKey: string };
};
expect(result.initiator.deviceId).toBe(deviceIdFromKeys(alice));
expect(result.acceptor.deviceId).toBe(deviceIdFromKeys(bob));
});
it("rejects invalid pairing code", async () => {
const res = await fetch(`http://127.0.0.1:${port}/pair/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deviceId: "fake",
x25519PubKey: "fake",
pairingCode: "000000",
}),
});
expect(res.status).toBe(404);
});
it("authenticates devices via WebSocket", async () => {
const alice = generateKeyPair();
const { ws, deviceId } = await connectDevice(port, alice);
expect(deviceId).toBe(deviceIdFromKeys(alice));
ws.close();
});
it("rejects bad auth signatures", async () => {
const alice = generateKeyPair();
const eve = generateKeyPair(); // wrong keys
await expect(
new Promise<void>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
ws.on("message", (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === "auth_challenge") {
// Sign with wrong key
const challenge = Buffer.from(msg.challenge, "hex");
const sig = sign(challenge, eve.signSec);
ws.send(
JSON.stringify({
type: "auth_response",
deviceId: deviceIdFromKeys(alice), // claim to be alice
sig: sig.toString("hex"),
}),
);
}
});
ws.on("close", (code: number) => {
if (code === 4003) reject(new Error("Auth failed as expected"));
else resolve();
});
}),
).rejects.toThrow("Auth failed");
});
it("relays encrypted cookie sync between two devices", async () => {
const alice = generateKeyPair();
const bob = generateKeyPair();
const aliceConn = await connectDevice(port, alice);
const bobConn = await connectDevice(port, bob);
const aliceDeviceId = deviceIdFromKeys(alice);
const bobDeviceId = deviceIdFromKeys(bob);
// Alice sends a cookie sync to Bob
const payload: CookieSyncPayload = {
action: "set",
cookies: [
{
domain: "example.com",
name: "session",
value: "tok_abc123",
path: "/",
secure: true,
httpOnly: true,
sameSite: "lax",
expiresAt: null,
},
],
lamportTs: 1,
};
const envelope = buildEnvelope(
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));
// Alice gets ACK
const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK);
expect(ack.delivered).toBe(true);
// Bob receives the encrypted envelope
const received = (await bobMessagePromise) as unknown as Envelope;
expect(received.from).toBe(aliceDeviceId);
expect(received.to).toBe(bobDeviceId);
// Bob decrypts
const decrypted = openEnvelope(received, bob, alice.encPub);
expect(decrypted.action).toBe("set");
expect(decrypted.cookies).toHaveLength(1);
expect(decrypted.cookies[0].domain).toBe("example.com");
expect(decrypted.cookies[0].value).toBe("tok_abc123");
aliceConn.ws.close();
bobConn.ws.close();
});
it("queues messages for offline devices and delivers on reconnect", async () => {
const alice = generateKeyPair();
const bob = generateKeyPair();
const bobDeviceId = deviceIdFromKeys(bob);
// Alice connects, Bob is offline
const aliceConn = await connectDevice(port, alice);
const payload: CookieSyncPayload = {
action: "set",
cookies: [
{
domain: "queued.com",
name: "token",
value: "queued_val",
path: "/",
secure: true,
httpOnly: false,
sameSite: "none",
expiresAt: null,
},
],
lamportTs: 1,
};
const envelope = buildEnvelope(
MESSAGE_TYPES.COOKIE_SYNC,
payload,
alice,
bob.encPub,
bobDeviceId,
);
// Send while Bob is offline
aliceConn.ws.send(JSON.stringify(envelope));
const ack = await waitForMessage(aliceConn.ws, MESSAGE_TYPES.ACK);
expect(ack.delivered).toBe(false); // queued, not delivered
// Bob comes online — should receive the queued message
const bobMessagePromise = new Promise<Envelope>((resolve) => {
const bobWs = new WebSocket(`ws://127.0.0.1:${port}/ws`);
bobWs.on("message", (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === "auth_challenge") {
const challenge = Buffer.from(msg.challenge, "hex");
const sig = sign(challenge, bob.signSec);
bobWs.send(
JSON.stringify({
type: "auth_response",
deviceId: bobDeviceId,
sig: sig.toString("hex"),
}),
);
} else if (msg.type === MESSAGE_TYPES.COOKIE_SYNC) {
resolve(msg as unknown as Envelope);
bobWs.close();
}
});
});
const received = await bobMessagePromise;
const decrypted = openEnvelope(received, bob, alice.encPub);
expect(decrypted.cookies[0].value).toBe("queued_val");
aliceConn.ws.close();
});
});

57
tests/pairing.test.ts Normal file
View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { PairingStore, generatePairingCode } from "../src/pairing/index.js";
describe("PairingStore", () => {
it("creates and finds a pairing session", () => {
const store = new PairingStore();
const session = store.create("device-a-id", "device-a-x25519-pub");
expect(session.pairingCode).toHaveLength(6);
expect(session.deviceId).toBe("device-a-id");
const found = store.find(session.pairingCode);
expect(found).not.toBeNull();
expect(found!.deviceId).toBe("device-a-id");
});
it("consumes a session (one-time use)", () => {
const store = new PairingStore();
const session = store.create("d1", "pub1");
const consumed = store.consume(session.pairingCode);
expect(consumed).not.toBeNull();
expect(consumed!.deviceId).toBe("d1");
// Second consume returns null
const again = store.consume(session.pairingCode);
expect(again).toBeNull();
});
it("returns null for unknown code", () => {
const store = new PairingStore();
expect(store.find("999999")).toBeNull();
});
it("expires sessions after TTL", () => {
const store = new PairingStore();
const session = store.create("d1", "pub1");
// Manually expire by setting expiresAt in the past
// We access the internal session via find and mutate it
const found = store.find(session.pairingCode);
if (found) {
(found as { expiresAt: number }).expiresAt = Date.now() - 1000;
}
expect(store.find(session.pairingCode)).toBeNull();
});
});
describe("generatePairingCode", () => {
it("generates 6-digit codes", () => {
for (let i = 0; i < 20; i++) {
const code = generatePairingCode();
expect(code).toHaveLength(6);
expect(/^\d{6}$/.test(code)).toBe(true);
}
});
});

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
testTimeout: 15_000,
},
});