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:
2105
projects/cookiebridge/package-lock.json
generated
Normal file
2105
projects/cookiebridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
projects/cookiebridge/package.json
Normal file
33
projects/cookiebridge/package.json
Normal 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
projects/cookiebridge/src/cli.ts
Normal file
21
projects/cookiebridge/src/cli.ts
Normal 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
projects/cookiebridge/src/crypto/encryption.ts
Normal file
68
projects/cookiebridge/src/crypto/encryption.ts
Normal 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
projects/cookiebridge/src/crypto/index.ts
Normal file
11
projects/cookiebridge/src/crypto/index.ts
Normal 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
projects/cookiebridge/src/crypto/keys.ts
Normal file
55
projects/cookiebridge/src/crypto/keys.ts
Normal 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
projects/cookiebridge/src/crypto/signing.ts
Normal file
39
projects/cookiebridge/src/crypto/signing.ts
Normal 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
projects/cookiebridge/src/index.ts
Normal file
39
projects/cookiebridge/src/index.ts
Normal 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
projects/cookiebridge/src/pairing/index.ts
Normal file
2
projects/cookiebridge/src/pairing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { PairingStore, generatePairingCode } from "./pairing.js";
|
||||||
|
export type { PairingSession } from "./pairing.js";
|
||||||
70
projects/cookiebridge/src/pairing/pairing.ts
Normal file
70
projects/cookiebridge/src/pairing/pairing.ts
Normal 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
projects/cookiebridge/src/protocol/spec.ts
Normal file
143
projects/cookiebridge/src/protocol/spec.ts
Normal 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
projects/cookiebridge/src/relay/auth.ts
Normal file
23
projects/cookiebridge/src/relay/auth.ts
Normal 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
projects/cookiebridge/src/relay/connections.ts
Normal file
93
projects/cookiebridge/src/relay/connections.ts
Normal 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
projects/cookiebridge/src/relay/index.ts
Normal file
3
projects/cookiebridge/src/relay/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { RelayServer } from "./server.js";
|
||||||
|
export type { RelayServerConfig } from "./server.js";
|
||||||
|
export { ConnectionManager } from "./connections.js";
|
||||||
328
projects/cookiebridge/src/relay/server.ts
Normal file
328
projects/cookiebridge/src/relay/server.ts
Normal 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
projects/cookiebridge/src/sync/conflict.ts
Normal file
97
projects/cookiebridge/src/sync/conflict.ts
Normal 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
projects/cookiebridge/src/sync/envelope.ts
Normal file
69
projects/cookiebridge/src/sync/envelope.ts
Normal 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
projects/cookiebridge/src/sync/index.ts
Normal file
2
projects/cookiebridge/src/sync/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { buildEnvelope, openEnvelope } from "./envelope.js";
|
||||||
|
export { CookieStore } from "./conflict.js";
|
||||||
138
projects/cookiebridge/tests/conflict.test.ts
Normal file
138
projects/cookiebridge/tests/conflict.test.ts
Normal 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
projects/cookiebridge/tests/crypto.test.ts
Normal file
124
projects/cookiebridge/tests/crypto.test.ts
Normal 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
projects/cookiebridge/tests/integration.test.ts
Normal file
301
projects/cookiebridge/tests/integration.test.ts
Normal 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
projects/cookiebridge/tests/pairing.test.ts
Normal file
57
projects/cookiebridge/tests/pairing.test.ts
Normal 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
projects/cookiebridge/tsconfig.json
Normal file
18
projects/cookiebridge/tsconfig.json
Normal 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
projects/cookiebridge/vitest.config.ts
Normal file
8
projects/cookiebridge/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
testTimeout: 15_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user