diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e85ab6c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +extension +tests +.git +.github +docs +*.md +!package.json +!package-lock.json +.DS_Store diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1610e01 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + - run: npm run typecheck + - run: npm test + + docker: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t cookiebridge:ci . + + - name: Smoke test + run: | + docker run -d --name cb-test -p 8080:8080 cookiebridge:ci + sleep 3 + curl -sf http://localhost:8080/health + docker stop cb-test + + extension: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Install extension dependencies + working-directory: extension + run: npm ci + + - name: Build all browsers + working-directory: extension + run: | + node esbuild.config.mjs --browser=chrome + node esbuild.config.mjs --browser=firefox + node esbuild.config.mjs --browser=edge + node esbuild.config.mjs --browser=safari diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8c023c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + packages: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + + docker: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} + ghcr.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + github-release: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Build extensions + working-directory: extension + run: | + npm ci + for browser in chrome firefox edge safari; do + node esbuild.config.mjs --browser=$browser + done + + - name: Package extensions + run: | + cd extension/build + for browser in chrome firefox edge safari; do + zip -r "../../cookiebridge-${browser}-${{ github.ref_name }}.zip" "$browser/" + done + + - uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: cookiebridge-*-${{ github.ref_name }}.zip diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8cb499e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to CookieBridge + +## Getting Started + +1. Fork and clone the repository. +2. Install dependencies: `npm install` +3. Start the dev server: `npm run dev` +4. Run tests: `npm test` + +## Development Setup + +**Prerequisites:** +- Node.js 22+ +- npm + +**Relay server:** + +```bash +npm install +npm run dev # Starts with file watching on :8080 +npm test # Run test suite +npm run typecheck # Type checking only +``` + +**Extension:** + +```bash +cd extension +npm install +node esbuild.config.mjs --browser=chrome +``` + +Load the unpacked extension from `extension/build/chrome/` in your browser. + +## Making Changes + +1. Create a branch from `main`. +2. Make focused, small commits. Each commit should do one thing. +3. Run `npm test` and `npm run typecheck` before pushing. +4. Open a pull request against `main`. + +## Code Style + +- TypeScript strict mode everywhere. +- No frameworks on the server — use Node.js built-ins where possible. +- Use libsodium for all cryptographic operations. No `crypto.subtle` or OpenSSL. +- Keep dependencies minimal. + +## Testing + +Tests use Vitest. Run with `npm test`. + +- `tests/crypto.test.ts` — Encryption and signing primitives. +- `tests/pairing.test.ts` — Device pairing flow. +- `tests/conflict.test.ts` — LWW conflict resolution. +- `tests/integration.test.ts` — Full server integration tests. + +Add tests for new features. Integration tests should start a real server instance. + +## Commit Messages + +Use clear, imperative commit messages: + +``` +feat: add cookie expiry sync support +fix: handle WebSocket reconnect on network change +docs: update self-hosting guide +``` + +## Reporting Issues + +Open an issue with: +- What you expected to happen. +- What actually happened. +- Steps to reproduce. +- Browser and OS version (for extension issues). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..41971ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +# sodium-native needs build tools on Alpine +RUN apk add --no-cache python3 make g++ + +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts=false + +COPY tsconfig.json ./ +COPY src/ ./src/ +RUN npm run build + +# --- Production image --- +FROM node:22-alpine + +WORKDIR /app + +RUN apk add --no-cache python3 make g++ + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts=false && \ + apk del python3 make g++ && \ + rm -rf /root/.npm /tmp/* + +COPY --from=builder /app/dist ./dist + +ENV PORT=8080 +ENV HOST=0.0.0.0 +EXPOSE 8080 + +USER node + +CMD ["node", "dist/cli.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..afee9ad --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# CookieBridge + +Cross-device cookie synchronization with end-to-end encryption. Login once, be logged in everywhere. + +CookieBridge syncs browser cookies across devices through an encrypted relay server. The server stores only encrypted blobs — it cannot read your cookie data. Devices pair using a short code and derive a shared secret locally via X25519 key exchange. + +## Features + +- **End-to-end encryption** — XChaCha20-Poly1305 (AEAD). The relay server is zero-knowledge. +- **Multi-browser support** — Chrome, Firefox, Edge, and Safari extensions. +- **Real-time sync** — WebSocket transport with HTTP polling fallback. +- **Device pairing** — 6-digit code, 5-minute TTL, X25519 key exchange. +- **AI agent API** — Agents can retrieve encrypted cookies with granted access. +- **Conflict resolution** — Last-writer-wins with Lamport clocks. +- **Self-hostable** — Docker image or run directly with Node.js. + +## Quick Start + +### Docker (recommended) + +```bash +docker compose up -d +``` + +The relay server starts on port 8080. Override with `PORT=3000 docker compose up -d`. + +### Docker (manual) + +```bash +docker build -t cookiebridge . +docker run -d -p 8080:8080 --name cookiebridge cookiebridge +``` + +### From source + +```bash +npm install +npm start +``` + +Requires Node.js 22+. The server listens on `0.0.0.0:8080` by default. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | Server listen port | +| `HOST` | `0.0.0.0` | Server bind address | + +## Browser Extensions + +Extensions live in `extension/` and support Chrome, Firefox, Edge, and Safari. + +### Building + +```bash +cd extension +npm install + +# Build for a specific browser +node esbuild.config.mjs --browser=chrome +node esbuild.config.mjs --browser=firefox +node esbuild.config.mjs --browser=edge +node esbuild.config.mjs --browser=safari +``` + +Output goes to `extension/build/{browser}/`. Load the unpacked extension from there. + +### Installing + +- **Chrome**: `chrome://extensions` → Enable Developer Mode → Load unpacked → select `extension/build/chrome` +- **Firefox**: `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → select `extension/build/firefox/manifest.json` +- **Edge**: `edge://extensions` → Enable Developer Mode → Load unpacked → select `extension/build/edge` +- **Safari**: Requires Xcode. Convert with `xcrun safari-web-extension-converter extension/build/safari` + +## Architecture + +``` +Browser Extension ──WebSocket/HTTP──▶ Relay Server (stores encrypted blobs) + │ ▲ + ├── Ed25519 signing │ + ├── X25519 key exchange │ + └── XChaCha20-Poly1305 encryption │ + │ +AI Agent ──Bearer token──────────────────────┘ +``` + +The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model. + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/devices/register` | Register a device, receive API token | +| `POST` | `/api/pair` | Start a pairing session | +| `POST` | `/api/pair/accept` | Accept pairing with code | +| `POST` | `/api/cookies` | Push encrypted cookies | +| `GET` | `/api/cookies` | Pull cookies for a device | +| `GET` | `/api/cookies/updates` | Poll for updates since timestamp | +| `DELETE` | `/api/cookies` | Delete a cookie entry | +| `POST` | `/api/agent/tokens` | Create an agent access token | +| `POST` | `/api/agent/grant` | Grant agent access to a device | +| `GET` | `/api/agent/cookies` | Agent retrieves cookies | +| `GET` | `/health` | Health check | +| `WebSocket` | `/ws` | Real-time sync channel | + +## Development + +```bash +npm install +npm run dev # Start with file watching +npm test # Run test suite +npm run typecheck # Type checking only +``` + +## Project Structure + +``` +src/ + cli.ts # Server entry point + relay/ + server.ts # HTTP + WebSocket server + connections.ts # WebSocket connection manager + auth.ts # Token & challenge-response auth + store.ts # In-memory encrypted cookie storage + tokens.ts # Device & agent registries + crypto/ # XChaCha20-Poly1305, Ed25519 + pairing/ # Device pairing flow + sync/ # Sync engine, conflict resolution + protocol/ + spec.ts # Protocol types & constants +extension/ # Multi-browser extension source +tests/ # Vitest test suite +``` + +## License + +MIT diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..85ef36a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + relay: + build: . + ports: + - "${PORT:-8080}:8080" + environment: + - PORT=8080 + - HOST=0.0.0.0 + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..03de774 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,139 @@ +# CookieBridge Architecture + +## Overview + +CookieBridge is a cross-device cookie sync system built on a zero-knowledge relay architecture. The relay server transports and stores encrypted cookie blobs but cannot decrypt them. All cryptographic operations happen client-side. + +## Components + +### Relay Server (`src/relay/`) + +A plain Node.js HTTP + WebSocket server. No frameworks — just `http`, `ws`, and `sodium-native`. + +- **server.ts** — Routes HTTP requests and upgrades WebSocket connections. Single entry point for all API traffic. +- **connections.ts** — Manages active WebSocket connections. Maps device IDs to sockets for real-time push. +- **auth.ts** — Two auth methods: + - **Token auth**: Bearer tokens issued at device registration. Used for HTTP and WebSocket. + - **Challenge-response**: Server sends random bytes, client signs with Ed25519. Used for WebSocket upgrade. +- **store.ts** — In-memory encrypted cookie blob storage. Keyed by `(deviceId, domain, cookieName, path)`. Limit: 10,000 cookies per device. +- **tokens.ts** — Device and agent registries. Tracks device metadata, pairing relationships, and agent access grants. + +### Cryptography (`src/crypto/`) + +All crypto uses libsodium via `sodium-native` (server) or `libsodium-wrappers-sumo` (extension). + +| Operation | Algorithm | Purpose | +|-----------|-----------|---------| +| Encryption | XChaCha20-Poly1305 | Cookie payload encryption (AEAD) | +| Signing | Ed25519 | Message authentication, device identity | +| Key exchange | X25519 | Deriving shared secrets between paired devices | +| Key derivation | BLAKE2b (generic hash) | Deriving encryption keys from ECDH output | + +### Sync Engine (`src/sync/`) + +- **envelope.ts** — Wire format for WebSocket messages. Each message is signed and encrypted. +- **conflict.ts** — Last-writer-wins (LWW) conflict resolution. Uses Lamport clock timestamps. Ties broken by lexicographic device ID comparison. + +### Browser Extension (`extension/`) + +Single TypeScript codebase compiled per-browser via esbuild. + +- **service-worker.ts** — Extension lifecycle, device registration, pairing orchestration, sync triggers. +- **api-client.ts** — HTTP client for relay server REST API. +- **connection.ts** — WebSocket manager with auto-reconnect and exponential backoff. +- **crypto.ts** — Client-side encryption/decryption using libsodium-wrappers-sumo. +- **sync.ts** — Processes incoming cookie updates, applies them via `chrome.cookies` API. +- **compat.ts** — Cross-browser abstraction layer for Chrome, Firefox, Edge, Safari API differences. + +## Data Flow + +### Device Registration + +``` +Extension Relay Server + │ │ + │ POST /api/devices/register │ + │ { deviceId, name, platform, │ + │ encPub } │ + │──────────────────────────────────▶│ + │ │ + │ { token, deviceId, ... } │ + │◀──────────────────────────────────│ +``` + +### Pairing + +``` +Device A Relay Server Device B + │ │ │ + │ POST /api/pair │ │ + │ { deviceId, x25519Pub,│ │ + │ pairingCode } │ │ + │──────────────────────▶│ │ + │ │ │ + │ │ POST /api/pair/accept │ + │ │ { deviceId, x25519Pub,│ + │ │ pairingCode } │ + │ │◀──────────────────────│ + │ │ │ + │ { peerX25519PubKey } │ { peerX25519PubKey } │ + │◀──────────────────────│──────────────────────▶│ + │ │ │ + │ derive shared secret │ derive shared secret │ + │ (X25519 ECDH) │ (X25519 ECDH) │ +``` + +### Cookie Sync (WebSocket) + +``` +Device A Relay Server Device B + │ │ │ + │ cookie_sync envelope │ │ + │ (signed + encrypted) │ │ + │──────────────────────▶│ │ + │ │ store encrypted blob │ + │ │ │ + │ │ forward envelope │ + │ │──────────────────────▶│ + │ │ │ + │ │ decrypt│ + │ │ apply │ + │ ack │ │ + │◀──────────────────────│◀──────────────────────│ +``` + +### Cookie Sync (HTTP Polling) + +``` +Device Relay Server + │ │ + │ POST /api/cookies │ + │ { encrypted blob } │ + │───────────────────────────────▶│ + │ │ + │ GET /api/cookies/updates │ + │ ?since= │ + │───────────────────────────────▶│ + │ │ + │ [ encrypted blobs ] │ + │◀───────────────────────────────│ +``` + +## Storage + +All storage is **in-memory**. The relay server does not persist data to disk. Restarting the server clears all registrations, pairings, and stored cookies. Devices re-register and re-sync automatically on reconnection. + +This is intentional for the current version — the server is a transient relay, not a database. Persistent storage may be added in a future milestone. + +## Protocol Constants + +| Constant | Value | Purpose | +|----------|-------|---------| +| `PROTOCOL_VERSION` | `2.0.0` | Wire protocol version | +| `MAX_STORED_COOKIES_PER_DEVICE` | 10,000 | Per-device cookie limit | +| `PAIRING_CODE_LENGTH` | 6 digits | Pairing code size | +| `PAIRING_TTL_MS` | 5 minutes | Pairing session expiry | +| `NONCE_BYTES` | 24 | XChaCha20 nonce size | +| `PING_INTERVAL_MS` | 30 seconds | WebSocket keepalive | +| `PONG_TIMEOUT_MS` | 10 seconds | Pong deadline | +| `POLL_INTERVAL_MS` | 5 seconds | HTTP polling default | diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..83a434a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,115 @@ +# CookieBridge Security Model + +## Design Principles + +1. **Zero-knowledge relay** — The server stores and forwards encrypted blobs. It never possesses decryption keys and cannot read cookie data. +2. **Client-side crypto only** — All encryption, decryption, signing, and key exchange happen in the browser extension or agent client. +3. **Minimal trust surface** — The server is trusted only for availability and message delivery, not confidentiality. + +## Cryptographic Primitives + +| Primitive | Algorithm | Library | +|-----------|-----------|---------| +| Symmetric encryption | XChaCha20-Poly1305 (AEAD) | libsodium | +| Digital signatures | Ed25519 | libsodium | +| Key exchange | X25519 (ECDH) | libsodium | +| Key derivation | BLAKE2b (generic hash) | libsodium | +| Random nonces | 24-byte crypto random | libsodium | + +All algorithms are from the libsodium suite (`sodium-native` on the server, `libsodium-wrappers-sumo` in the extension). + +## Identity + +Each device generates two keypairs at first launch: + +- **Ed25519** — Signing keypair. The public key (hex) serves as the `deviceId`. Used to sign all WebSocket messages and authenticate via challenge-response. +- **X25519** — Encryption keypair. Used in ECDH key exchange during pairing to derive a shared secret. + +Keys are stored in `chrome.storage.local` (extension) and never leave the device except for public key exchange during registration and pairing. + +## Authentication + +### Token Auth (HTTP) + +On device registration, the server issues a random Bearer token. This token authenticates all subsequent HTTP requests. Tokens are stored server-side in memory and associated with the device ID. + +### Challenge-Response (WebSocket) + +1. Server sends a random challenge (32 bytes, hex). +2. Client signs the challenge with its Ed25519 private key. +3. Server verifies the signature against the registered Ed25519 public key. + +This proves the client possesses the private key corresponding to the registered device ID. + +## Pairing Security + +1. Device A creates a pairing session and generates a 6-digit code. +2. The code must be communicated out-of-band (e.g., user reads it from one device and types it on another). +3. Device B submits the code along with its X25519 public key. +4. The server brokers the exchange — both devices receive each other's X25519 public key. +5. Each device derives the shared secret locally via X25519 ECDH + BLAKE2b key derivation. + +**Mitigations:** +- Pairing sessions expire after 5 minutes (`PAIRING_TTL_MS`). +- Codes are 6 digits (1M combinations). Rate limiting should be applied in production. +- The server sees the X25519 public keys but not the derived shared secret. + +## Encryption + +Cookie data is encrypted with XChaCha20-Poly1305 before leaving the extension: + +1. A fresh 24-byte random nonce is generated per message. +2. The cookie payload is encrypted with the shared secret derived from pairing. +3. The nonce and ciphertext are sent to the server. +4. The server stores the encrypted blob without the ability to decrypt it. + +**AEAD properties:** +- Confidentiality: only holders of the shared secret can decrypt. +- Integrity: any tampering with the ciphertext is detected. +- Nonce uniqueness: random 24-byte nonces provide negligible collision probability. + +## Message Signing + +Every WebSocket message is signed with the sender's Ed25519 private key. The recipient verifies the signature against the sender's registered public key before processing. This prevents message forgery even if the server is compromised. + +## Threat Model + +### What the server knows + +- Device IDs (Ed25519 public keys), device names, platform strings. +- Pairing relationships (which devices are paired). +- Cookie metadata: domain, cookie name, path (stored in plaintext for query routing). +- Encrypted cookie values (ciphertext — cannot decrypt). +- Timing: when cookies are synced, update frequency. + +### What the server does NOT know + +- Cookie values (encrypted end-to-end). +- Shared secrets (derived client-side via ECDH). +- Private keys (never transmitted). + +### Threats and Mitigations + +| Threat | Mitigation | +|--------|-----------| +| Server compromise | Zero-knowledge design. Attacker gets encrypted blobs and metadata, but not cookie values. | +| Man-in-the-middle on pairing | Pairing code is out-of-band. ECDH prevents passive eavesdroppers. Active MITM requires server collusion — mitigated by self-hosting. | +| Replay attacks | Each message has a unique nonce. Lamport clocks provide causal ordering. | +| Message forgery | Ed25519 signatures on all WebSocket messages. | +| Cookie metadata leakage | Domain and cookie names are plaintext on the server. Self-hosting eliminates third-party access to this metadata. | +| Brute-force pairing code | 6-digit code with 5-minute TTL. Apply rate limiting in production (not yet implemented). | + +### Current Limitations + +- **In-memory storage** — Server restart clears all data. No persistence layer. +- **No rate limiting** — Pairing endpoint and registration are not rate-limited. Deploy behind a reverse proxy with rate limiting for production use. +- **Plaintext metadata** — Domain names, cookie names, and paths are stored unencrypted for query routing. A future version may support encrypted metadata queries. +- **No TLS termination** — The relay server is plain HTTP. Run behind a TLS-terminating reverse proxy (nginx, Caddy, Cloudflare Tunnel) for production. + +## Self-Hosting Security Checklist + +1. **TLS** — Place the relay behind a reverse proxy with TLS (e.g., Caddy with automatic HTTPS). +2. **Rate limiting** — Configure rate limiting on `/api/pair`, `/api/devices/register`, and `/ws` endpoints. +3. **Firewall** — Restrict access to the relay port. Only allow traffic from your devices/networks. +4. **Updates** — Watch for CookieBridge releases and update promptly. +5. **Monitoring** — Use the `/health` endpoint for uptime monitoring.