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
package-lock.json
generated
Normal file
2105
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
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
src/cli.ts
Normal file
21
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
src/crypto/encryption.ts
Normal file
68
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
src/crypto/index.ts
Normal file
11
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
src/crypto/keys.ts
Normal file
55
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
src/crypto/signing.ts
Normal file
39
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
src/index.ts
Normal file
39
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
src/pairing/index.ts
Normal file
2
src/pairing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PairingStore, generatePairingCode } from "./pairing.js";
|
||||
export type { PairingSession } from "./pairing.js";
|
||||
70
src/pairing/pairing.ts
Normal file
70
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
src/protocol/spec.ts
Normal file
143
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
src/relay/auth.ts
Normal file
23
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
src/relay/connections.ts
Normal file
93
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
src/relay/index.ts
Normal file
3
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
src/relay/server.ts
Normal file
328
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
src/sync/conflict.ts
Normal file
97
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
src/sync/envelope.ts
Normal file
69
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
src/sync/index.ts
Normal file
2
src/sync/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { buildEnvelope, openEnvelope } from "./envelope.js";
|
||||
export { CookieStore } from "./conflict.js";
|
||||
138
tests/conflict.test.ts
Normal file
138
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
tests/crypto.test.ts
Normal file
124
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
tests/integration.test.ts
Normal file
301
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
tests/pairing.test.ts
Normal file
57
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
tsconfig.json
Normal file
18
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
vitest.config.ts
Normal file
8
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