Compare commits
3 Commits
1bd7a34de8
...
b6fbf7a921
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6fbf7a921 | ||
|
|
f39ff8c215 | ||
|
|
dc3be4d73f |
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
extension
|
||||
tests
|
||||
.git
|
||||
.github
|
||||
docs
|
||||
*.md
|
||||
!package.json
|
||||
!package-lock.json
|
||||
.DS_Store
|
||||
64
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
83
.github/workflows/release.yml
vendored
Normal file
@@ -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
|
||||
76
CONTRIBUTING.md
Normal file
@@ -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).
|
||||
35
Dockerfile
Normal file
@@ -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"]
|
||||
138
README.md
Normal file
@@ -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
|
||||
15
docker-compose.yml
Normal file
@@ -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
|
||||
139
docs/architecture.md
Normal file
@@ -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=<timestamp> │
|
||||
│───────────────────────────────▶│
|
||||
│ │
|
||||
│ [ 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 |
|
||||
115
docs/security.md
Normal file
@@ -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.
|
||||
3
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
101
extension/esbuild.config.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as esbuild from "esbuild";
|
||||
import { cpSync, mkdirSync, existsSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const isWatch = process.argv.includes("--watch");
|
||||
const targetBrowser = process.argv.find((a) => a.startsWith("--browser="))?.split("=")[1] || "all";
|
||||
|
||||
const browsers = targetBrowser === "all"
|
||||
? ["chrome", "firefox", "edge", "safari"]
|
||||
: [targetBrowser];
|
||||
|
||||
// Browser-specific esbuild targets
|
||||
const browserTargets = {
|
||||
chrome: "chrome120",
|
||||
edge: "chrome120", // Edge is Chromium-based
|
||||
firefox: "firefox109",
|
||||
safari: "safari16",
|
||||
};
|
||||
|
||||
const sharedBuildOptions = {
|
||||
entryPoints: [
|
||||
"src/background/service-worker.ts",
|
||||
"src/popup/popup.ts",
|
||||
"src/options/options.ts",
|
||||
],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
sourcemap: true,
|
||||
minify: !isWatch,
|
||||
// Force CJS resolution for libsodium (ESM entry has broken sibling import)
|
||||
alias: {
|
||||
"libsodium-wrappers-sumo":
|
||||
"./node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy static assets (HTML, icons, manifest) to a browser-specific build directory.
|
||||
*/
|
||||
function copyStaticAssets(browser) {
|
||||
const outDir = join(__dirname, "build", browser);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Copy manifest
|
||||
const manifestSrc = join(__dirname, "manifests", `${browser}.json`);
|
||||
if (existsSync(manifestSrc)) {
|
||||
cpSync(manifestSrc, join(outDir, "manifest.json"));
|
||||
}
|
||||
|
||||
// Copy src (HTML, icons)
|
||||
const srcDir = join(__dirname, "src");
|
||||
cpSync(srcDir, join(outDir, "src"), { recursive: true, filter: (src) => !src.endsWith(".ts") });
|
||||
}
|
||||
|
||||
async function buildBrowser(browser) {
|
||||
const outDir = join("build", browser, "dist");
|
||||
|
||||
const options = {
|
||||
...sharedBuildOptions,
|
||||
outdir: outDir,
|
||||
target: browserTargets[browser] || "es2020",
|
||||
};
|
||||
|
||||
if (isWatch && browsers.length === 1) {
|
||||
const ctx = await esbuild.context(options);
|
||||
await ctx.watch();
|
||||
console.log(`Watching for changes (${browser})...`);
|
||||
} else {
|
||||
await esbuild.build(options);
|
||||
}
|
||||
|
||||
copyStaticAssets(browser);
|
||||
console.log(`Build complete: ${browser} → build/${browser}/`);
|
||||
}
|
||||
|
||||
// Also build the default dist/ for backwards compatibility (Chrome)
|
||||
async function buildDefault() {
|
||||
const options = {
|
||||
...sharedBuildOptions,
|
||||
outdir: "dist",
|
||||
target: "chrome120",
|
||||
};
|
||||
|
||||
if (isWatch) {
|
||||
const ctx = await esbuild.context(options);
|
||||
await ctx.watch();
|
||||
console.log("Watching for changes (default)...");
|
||||
} else {
|
||||
await esbuild.build(options);
|
||||
console.log("Build complete (default dist/).");
|
||||
}
|
||||
}
|
||||
|
||||
// Build all targets
|
||||
await buildDefault();
|
||||
for (const browser of browsers) {
|
||||
await buildBrowser(browser);
|
||||
}
|
||||
139
extension/generate-icons.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Generate simple PNG icons for the extension.
|
||||
* Creates colored circle icons with a "C" letter.
|
||||
* Run: node generate-icons.mjs
|
||||
*/
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
// Minimal PNG encoder for simple icons
|
||||
function createPNG(size, r, g, b) {
|
||||
// Create raw RGBA pixel data
|
||||
const pixels = new Uint8Array(size * size * 4);
|
||||
const center = size / 2;
|
||||
const radius = size / 2 - 1;
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const idx = (y * size + x) * 4;
|
||||
const dist = Math.sqrt((x - center) ** 2 + (y - center) ** 2);
|
||||
if (dist <= radius) {
|
||||
pixels[idx] = r;
|
||||
pixels[idx + 1] = g;
|
||||
pixels[idx + 2] = b;
|
||||
pixels[idx + 3] = 255;
|
||||
} else if (dist <= radius + 1) {
|
||||
// Anti-aliased edge
|
||||
const alpha = Math.max(0, Math.round((radius + 1 - dist) * 255));
|
||||
pixels[idx] = r;
|
||||
pixels[idx + 1] = g;
|
||||
pixels[idx + 2] = b;
|
||||
pixels[idx + 3] = alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as PNG
|
||||
return encodePNG(size, size, pixels);
|
||||
}
|
||||
|
||||
function encodePNG(width, height, pixels) {
|
||||
const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
c ^= buf[i];
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = (c >>> 1) ^ (c & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBytes = Buffer.from(type);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const combined = Buffer.concat([typeBytes, data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(combined));
|
||||
return Buffer.concat([len, combined, crc]);
|
||||
}
|
||||
|
||||
function adler32(buf) {
|
||||
let a = 1, b = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
a = (a + buf[i]) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
return ((b << 16) | a) >>> 0;
|
||||
}
|
||||
|
||||
// IHDR
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter
|
||||
ihdr[12] = 0; // interlace
|
||||
|
||||
// IDAT - raw pixel data with filter bytes
|
||||
const rawData = Buffer.alloc(height * (1 + width * 4));
|
||||
for (let y = 0; y < height; y++) {
|
||||
rawData[y * (1 + width * 4)] = 0; // no filter
|
||||
for (let x = 0; x < width * 4; x++) {
|
||||
rawData[y * (1 + width * 4) + 1 + x] = pixels[y * width * 4 + x];
|
||||
}
|
||||
}
|
||||
|
||||
// Deflate with store (no compression) - simple but works
|
||||
const blocks = [];
|
||||
let offset = 0;
|
||||
while (offset < rawData.length) {
|
||||
const remaining = rawData.length - offset;
|
||||
const blockSize = Math.min(remaining, 65535);
|
||||
const isLast = offset + blockSize >= rawData.length;
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = isLast ? 1 : 0;
|
||||
header.writeUInt16LE(blockSize, 1);
|
||||
header.writeUInt16LE(blockSize ^ 0xffff, 3);
|
||||
blocks.push(header);
|
||||
blocks.push(rawData.subarray(offset, offset + blockSize));
|
||||
offset += blockSize;
|
||||
}
|
||||
|
||||
const zlibHeader = Buffer.from([0x78, 0x01]); // deflate, no compression
|
||||
const adler = Buffer.alloc(4);
|
||||
adler.writeUInt32BE(adler32(rawData));
|
||||
const compressed = Buffer.concat([zlibHeader, ...blocks, adler]);
|
||||
|
||||
// IEND
|
||||
const iend = Buffer.alloc(0);
|
||||
|
||||
return Buffer.concat([
|
||||
SIGNATURE,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", compressed),
|
||||
chunk("IEND", iend),
|
||||
]);
|
||||
}
|
||||
|
||||
const colors = {
|
||||
gray: [156, 163, 175],
|
||||
blue: [59, 130, 246],
|
||||
green: [34, 197, 94],
|
||||
red: [239, 68, 68],
|
||||
};
|
||||
|
||||
const sizes = [16, 48, 128];
|
||||
|
||||
for (const [name, [r, g, b]] of Object.entries(colors)) {
|
||||
for (const size of sizes) {
|
||||
const png = createPNG(size, r, g, b);
|
||||
const path = `src/icons/icon-${name}-${size}.png`;
|
||||
writeFileSync(path, png);
|
||||
console.log(`Generated ${path}`);
|
||||
}
|
||||
}
|
||||
32
extension/manifest.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CookieBridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Cross-device cookie synchronization with end-to-end encryption",
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"alarms",
|
||||
"tabs",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "dist/background/service-worker.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "src/icons/icon-gray-16.png",
|
||||
"48": "src/icons/icon-gray-48.png",
|
||||
"128": "src/icons/icon-gray-128.png"
|
||||
}
|
||||
},
|
||||
"options_page": "src/options/options.html",
|
||||
"icons": {
|
||||
"16": "src/icons/icon-blue-16.png",
|
||||
"48": "src/icons/icon-blue-48.png",
|
||||
"128": "src/icons/icon-blue-128.png"
|
||||
}
|
||||
}
|
||||
32
extension/manifests/chrome.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CookieBridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Cross-device cookie synchronization with end-to-end encryption",
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"alarms",
|
||||
"tabs",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "dist/background/service-worker.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "src/icons/icon-gray-16.png",
|
||||
"48": "src/icons/icon-gray-48.png",
|
||||
"128": "src/icons/icon-gray-128.png"
|
||||
}
|
||||
},
|
||||
"options_page": "src/options/options.html",
|
||||
"icons": {
|
||||
"16": "src/icons/icon-blue-16.png",
|
||||
"48": "src/icons/icon-blue-48.png",
|
||||
"128": "src/icons/icon-blue-128.png"
|
||||
}
|
||||
}
|
||||
32
extension/manifests/edge.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CookieBridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Cross-device cookie synchronization with end-to-end encryption",
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"alarms",
|
||||
"tabs",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "dist/background/service-worker.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "src/icons/icon-gray-16.png",
|
||||
"48": "src/icons/icon-gray-48.png",
|
||||
"128": "src/icons/icon-gray-128.png"
|
||||
}
|
||||
},
|
||||
"options_page": "src/options/options.html",
|
||||
"icons": {
|
||||
"16": "src/icons/icon-blue-16.png",
|
||||
"48": "src/icons/icon-blue-48.png",
|
||||
"128": "src/icons/icon-blue-128.png"
|
||||
}
|
||||
}
|
||||
41
extension/manifests/firefox.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CookieBridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Cross-device cookie synchronization with end-to-end encryption",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "cookiebridge@rc707agency.com",
|
||||
"strict_min_version": "109.0"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"alarms",
|
||||
"tabs",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["dist/background/service-worker.js"],
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "src/icons/icon-gray-16.png",
|
||||
"48": "src/icons/icon-gray-48.png",
|
||||
"128": "src/icons/icon-gray-128.png"
|
||||
}
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "src/options/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"icons": {
|
||||
"16": "src/icons/icon-blue-16.png",
|
||||
"48": "src/icons/icon-blue-48.png",
|
||||
"128": "src/icons/icon-blue-128.png"
|
||||
}
|
||||
}
|
||||
36
extension/manifests/safari.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CookieBridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Cross-device cookie synchronization with end-to-end encryption",
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"alarms",
|
||||
"tabs",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"scripts": ["dist/background/service-worker.js"],
|
||||
"type": "module",
|
||||
"persistent": false
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "src/icons/icon-gray-16.png",
|
||||
"48": "src/icons/icon-gray-48.png",
|
||||
"128": "src/icons/icon-gray-128.png"
|
||||
}
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "src/options/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"icons": {
|
||||
"16": "src/icons/icon-blue-16.png",
|
||||
"48": "src/icons/icon-blue-48.png",
|
||||
"128": "src/icons/icon-blue-128.png"
|
||||
}
|
||||
}
|
||||
550
extension/package-lock.json
generated
Normal file
@@ -0,0 +1,550 @@
|
||||
{
|
||||
"name": "cookiebridge-extension",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cookiebridge-extension",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"libsodium-wrappers-sumo": "^0.7.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"esbuild": "^0.24.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.0.287",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz",
|
||||
"integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filesystem": "*",
|
||||
"@types/har-format": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filesystem": {
|
||||
"version": "0.0.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filewriter": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filewriter": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.24.2",
|
||||
"@esbuild/android-arm": "0.24.2",
|
||||
"@esbuild/android-arm64": "0.24.2",
|
||||
"@esbuild/android-x64": "0.24.2",
|
||||
"@esbuild/darwin-arm64": "0.24.2",
|
||||
"@esbuild/darwin-x64": "0.24.2",
|
||||
"@esbuild/freebsd-arm64": "0.24.2",
|
||||
"@esbuild/freebsd-x64": "0.24.2",
|
||||
"@esbuild/linux-arm": "0.24.2",
|
||||
"@esbuild/linux-arm64": "0.24.2",
|
||||
"@esbuild/linux-ia32": "0.24.2",
|
||||
"@esbuild/linux-loong64": "0.24.2",
|
||||
"@esbuild/linux-mips64el": "0.24.2",
|
||||
"@esbuild/linux-ppc64": "0.24.2",
|
||||
"@esbuild/linux-riscv64": "0.24.2",
|
||||
"@esbuild/linux-s390x": "0.24.2",
|
||||
"@esbuild/linux-x64": "0.24.2",
|
||||
"@esbuild/netbsd-arm64": "0.24.2",
|
||||
"@esbuild/netbsd-x64": "0.24.2",
|
||||
"@esbuild/openbsd-arm64": "0.24.2",
|
||||
"@esbuild/openbsd-x64": "0.24.2",
|
||||
"@esbuild/sunos-x64": "0.24.2",
|
||||
"@esbuild/win32-arm64": "0.24.2",
|
||||
"@esbuild/win32-ia32": "0.24.2",
|
||||
"@esbuild/win32-x64": "0.24.2"
|
||||
}
|
||||
},
|
||||
"node_modules/libsodium-sumo": {
|
||||
"version": "0.7.16",
|
||||
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz",
|
||||
"integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/libsodium-wrappers-sumo": {
|
||||
"version": "0.7.16",
|
||||
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.16.tgz",
|
||||
"integrity": "sha512-gR0JEFPeN3831lB9+ogooQk0KH4K5LSMIO5Prd5Q5XYR2wHFtZfPg0eP7t1oJIWq+UIzlU4WVeBxZ97mt28tXw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libsodium-sumo": "^0.7.16"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
extension/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "cookiebridge-extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CookieBridge Chrome Extension — cross-device cookie sync with E2E encryption",
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
"build:chrome": "node esbuild.config.mjs --browser=chrome",
|
||||
"build:firefox": "node esbuild.config.mjs --browser=firefox",
|
||||
"build:edge": "node esbuild.config.mjs --browser=edge",
|
||||
"build:safari": "node esbuild.config.mjs --browser=safari",
|
||||
"watch": "node esbuild.config.mjs --watch --browser=chrome",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"libsodium-wrappers-sumo": "^0.7.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.287",
|
||||
"esbuild": "^0.24.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
317
extension/src/background/service-worker.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* CookieBridge background service worker.
|
||||
* Manages device identity, connection lifecycle, and cookie sync.
|
||||
* Uses the browser-agnostic compat layer for cross-browser support.
|
||||
*/
|
||||
import {
|
||||
generateKeyPair,
|
||||
deviceIdFromKeys,
|
||||
serializeKeyPair,
|
||||
deserializeKeyPair,
|
||||
type DeviceKeyPair,
|
||||
} from "../lib/crypto";
|
||||
import { getState, setState, type PeerDevice } from "../lib/storage";
|
||||
import { ConnectionManager, type ConnectionStatus } from "../lib/connection";
|
||||
import { ApiClient } from "../lib/api-client";
|
||||
import { SyncEngine } from "../lib/sync";
|
||||
import { setIconState, clearSyncBadge } from "../lib/badge";
|
||||
import { MESSAGE_TYPES, POLL_INTERVAL_MS, type Envelope } from "../lib/protocol";
|
||||
import { alarms, runtime, cookies, getPlatformName } from "../lib/compat";
|
||||
|
||||
let connection: ConnectionManager | null = null;
|
||||
let syncEngine: SyncEngine | null = null;
|
||||
let api: ApiClient | null = null;
|
||||
let pollAlarmName = "cookiebridge-poll";
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
async function initialize() {
|
||||
const state = await getState();
|
||||
|
||||
if (!state.keys || !state.apiToken) {
|
||||
await setIconState("not_logged_in");
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = deserializeKeyPair(state.keys);
|
||||
api = new ApiClient(state.serverUrl, state.apiToken);
|
||||
|
||||
connection = new ConnectionManager(state.serverUrl, state.apiToken, keys, {
|
||||
onMessage: (envelope) => handleIncomingMessage(envelope, state.peers),
|
||||
onStatusChange: handleStatusChange,
|
||||
});
|
||||
|
||||
syncEngine = new SyncEngine(keys, connection, api);
|
||||
syncEngine.start();
|
||||
|
||||
if (state.connectionMode === "polling") {
|
||||
startPolling(state.pollIntervalSec);
|
||||
} else {
|
||||
connection.connect();
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(status: ConnectionStatus) {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
setIconState("connected");
|
||||
break;
|
||||
case "disconnected":
|
||||
case "connecting":
|
||||
setIconState("not_logged_in");
|
||||
break;
|
||||
case "error":
|
||||
setIconState("error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIncomingMessage(envelope: Envelope, peers: PeerDevice[]) {
|
||||
if (
|
||||
envelope.type === MESSAGE_TYPES.COOKIE_SYNC ||
|
||||
envelope.type === MESSAGE_TYPES.COOKIE_DELETE
|
||||
) {
|
||||
if (!syncEngine) return;
|
||||
await syncEngine.handleIncomingEnvelope(envelope, peers);
|
||||
await setIconState("syncing", 1);
|
||||
clearSyncBadge();
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(intervalSec: number) {
|
||||
alarms.create(pollAlarmName, {
|
||||
periodInMinutes: Math.max(intervalSec / 60, 1 / 60),
|
||||
});
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
alarms.clear(pollAlarmName);
|
||||
}
|
||||
|
||||
// --- Alarm handler for HTTP polling ---
|
||||
|
||||
alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name !== pollAlarmName) return;
|
||||
if (!api) return;
|
||||
|
||||
const state = await getState();
|
||||
if (!state.lastSyncAt) return;
|
||||
|
||||
try {
|
||||
const updates = await api.pullUpdates(state.lastSyncAt);
|
||||
if (updates.length > 0) {
|
||||
await setState({ lastSyncAt: new Date().toISOString() });
|
||||
await setIconState("syncing", updates.length);
|
||||
clearSyncBadge();
|
||||
}
|
||||
} catch {
|
||||
// Polling failure — will retry on next alarm
|
||||
}
|
||||
});
|
||||
|
||||
// --- Message handling from popup/options ---
|
||||
|
||||
export interface ExtensionMessage {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
runtime.onMessage.addListener(
|
||||
(message: ExtensionMessage, _sender: any, sendResponse: (response: any) => void) => {
|
||||
handleMessage(message).then(sendResponse).catch((err) => {
|
||||
sendResponse({ error: err.message });
|
||||
});
|
||||
return true; // async response
|
||||
},
|
||||
);
|
||||
|
||||
async function handleMessage(msg: ExtensionMessage): Promise<unknown> {
|
||||
switch (msg.type) {
|
||||
case "GET_STATUS":
|
||||
return getStatus();
|
||||
|
||||
case "REGISTER_DEVICE":
|
||||
return registerDevice(msg.payload as { name: string });
|
||||
|
||||
case "INITIATE_PAIRING":
|
||||
return initiatePairing();
|
||||
|
||||
case "ACCEPT_PAIRING":
|
||||
return acceptPairing(msg.payload as { code: string });
|
||||
|
||||
case "SYNC_CURRENT_TAB":
|
||||
if (syncEngine) await syncEngine.syncCurrentTab();
|
||||
return { ok: true };
|
||||
|
||||
case "SYNC_DOMAIN":
|
||||
if (syncEngine) {
|
||||
await syncEngine.syncDomain(
|
||||
(msg.payload as { domain: string }).domain,
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
case "ADD_WHITELIST": {
|
||||
const state = await getState();
|
||||
const domain = (msg.payload as { domain: string }).domain;
|
||||
if (!state.whitelist.includes(domain)) {
|
||||
await setState({ whitelist: [...state.whitelist, domain] });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case "DISCONNECT":
|
||||
connection?.disconnect();
|
||||
stopPolling();
|
||||
return { ok: true };
|
||||
|
||||
case "RECONNECT":
|
||||
await initialize();
|
||||
return { ok: true };
|
||||
|
||||
case "EXPORT_KEYS": {
|
||||
const state = await getState();
|
||||
return { keys: state.keys };
|
||||
}
|
||||
|
||||
case "IMPORT_KEYS": {
|
||||
const keys = msg.payload as {
|
||||
signPub: string;
|
||||
signSec: string;
|
||||
encPub: string;
|
||||
encSec: string;
|
||||
};
|
||||
const kp = deserializeKeyPair(keys);
|
||||
await setState({
|
||||
keys,
|
||||
deviceId: deviceIdFromKeys(kp),
|
||||
});
|
||||
await initialize();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case "LOGOUT":
|
||||
connection?.disconnect();
|
||||
syncEngine?.stop();
|
||||
stopPolling();
|
||||
await setState({
|
||||
keys: null,
|
||||
deviceId: null,
|
||||
deviceName: null,
|
||||
apiToken: null,
|
||||
peers: [],
|
||||
syncCount: 0,
|
||||
lastSyncAt: null,
|
||||
lamportClock: 0,
|
||||
});
|
||||
await setIconState("not_logged_in");
|
||||
return { ok: true };
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${msg.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getStatus(): Promise<{
|
||||
loggedIn: boolean;
|
||||
deviceId: string | null;
|
||||
deviceName: string | null;
|
||||
connectionStatus: ConnectionStatus;
|
||||
peerCount: number;
|
||||
syncCount: number;
|
||||
lastSyncAt: string | null;
|
||||
cookieCount: number;
|
||||
}> {
|
||||
const state = await getState();
|
||||
|
||||
// Count tracked cookies
|
||||
let cookieCount = 0;
|
||||
if (state.whitelist.length > 0) {
|
||||
for (const domain of state.whitelist) {
|
||||
const domainCookies = await cookies.getAll({ domain });
|
||||
cookieCount += domainCookies.length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loggedIn: !!state.apiToken,
|
||||
deviceId: state.deviceId,
|
||||
deviceName: state.deviceName,
|
||||
connectionStatus: connection?.status ?? "disconnected",
|
||||
peerCount: state.peers.length,
|
||||
syncCount: state.syncCount,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
cookieCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function registerDevice(params: { name: string }) {
|
||||
const keys = await generateKeyPair();
|
||||
const serialized = serializeKeyPair(keys);
|
||||
const deviceId = deviceIdFromKeys(keys);
|
||||
|
||||
const state = await getState();
|
||||
const client = new ApiClient(state.serverUrl);
|
||||
const device = await client.registerDevice({
|
||||
deviceId,
|
||||
name: params.name,
|
||||
platform: getPlatformName(),
|
||||
encPub: serialized.encPub,
|
||||
});
|
||||
|
||||
await setState({
|
||||
keys: serialized,
|
||||
deviceId,
|
||||
deviceName: params.name,
|
||||
apiToken: device.token,
|
||||
});
|
||||
|
||||
await initialize();
|
||||
return { deviceId, name: params.name };
|
||||
}
|
||||
|
||||
async function initiatePairing() {
|
||||
const state = await getState();
|
||||
if (!state.keys || !api) throw new Error("Not registered");
|
||||
|
||||
const result = await api.initiatePairing(
|
||||
state.deviceId!,
|
||||
state.keys.encPub,
|
||||
);
|
||||
return { pairingCode: result.pairingCode };
|
||||
}
|
||||
|
||||
async function acceptPairing(params: { code: string }) {
|
||||
const state = await getState();
|
||||
if (!state.keys || !api) throw new Error("Not registered");
|
||||
|
||||
const result = await api.acceptPairing(
|
||||
state.deviceId!,
|
||||
state.keys.encPub,
|
||||
params.code,
|
||||
);
|
||||
|
||||
const peer: PeerDevice = {
|
||||
deviceId: result.peerDeviceId,
|
||||
name: "Paired Device",
|
||||
platform: "unknown",
|
||||
encPub: result.peerX25519PubKey,
|
||||
pairedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await setState({ peers: [...state.peers, peer] });
|
||||
return { peerId: result.peerDeviceId };
|
||||
}
|
||||
|
||||
// --- Start on install/startup ---
|
||||
|
||||
runtime.onInstalled.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
runtime.onStartup.addListener(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
// Initialize immediately for service worker restart
|
||||
initialize();
|
||||
45
extension/src/icons/generate-icons.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Generate CookieBridge Icons</title></head>
|
||||
<body>
|
||||
<script>
|
||||
// Run this in a browser to generate icon PNGs, or use the canvas-based approach in service worker
|
||||
const sizes = [16, 48, 128];
|
||||
const colors = {
|
||||
gray: '#9CA3AF',
|
||||
blue: '#3B82F6',
|
||||
green: '#22C55E',
|
||||
red: '#EF4444',
|
||||
};
|
||||
|
||||
for (const [colorName, color] of Object.entries(colors)) {
|
||||
for (const size of sizes) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
|
||||
// Cookie icon (simplified)
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = `bold ${size * 0.5}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('C', size / 2, size / 2);
|
||||
|
||||
// Download
|
||||
const link = document.createElement('a');
|
||||
link.download = `icon-${colorName}-${size}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
}
|
||||
document.body.textContent = 'Icons generated! Check downloads.';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
extension/src/icons/icon-blue-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-blue-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-blue-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
extension/src/icons/icon-gray-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-gray-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-gray-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
extension/src/icons/icon-green-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-green-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-green-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
extension/src/icons/icon-red-128.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
extension/src/icons/icon-red-16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
extension/src/icons/icon-red-48.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
119
extension/src/lib/api-client.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* HTTP client for the CookieBridge relay server REST API.
|
||||
*/
|
||||
import type {
|
||||
DeviceRegisterRequest,
|
||||
DeviceInfo,
|
||||
PairingResult,
|
||||
EncryptedCookieBlob,
|
||||
} from "./protocol";
|
||||
|
||||
export class ApiClient {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private token: string | null = null,
|
||||
) {}
|
||||
|
||||
setToken(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`API ${method} ${path}: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Device Registration ---
|
||||
|
||||
async registerDevice(req: DeviceRegisterRequest): Promise<DeviceInfo> {
|
||||
return this.request("POST", "/api/devices/register", req);
|
||||
}
|
||||
|
||||
// --- Pairing ---
|
||||
|
||||
async initiatePairing(
|
||||
deviceId: string,
|
||||
x25519PubKey: string,
|
||||
): Promise<{ pairingCode: string }> {
|
||||
return this.request("POST", "/api/pair", { deviceId, x25519PubKey });
|
||||
}
|
||||
|
||||
async acceptPairing(
|
||||
deviceId: string,
|
||||
x25519PubKey: string,
|
||||
pairingCode: string,
|
||||
): Promise<PairingResult> {
|
||||
return this.request("POST", "/api/pair/accept", {
|
||||
deviceId,
|
||||
x25519PubKey,
|
||||
pairingCode,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Cookie Storage (HTTP polling) ---
|
||||
|
||||
async pushCookies(
|
||||
blobs: Array<{
|
||||
domain: string;
|
||||
cookieName: string;
|
||||
path: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
lamportTs: number;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
await this.request("POST", "/api/cookies", { cookies: blobs });
|
||||
}
|
||||
|
||||
async pullCookies(domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||
const params = domain ? `?domain=${encodeURIComponent(domain)}` : "";
|
||||
return this.request("GET", `/api/cookies${params}`);
|
||||
}
|
||||
|
||||
async pullUpdates(since: string): Promise<EncryptedCookieBlob[]> {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/api/cookies/updates?since=${encodeURIComponent(since)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCookie(
|
||||
domain: string,
|
||||
cookieName: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
await this.request("DELETE", "/api/cookies", {
|
||||
domain,
|
||||
cookieName,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
|
||||
async health(): Promise<{ status: string; connections: number }> {
|
||||
return this.request("GET", "/health");
|
||||
}
|
||||
}
|
||||
71
extension/src/lib/badge.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Badge/icon management — updates extension icon color and badge text
|
||||
* based on connection status and sync activity.
|
||||
* Uses the browser-agnostic compat layer.
|
||||
*
|
||||
* States:
|
||||
* - gray: Not logged in / no device identity
|
||||
* - blue: Connected, idle
|
||||
* - green: Syncing (with count badge)
|
||||
* - red: Error / disconnected
|
||||
*/
|
||||
import { action, storage } from "./compat";
|
||||
|
||||
type IconColor = "gray" | "blue" | "green" | "red";
|
||||
|
||||
function iconPath(color: IconColor, size: number): string {
|
||||
return `src/icons/icon-${color}-${size}.png`;
|
||||
}
|
||||
|
||||
function iconSet(color: IconColor): Record<string, string> {
|
||||
return {
|
||||
"16": iconPath(color, 16),
|
||||
"48": iconPath(color, 48),
|
||||
"128": iconPath(color, 128),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setIconState(
|
||||
state: "not_logged_in" | "connected" | "syncing" | "error",
|
||||
syncCount?: number,
|
||||
) {
|
||||
switch (state) {
|
||||
case "not_logged_in":
|
||||
await action.setIcon({ path: iconSet("gray") });
|
||||
await action.setBadgeText({ text: "" });
|
||||
break;
|
||||
|
||||
case "connected":
|
||||
await action.setIcon({ path: iconSet("blue") });
|
||||
await action.setBadgeText({ text: "" });
|
||||
break;
|
||||
|
||||
case "syncing":
|
||||
await action.setIcon({ path: iconSet("green") });
|
||||
if (syncCount && syncCount > 0) {
|
||||
await action.setBadgeText({
|
||||
text: syncCount > 99 ? "99+" : String(syncCount),
|
||||
});
|
||||
await action.setBadgeBackgroundColor({ color: "#22C55E" });
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
await action.setIcon({ path: iconSet("red") });
|
||||
await action.setBadgeText({ text: "!" });
|
||||
await action.setBadgeBackgroundColor({ color: "#EF4444" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the sync badge after a delay. */
|
||||
export function clearSyncBadge(delayMs = 3000) {
|
||||
setTimeout(async () => {
|
||||
const state = await storage.get(["apiToken"]);
|
||||
if (state.apiToken) {
|
||||
await setIconState("connected");
|
||||
} else {
|
||||
await setIconState("not_logged_in");
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
278
extension/src/lib/compat.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Browser compatibility layer — provides a unified API across Chrome, Firefox, Edge, and Safari.
|
||||
*
|
||||
* Firefox uses the `browser.*` namespace with promise-based APIs.
|
||||
* Chrome/Edge use `chrome.*` with callback-based APIs.
|
||||
* Safari uses `browser.*` (WebExtensions) with some limitations.
|
||||
*
|
||||
* This module detects the environment and exports a normalized `browserAPI`
|
||||
* that always returns promises and works across all supported browsers.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
type BrowserType = "chrome" | "firefox" | "safari" | "edge" | "unknown";
|
||||
|
||||
function detectBrowser(): BrowserType {
|
||||
if (typeof globalThis !== "undefined") {
|
||||
const ua =
|
||||
typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
|
||||
|
||||
// Safari: check before Chrome since Safari UA may contain "Chrome" in some contexts
|
||||
if (
|
||||
ua.includes("Safari") &&
|
||||
!ua.includes("Chrome") &&
|
||||
!ua.includes("Chromium")
|
||||
) {
|
||||
return "safari";
|
||||
}
|
||||
// Firefox
|
||||
if (
|
||||
typeof (globalThis as any).browser !== "undefined" &&
|
||||
typeof (globalThis as any).browser.runtime !== "undefined" &&
|
||||
ua.includes("Firefox")
|
||||
) {
|
||||
return "firefox";
|
||||
}
|
||||
// Edge (Chromium-based)
|
||||
if (ua.includes("Edg/")) {
|
||||
return "edge";
|
||||
}
|
||||
// Chrome
|
||||
if (
|
||||
typeof (globalThis as any).chrome !== "undefined" &&
|
||||
typeof (globalThis as any).chrome.runtime !== "undefined"
|
||||
) {
|
||||
return "chrome";
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export const BROWSER: BrowserType = detectBrowser();
|
||||
|
||||
/**
|
||||
* Get the raw extension API object (`browser` on Firefox/Safari, `chrome` on Chrome/Edge).
|
||||
*/
|
||||
function getRawAPI(): any {
|
||||
if (
|
||||
typeof (globalThis as any).browser !== "undefined" &&
|
||||
(globalThis as any).browser.runtime
|
||||
) {
|
||||
return (globalThis as any).browser;
|
||||
}
|
||||
if (
|
||||
typeof (globalThis as any).chrome !== "undefined" &&
|
||||
(globalThis as any).chrome.runtime
|
||||
) {
|
||||
return (globalThis as any).chrome;
|
||||
}
|
||||
throw new Error("No extension API found");
|
||||
}
|
||||
|
||||
const raw = getRawAPI();
|
||||
|
||||
/**
|
||||
* Wraps a callback-style Chrome API call into a promise.
|
||||
* Firefox's `browser.*` already returns promises so we detect and pass through.
|
||||
*/
|
||||
function promisify<T>(fn: (...args: any[]) => any, ...args: any[]): Promise<T> {
|
||||
const result = fn(...args);
|
||||
if (result && typeof result.then === "function") {
|
||||
return result;
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
fn(...args, (val: T) => {
|
||||
if (raw.runtime.lastError) {
|
||||
reject(new Error(raw.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(val);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Storage ───
|
||||
|
||||
export const storage = {
|
||||
async get(keys: string | string[] | null): Promise<Record<string, any>> {
|
||||
return promisify((k: any, cb?: any) => raw.storage.local.get(k, cb), keys);
|
||||
},
|
||||
async set(items: Record<string, any>): Promise<void> {
|
||||
return promisify((i: any, cb?: any) => raw.storage.local.set(i, cb), items);
|
||||
},
|
||||
async clear(): Promise<void> {
|
||||
return promisify((cb?: any) => raw.storage.local.clear(cb));
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Cookies ───
|
||||
|
||||
export interface CompatCookie {
|
||||
domain: string;
|
||||
name: string;
|
||||
value: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: string;
|
||||
expirationDate?: number;
|
||||
}
|
||||
|
||||
export interface CompatCookieChangeInfo {
|
||||
cookie: CompatCookie;
|
||||
removed: boolean;
|
||||
cause: string;
|
||||
}
|
||||
|
||||
export const cookies = {
|
||||
async getAll(details: { domain?: string; url?: string }): Promise<CompatCookie[]> {
|
||||
return promisify(
|
||||
(d: any, cb?: any) => raw.cookies.getAll(d, cb),
|
||||
details,
|
||||
);
|
||||
},
|
||||
async set(details: Record<string, any>): Promise<CompatCookie | null> {
|
||||
return promisify(
|
||||
(d: any, cb?: any) => raw.cookies.set(d, cb),
|
||||
details,
|
||||
);
|
||||
},
|
||||
async remove(details: { url: string; name: string }): Promise<any> {
|
||||
return promisify(
|
||||
(d: any, cb?: any) => raw.cookies.remove(d, cb),
|
||||
details,
|
||||
);
|
||||
},
|
||||
onChanged: {
|
||||
addListener(callback: (changeInfo: CompatCookieChangeInfo) => void) {
|
||||
raw.cookies.onChanged.addListener(callback);
|
||||
},
|
||||
removeListener(callback: (changeInfo: CompatCookieChangeInfo) => void) {
|
||||
raw.cookies.onChanged.removeListener(callback);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Tabs ───
|
||||
|
||||
export const tabs = {
|
||||
async query(queryInfo: {
|
||||
active?: boolean;
|
||||
currentWindow?: boolean;
|
||||
}): Promise<Array<{ url?: string; id?: number }>> {
|
||||
return promisify(
|
||||
(q: any, cb?: any) => raw.tabs.query(q, cb),
|
||||
queryInfo,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Alarms ───
|
||||
|
||||
export const alarms = {
|
||||
create(name: string, alarmInfo: { periodInMinutes: number }): void {
|
||||
raw.alarms.create(name, alarmInfo);
|
||||
},
|
||||
clear(name: string): void {
|
||||
raw.alarms.clear(name);
|
||||
},
|
||||
onAlarm: {
|
||||
addListener(callback: (alarm: { name: string }) => void): void {
|
||||
raw.alarms.onAlarm.addListener(callback);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Runtime ───
|
||||
|
||||
export const runtime = {
|
||||
onMessage: {
|
||||
addListener(
|
||||
callback: (
|
||||
message: any,
|
||||
sender: any,
|
||||
sendResponse: (response: any) => void,
|
||||
) => boolean | void,
|
||||
): void {
|
||||
raw.runtime.onMessage.addListener(callback);
|
||||
},
|
||||
},
|
||||
sendMessage(message: any): Promise<any> {
|
||||
// Firefox returns a promise, Chrome uses callbacks
|
||||
const result = raw.runtime.sendMessage(message);
|
||||
if (result && typeof result.then === "function") {
|
||||
return result;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
raw.runtime.sendMessage(message, (response: any) => {
|
||||
if (raw.runtime.lastError) {
|
||||
reject(new Error(raw.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
onInstalled: {
|
||||
addListener(callback: () => void): void {
|
||||
raw.runtime.onInstalled.addListener(callback);
|
||||
},
|
||||
},
|
||||
onStartup: {
|
||||
addListener(callback: () => void): void {
|
||||
raw.runtime.onStartup.addListener(callback);
|
||||
},
|
||||
},
|
||||
openOptionsPage(): void {
|
||||
raw.runtime.openOptionsPage();
|
||||
},
|
||||
get lastError(): { message?: string } | null | undefined {
|
||||
return raw.runtime.lastError;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Action (browserAction on older Firefox) ───
|
||||
|
||||
function getActionAPI(): any {
|
||||
return raw.action || raw.browserAction;
|
||||
}
|
||||
|
||||
export const action = {
|
||||
async setIcon(details: { path: Record<string, string> }): Promise<void> {
|
||||
const api = getActionAPI();
|
||||
if (!api) return;
|
||||
return promisify((d: any, cb?: any) => api.setIcon(d, cb), details);
|
||||
},
|
||||
async setBadgeText(details: { text: string }): Promise<void> {
|
||||
const api = getActionAPI();
|
||||
if (!api) return;
|
||||
return promisify((d: any, cb?: any) => api.setBadgeText(d, cb), details);
|
||||
},
|
||||
async setBadgeBackgroundColor(details: {
|
||||
color: string | [number, number, number, number];
|
||||
}): Promise<void> {
|
||||
const api = getActionAPI();
|
||||
if (!api) return;
|
||||
return promisify(
|
||||
(d: any, cb?: any) => api.setBadgeBackgroundColor(d, cb),
|
||||
details,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Platform detection for device registration ───
|
||||
|
||||
export function getPlatformName(): string {
|
||||
switch (BROWSER) {
|
||||
case "firefox":
|
||||
return "firefox-extension";
|
||||
case "safari":
|
||||
return "safari-extension";
|
||||
case "edge":
|
||||
return "edge-extension";
|
||||
case "chrome":
|
||||
default:
|
||||
return "chrome-extension";
|
||||
}
|
||||
}
|
||||
168
extension/src/lib/connection.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Connection manager — handles WebSocket and HTTP polling connections to the relay server.
|
||||
*/
|
||||
import { toHex } from "./hex";
|
||||
import type { DeviceKeyPair } from "./crypto";
|
||||
import { deviceIdFromKeys, sign } from "./crypto";
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
PING_INTERVAL_MS,
|
||||
PONG_TIMEOUT_MS,
|
||||
type Envelope,
|
||||
} from "./protocol";
|
||||
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
|
||||
|
||||
export interface ConnectionEvents {
|
||||
onMessage: (envelope: Envelope) => void;
|
||||
onStatusChange: (status: ConnectionStatus) => void;
|
||||
}
|
||||
|
||||
export class ConnectionManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay = 1000;
|
||||
private _status: ConnectionStatus = "disconnected";
|
||||
private serverUrl: string;
|
||||
private token: string;
|
||||
private keys: DeviceKeyPair;
|
||||
private events: ConnectionEvents;
|
||||
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
keys: DeviceKeyPair,
|
||||
events: ConnectionEvents,
|
||||
) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.token = token;
|
||||
this.keys = keys;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
get status(): ConnectionStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
private setStatus(s: ConnectionStatus) {
|
||||
this._status = s;
|
||||
this.events.onStatusChange(s);
|
||||
}
|
||||
|
||||
/** Connect via WebSocket. */
|
||||
connect() {
|
||||
if (this.ws) this.disconnect();
|
||||
|
||||
this.setStatus("connecting");
|
||||
const wsUrl = this.serverUrl
|
||||
.replace(/^http/, "ws")
|
||||
.replace(/\/$/, "");
|
||||
this.ws = new WebSocket(`${wsUrl}/ws?token=${encodeURIComponent(this.token)}`);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setStatus("connected");
|
||||
this.reconnectDelay = 1000;
|
||||
this.startPing();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string);
|
||||
if (data.type === MESSAGE_TYPES.PONG) {
|
||||
this.handlePong();
|
||||
return;
|
||||
}
|
||||
if (data.type === MESSAGE_TYPES.PING) {
|
||||
this.sendRaw({ type: MESSAGE_TYPES.PONG });
|
||||
return;
|
||||
}
|
||||
this.events.onMessage(data as Envelope);
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.cleanup();
|
||||
this.setStatus("disconnected");
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.cleanup();
|
||||
this.setStatus("error");
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.cleanup();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.setStatus("disconnected");
|
||||
}
|
||||
|
||||
/** Send an envelope over WebSocket. */
|
||||
send(envelope: Envelope) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(envelope));
|
||||
}
|
||||
}
|
||||
|
||||
private sendRaw(data: unknown) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
private startPing() {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.sendRaw({ type: MESSAGE_TYPES.PING });
|
||||
this.pongTimer = setTimeout(() => {
|
||||
// No pong received, connection is dead
|
||||
this.ws?.close();
|
||||
}, PONG_TIMEOUT_MS);
|
||||
}, PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopPing() {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
if (this.pongTimer) {
|
||||
clearTimeout(this.pongTimer);
|
||||
this.pongTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handlePong() {
|
||||
if (this.pongTimer) {
|
||||
clearTimeout(this.pongTimer);
|
||||
this.pongTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.stopPing();
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
// Exponential backoff, max 30s
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
||||
}
|
||||
}
|
||||
217
extension/src/lib/crypto.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Browser-compatible crypto module using libsodium-wrappers-sumo.
|
||||
* Mirrors the server's sodium-native API but runs in the browser.
|
||||
*/
|
||||
import _sodium from "libsodium-wrappers-sumo";
|
||||
import { toHex, fromHex, toBase64, fromBase64 } from "./hex";
|
||||
|
||||
let sodiumReady: Promise<typeof _sodium> | null = null;
|
||||
|
||||
async function getSodium(): Promise<typeof _sodium> {
|
||||
if (!sodiumReady) {
|
||||
sodiumReady = _sodium.ready.then(() => _sodium);
|
||||
}
|
||||
return sodiumReady;
|
||||
}
|
||||
|
||||
// --- Key Types ---
|
||||
|
||||
export interface DeviceKeyPair {
|
||||
signPub: Uint8Array;
|
||||
signSec: Uint8Array;
|
||||
encPub: Uint8Array;
|
||||
encSec: Uint8Array;
|
||||
}
|
||||
|
||||
export interface SerializedKeyPair {
|
||||
signPub: string; // hex
|
||||
signSec: string; // hex
|
||||
encPub: string; // hex
|
||||
encSec: string; // hex
|
||||
}
|
||||
|
||||
// --- Key Generation ---
|
||||
|
||||
export async function generateKeyPair(): Promise<DeviceKeyPair> {
|
||||
const sodium = await getSodium();
|
||||
const signKp = sodium.crypto_sign_keypair();
|
||||
const encKp = sodium.crypto_box_keypair();
|
||||
return {
|
||||
signPub: signKp.publicKey,
|
||||
signSec: signKp.privateKey,
|
||||
encPub: encKp.publicKey,
|
||||
encSec: encKp.privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function deviceIdFromKeys(keys: DeviceKeyPair): string {
|
||||
return toHex(keys.signPub);
|
||||
}
|
||||
|
||||
export function serializeKeyPair(keys: DeviceKeyPair): SerializedKeyPair {
|
||||
return {
|
||||
signPub: toHex(keys.signPub),
|
||||
signSec: toHex(keys.signSec),
|
||||
encPub: toHex(keys.encPub),
|
||||
encSec: toHex(keys.encSec),
|
||||
};
|
||||
}
|
||||
|
||||
export function deserializeKeyPair(data: SerializedKeyPair): DeviceKeyPair {
|
||||
return {
|
||||
signPub: fromHex(data.signPub),
|
||||
signSec: fromHex(data.signSec),
|
||||
encPub: fromHex(data.encPub),
|
||||
encSec: fromHex(data.encSec),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Encryption ---
|
||||
|
||||
export async function deriveSharedKey(
|
||||
ourEncSec: Uint8Array,
|
||||
peerEncPub: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const sodium = await getSodium();
|
||||
const raw = sodium.crypto_scalarmult(ourEncSec, peerEncPub);
|
||||
return sodium.crypto_generichash(32, raw);
|
||||
}
|
||||
|
||||
export async function encrypt(
|
||||
plaintext: Uint8Array,
|
||||
sharedKey: Uint8Array,
|
||||
): Promise<{ nonce: Uint8Array; ciphertext: Uint8Array }> {
|
||||
const sodium = await getSodium();
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES,
|
||||
);
|
||||
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
plaintext,
|
||||
null, // no additional data
|
||||
null, // unused nsec
|
||||
nonce,
|
||||
sharedKey,
|
||||
);
|
||||
return { nonce, ciphertext };
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
sharedKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const sodium = await getSodium();
|
||||
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
null, // unused nsec
|
||||
ciphertext,
|
||||
null, // no additional data
|
||||
nonce,
|
||||
sharedKey,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Signing ---
|
||||
|
||||
export async function sign(
|
||||
message: Uint8Array,
|
||||
signSec: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const sodium = await getSodium();
|
||||
return sodium.crypto_sign_detached(message, signSec);
|
||||
}
|
||||
|
||||
export async function verify(
|
||||
message: Uint8Array,
|
||||
sig: Uint8Array,
|
||||
signPub: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
const sodium = await getSodium();
|
||||
try {
|
||||
return sodium.crypto_sign_verify_detached(sig, message, signPub);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSignablePayload(fields: {
|
||||
type: string;
|
||||
from: string;
|
||||
to: string;
|
||||
nonce: string;
|
||||
payload: string;
|
||||
timestamp: string;
|
||||
}): Uint8Array {
|
||||
const str =
|
||||
fields.type +
|
||||
fields.from +
|
||||
fields.to +
|
||||
fields.nonce +
|
||||
fields.payload +
|
||||
fields.timestamp;
|
||||
return new TextEncoder().encode(str);
|
||||
}
|
||||
|
||||
// --- Envelope helpers ---
|
||||
|
||||
export async function buildEnvelope(
|
||||
type: string,
|
||||
payload: object,
|
||||
senderKeys: DeviceKeyPair,
|
||||
peerEncPub: Uint8Array,
|
||||
peerDeviceId: string,
|
||||
): Promise<{
|
||||
type: string;
|
||||
from: string;
|
||||
to: string;
|
||||
nonce: string;
|
||||
payload: string;
|
||||
timestamp: string;
|
||||
sig: string;
|
||||
}> {
|
||||
const fromId = deviceIdFromKeys(senderKeys);
|
||||
const sharedKey = await deriveSharedKey(senderKeys.encSec, peerEncPub);
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
||||
const { nonce, ciphertext } = await encrypt(plaintext, sharedKey);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonceHex = toHex(nonce);
|
||||
const payloadB64 = toBase64(ciphertext);
|
||||
|
||||
const signable = buildSignablePayload({
|
||||
type,
|
||||
from: fromId,
|
||||
to: peerDeviceId,
|
||||
nonce: nonceHex,
|
||||
payload: payloadB64,
|
||||
timestamp,
|
||||
});
|
||||
const sig = await sign(signable, senderKeys.signSec);
|
||||
|
||||
return {
|
||||
type,
|
||||
from: fromId,
|
||||
to: peerDeviceId,
|
||||
nonce: nonceHex,
|
||||
payload: payloadB64,
|
||||
timestamp,
|
||||
sig: toHex(sig),
|
||||
};
|
||||
}
|
||||
|
||||
export async function openEnvelope(
|
||||
envelope: {
|
||||
nonce: string;
|
||||
payload: string;
|
||||
},
|
||||
receiverKeys: DeviceKeyPair,
|
||||
peerEncPub: Uint8Array,
|
||||
): Promise<unknown> {
|
||||
const sharedKey = await deriveSharedKey(receiverKeys.encSec, peerEncPub);
|
||||
const nonce = fromHex(envelope.nonce);
|
||||
const ciphertext = fromBase64(envelope.payload);
|
||||
const plaintext = await decrypt(ciphertext, nonce, sharedKey);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext));
|
||||
}
|
||||
|
||||
// Re-export hex utils
|
||||
export { toHex, fromHex, toBase64, fromBase64 };
|
||||
34
extension/src/lib/hex.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** Convert Uint8Array to hex string. */
|
||||
export function toHex(buf: Uint8Array): string {
|
||||
return Array.from(buf)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Convert hex string to Uint8Array. */
|
||||
export function fromHex(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Convert Uint8Array to base64 string. */
|
||||
export function toBase64(buf: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
binary += String.fromCharCode(buf[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Convert base64 string to Uint8Array. */
|
||||
export function fromBase64(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
40
extension/src/lib/libsodium-wrappers-sumo.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
declare module "libsodium-wrappers-sumo" {
|
||||
interface KeyPair {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
keyType: string;
|
||||
}
|
||||
|
||||
interface Sodium {
|
||||
ready: Promise<void>;
|
||||
crypto_sign_keypair(): KeyPair;
|
||||
crypto_box_keypair(): KeyPair;
|
||||
crypto_scalarmult(privateKey: Uint8Array, publicKey: Uint8Array): Uint8Array;
|
||||
crypto_generichash(hashLength: number, message: Uint8Array): Uint8Array;
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
message: Uint8Array,
|
||||
additionalData: Uint8Array | null,
|
||||
nsec: Uint8Array | null,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Uint8Array;
|
||||
crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
nsec: Uint8Array | null,
|
||||
ciphertext: Uint8Array,
|
||||
additionalData: Uint8Array | null,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Uint8Array;
|
||||
crypto_aead_xchacha20poly1305_ietf_NPUBBYTES: number;
|
||||
crypto_sign_detached(message: Uint8Array, privateKey: Uint8Array): Uint8Array;
|
||||
crypto_sign_verify_detached(
|
||||
signature: Uint8Array,
|
||||
message: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
): boolean;
|
||||
randombytes_buf(length: number): Uint8Array;
|
||||
}
|
||||
|
||||
const sodium: Sodium;
|
||||
export default sodium;
|
||||
}
|
||||
83
extension/src/lib/protocol.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Protocol types — mirrors the server's protocol/spec.ts for use in the extension.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "2.0.0";
|
||||
export const MAX_STORED_COOKIES_PER_DEVICE = 10_000;
|
||||
export const PAIRING_CODE_LENGTH = 6;
|
||||
export const PAIRING_TTL_MS = 5 * 60 * 1000;
|
||||
export const NONCE_BYTES = 24;
|
||||
export const PING_INTERVAL_MS = 30_000;
|
||||
export const PONG_TIMEOUT_MS = 10_000;
|
||||
export const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
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];
|
||||
|
||||
export interface Envelope {
|
||||
type: MessageType;
|
||||
from: string;
|
||||
to: string;
|
||||
nonce: string;
|
||||
payload: string;
|
||||
timestamp: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
export interface CookieEntry {
|
||||
domain: string;
|
||||
name: string;
|
||||
value: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: "strict" | "lax" | "none";
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface CookieSyncPayload {
|
||||
action: "set" | "delete";
|
||||
cookies: CookieEntry[];
|
||||
lamportTs: number;
|
||||
}
|
||||
|
||||
export interface EncryptedCookieBlob {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
domain: string;
|
||||
cookieName: string;
|
||||
path: string;
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
lamportTs: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DeviceRegisterRequest {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
encPub: string;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
encPub: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PairingResult {
|
||||
peerDeviceId: string;
|
||||
peerX25519PubKey: string;
|
||||
}
|
||||
111
extension/src/lib/storage.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Storage wrapper — provides typed access to extension state.
|
||||
* Uses the browser-agnostic compat layer.
|
||||
*/
|
||||
import type { SerializedKeyPair } from "./crypto";
|
||||
import { storage } from "./compat";
|
||||
|
||||
export interface PeerDevice {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
encPub: string; // hex
|
||||
pairedAt: string;
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
// Device identity
|
||||
keys: SerializedKeyPair | null;
|
||||
deviceId: string | null;
|
||||
deviceName: string | null;
|
||||
apiToken: string | null;
|
||||
|
||||
// Server config
|
||||
serverUrl: string;
|
||||
connectionMode: "auto" | "websocket" | "polling";
|
||||
pollIntervalSec: number;
|
||||
|
||||
// Sync settings
|
||||
autoSync: boolean;
|
||||
whitelist: string[]; // domains to sync
|
||||
blacklist: string[]; // domains to never sync (banks, etc.)
|
||||
|
||||
// Paired devices
|
||||
peers: PeerDevice[];
|
||||
|
||||
// Stats
|
||||
syncCount: number;
|
||||
lastSyncAt: string | null;
|
||||
|
||||
// Lamport clock
|
||||
lamportClock: number;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: ExtensionState = {
|
||||
keys: null,
|
||||
deviceId: null,
|
||||
deviceName: null,
|
||||
apiToken: null,
|
||||
serverUrl: "http://localhost:3000",
|
||||
connectionMode: "auto",
|
||||
pollIntervalSec: 5,
|
||||
autoSync: true,
|
||||
whitelist: [],
|
||||
blacklist: [
|
||||
"*.bank.*",
|
||||
"*.paypal.com",
|
||||
"*.stripe.com",
|
||||
"accounts.google.com",
|
||||
"login.microsoftonline.com",
|
||||
],
|
||||
peers: [],
|
||||
syncCount: 0,
|
||||
lastSyncAt: null,
|
||||
lamportClock: 0,
|
||||
};
|
||||
|
||||
/** Get full extension state, merging defaults. */
|
||||
export async function getState(): Promise<ExtensionState> {
|
||||
const data = await storage.get(null);
|
||||
return { ...DEFAULT_STATE, ...data } as ExtensionState;
|
||||
}
|
||||
|
||||
/** Update specific state fields. */
|
||||
export async function setState(
|
||||
partial: Partial<ExtensionState>,
|
||||
): Promise<void> {
|
||||
await storage.set(partial);
|
||||
}
|
||||
|
||||
/** Reset all state to defaults. */
|
||||
export async function clearState(): Promise<void> {
|
||||
await storage.clear();
|
||||
}
|
||||
|
||||
/** Check if a domain matches a pattern (supports leading wildcard *.). */
|
||||
function matchDomain(pattern: string, domain: string): boolean {
|
||||
if (pattern.startsWith("*.")) {
|
||||
const suffix = pattern.slice(1); // ".bank." or ".paypal.com"
|
||||
return domain.includes(suffix);
|
||||
}
|
||||
return domain === pattern;
|
||||
}
|
||||
|
||||
/** Check if a domain is allowed for syncing based on whitelist/blacklist. */
|
||||
export function isDomainAllowed(
|
||||
domain: string,
|
||||
whitelist: string[],
|
||||
blacklist: string[],
|
||||
): boolean {
|
||||
// Blacklist always wins
|
||||
for (const pattern of blacklist) {
|
||||
if (matchDomain(pattern, domain)) return false;
|
||||
}
|
||||
// If whitelist is empty, allow all (except blacklisted)
|
||||
if (whitelist.length === 0) return true;
|
||||
// If whitelist is non-empty, only allow whitelisted
|
||||
for (const pattern of whitelist) {
|
||||
if (matchDomain(pattern, domain)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
271
extension/src/lib/sync.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Cookie sync engine — monitors browser cookies, syncs with relay server.
|
||||
* Uses the browser-agnostic compat layer.
|
||||
*/
|
||||
import type { CookieEntry, CookieSyncPayload, Envelope } from "./protocol";
|
||||
import { MESSAGE_TYPES } from "./protocol";
|
||||
import type { DeviceKeyPair } from "./crypto";
|
||||
import {
|
||||
deviceIdFromKeys,
|
||||
buildEnvelope,
|
||||
openEnvelope,
|
||||
fromHex,
|
||||
} from "./crypto";
|
||||
import type { ConnectionManager } from "./connection";
|
||||
import type { ApiClient } from "./api-client";
|
||||
import { getState, setState, isDomainAllowed, type PeerDevice } from "./storage";
|
||||
import { cookies, tabs, type CompatCookie, type CompatCookieChangeInfo } from "./compat";
|
||||
|
||||
type CookieKey = string;
|
||||
|
||||
interface TrackedCookie {
|
||||
entry: CookieEntry;
|
||||
lamportTs: number;
|
||||
sourceDeviceId: string;
|
||||
}
|
||||
|
||||
function cookieKey(domain: string, name: string, path: string): CookieKey {
|
||||
return `${domain}|${name}|${path}`;
|
||||
}
|
||||
|
||||
function browserCookieToEntry(cookie: CompatCookie): CookieEntry {
|
||||
return {
|
||||
domain: cookie.domain,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
path: cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite === "strict"
|
||||
? "strict"
|
||||
: cookie.sameSite === "lax"
|
||||
? "lax"
|
||||
: "none",
|
||||
expiresAt: cookie.expirationDate
|
||||
? new Date(cookie.expirationDate * 1000).toISOString()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncEngine {
|
||||
private cookieMap = new Map<CookieKey, TrackedCookie>();
|
||||
private lamportClock = 0;
|
||||
private deviceId: string;
|
||||
private keys: DeviceKeyPair;
|
||||
private connection: ConnectionManager;
|
||||
private api: ApiClient;
|
||||
private suppressLocal = new Set<string>(); // keys to skip on local change (avoid echo)
|
||||
|
||||
constructor(
|
||||
keys: DeviceKeyPair,
|
||||
connection: ConnectionManager,
|
||||
api: ApiClient,
|
||||
) {
|
||||
this.keys = keys;
|
||||
this.deviceId = deviceIdFromKeys(keys);
|
||||
this.connection = connection;
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
/** Start monitoring cookie changes. */
|
||||
start() {
|
||||
cookies.onChanged.addListener(this.handleCookieChange);
|
||||
}
|
||||
|
||||
stop() {
|
||||
cookies.onChanged.removeListener(this.handleCookieChange);
|
||||
}
|
||||
|
||||
/** Handle incoming envelope from WebSocket. */
|
||||
async handleIncomingEnvelope(envelope: Envelope, peers: PeerDevice[]) {
|
||||
const peer = peers.find((p) => p.deviceId === envelope.from);
|
||||
if (!peer) return; // Unknown peer, ignore
|
||||
|
||||
const peerEncPub = fromHex(peer.encPub);
|
||||
const payload = (await openEnvelope(
|
||||
envelope,
|
||||
this.keys,
|
||||
peerEncPub,
|
||||
)) as CookieSyncPayload;
|
||||
|
||||
const applied = this.applyRemote(payload, envelope.from);
|
||||
for (const entry of applied) {
|
||||
await this.applyCookieToBrowser(entry, payload.action);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const state = await getState();
|
||||
await setState({
|
||||
syncCount: state.syncCount + applied.length,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
lamportClock: this.lamportClock,
|
||||
});
|
||||
}
|
||||
|
||||
/** Sync a specific domain's cookies to all peers. */
|
||||
async syncDomain(domain: string) {
|
||||
const allCookies = await cookies.getAll({ domain });
|
||||
if (allCookies.length === 0) return;
|
||||
|
||||
const entries = allCookies.map(browserCookieToEntry);
|
||||
await this.syncEntriesToPeers(entries, "set");
|
||||
}
|
||||
|
||||
/** Sync the current tab's cookies. */
|
||||
async syncCurrentTab() {
|
||||
const [tab] = await tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
if (!tab?.url) return;
|
||||
|
||||
try {
|
||||
const url = new URL(tab.url);
|
||||
await this.syncDomain(url.hostname);
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
}
|
||||
|
||||
get currentLamportTs(): number {
|
||||
return this.lamportClock;
|
||||
}
|
||||
|
||||
private handleCookieChange = async (
|
||||
changeInfo: CompatCookieChangeInfo,
|
||||
) => {
|
||||
const { cookie, removed, cause } = changeInfo;
|
||||
|
||||
// Skip changes caused by us applying remote cookies
|
||||
const key = cookieKey(cookie.domain, cookie.name, cookie.path);
|
||||
if (this.suppressLocal.has(key)) {
|
||||
this.suppressLocal.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only sync user-initiated changes (not expired, not evicted)
|
||||
if (cause === "expired" || cause === "evicted") return;
|
||||
|
||||
const state = await getState();
|
||||
if (!state.autoSync) return;
|
||||
if (!isDomainAllowed(cookie.domain, state.whitelist, state.blacklist))
|
||||
return;
|
||||
|
||||
const entry = browserCookieToEntry(cookie);
|
||||
const action = removed ? "delete" : "set";
|
||||
|
||||
if (!removed) {
|
||||
this.lamportClock++;
|
||||
this.cookieMap.set(key, {
|
||||
entry,
|
||||
lamportTs: this.lamportClock,
|
||||
sourceDeviceId: this.deviceId,
|
||||
});
|
||||
} else {
|
||||
this.cookieMap.delete(key);
|
||||
}
|
||||
|
||||
await this.syncEntriesToPeers([entry], action);
|
||||
await setState({ lamportClock: this.lamportClock });
|
||||
};
|
||||
|
||||
private async syncEntriesToPeers(entries: CookieEntry[], action: "set" | "delete") {
|
||||
const state = await getState();
|
||||
this.lamportClock++;
|
||||
|
||||
const payload: CookieSyncPayload = {
|
||||
action,
|
||||
cookies: entries,
|
||||
lamportTs: this.lamportClock,
|
||||
};
|
||||
|
||||
for (const peer of state.peers) {
|
||||
const peerEncPub = fromHex(peer.encPub);
|
||||
const envelope = await buildEnvelope(
|
||||
MESSAGE_TYPES.COOKIE_SYNC,
|
||||
payload,
|
||||
this.keys,
|
||||
peerEncPub,
|
||||
peer.deviceId,
|
||||
);
|
||||
this.connection.send(envelope as unknown as Envelope);
|
||||
}
|
||||
}
|
||||
|
||||
private applyRemote(
|
||||
payload: CookieSyncPayload,
|
||||
sourceDeviceId: string,
|
||||
): CookieEntry[] {
|
||||
this.lamportClock = Math.max(this.lamportClock, payload.lamportTs) + 1;
|
||||
const applied: CookieEntry[] = [];
|
||||
|
||||
for (const entry of payload.cookies) {
|
||||
const key = cookieKey(entry.domain, entry.name, entry.path);
|
||||
const existing = this.cookieMap.get(key);
|
||||
|
||||
if (payload.action === "delete") {
|
||||
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
|
||||
this.cookieMap.delete(key);
|
||||
applied.push(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.shouldApply(existing, payload.lamportTs, sourceDeviceId)) {
|
||||
this.cookieMap.set(key, {
|
||||
entry,
|
||||
lamportTs: payload.lamportTs,
|
||||
sourceDeviceId,
|
||||
});
|
||||
applied.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private async applyCookieToBrowser(entry: CookieEntry, action: "set" | "delete") {
|
||||
const key = cookieKey(entry.domain, entry.name, entry.path);
|
||||
this.suppressLocal.add(key);
|
||||
|
||||
const url = `http${entry.secure ? "s" : ""}://${entry.domain.replace(/^\./, "")}${entry.path}`;
|
||||
|
||||
if (action === "delete") {
|
||||
await cookies.remove({ url, name: entry.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const details: Record<string, any> = {
|
||||
url,
|
||||
name: entry.name,
|
||||
value: entry.value,
|
||||
path: entry.path,
|
||||
secure: entry.secure,
|
||||
httpOnly: entry.httpOnly,
|
||||
sameSite: entry.sameSite === "strict"
|
||||
? "strict"
|
||||
: entry.sameSite === "lax"
|
||||
? "lax"
|
||||
: "no_restriction",
|
||||
};
|
||||
|
||||
if (entry.expiresAt) {
|
||||
details.expirationDate = new Date(entry.expiresAt).getTime() / 1000;
|
||||
}
|
||||
|
||||
await cookies.set(details);
|
||||
}
|
||||
}
|
||||
305
extension/src/options/options.css
Normal file
@@ -0,0 +1,305 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
background: #f9fafb;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field .value {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.field .value.mono {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field input[type="text"],
|
||||
.field select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.field input[type="text"]:focus,
|
||||
.field select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.field-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Range */
|
||||
.range-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.range-group input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.range-group span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-label input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: #d1d5db;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #ffffff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-label input:checked + .toggle {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.toggle-label input:checked + .toggle::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag .remove {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #93c5fd;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.tag .remove:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.add-tag {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-tag input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-tag input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Peer List */
|
||||
.peer-list {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.peer-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.peer-info .peer-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.peer-info .peer-id {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.peer-info .peer-date {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Save Bar */
|
||||
.save-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
font-size: 13px;
|
||||
color: #059669;
|
||||
}
|
||||
114
extension/src/options/options.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CookieBridge Settings</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>CookieBridge Settings</h1>
|
||||
</header>
|
||||
|
||||
<!-- Account Section -->
|
||||
<section class="section">
|
||||
<h2>Account</h2>
|
||||
<div class="field">
|
||||
<label>Device Name</label>
|
||||
<span id="opt-device-name" class="value">—</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Device ID</label>
|
||||
<span id="opt-device-id" class="value mono">—</span>
|
||||
</div>
|
||||
<div class="field-actions">
|
||||
<button id="btn-logout" class="btn btn-danger">Log Out</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Connection Section -->
|
||||
<section class="section">
|
||||
<h2>Connection</h2>
|
||||
<div class="field">
|
||||
<label for="opt-server-url">Server URL</label>
|
||||
<input type="text" id="opt-server-url" placeholder="http://localhost:3000" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="opt-connection-mode">Connection Mode</label>
|
||||
<select id="opt-connection-mode">
|
||||
<option value="auto">Auto (WebSocket preferred)</option>
|
||||
<option value="websocket">WebSocket Only</option>
|
||||
<option value="polling">HTTP Polling</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="field-poll-interval">
|
||||
<label for="opt-poll-interval">Poll Interval</label>
|
||||
<div class="range-group">
|
||||
<input type="range" id="opt-poll-interval" min="1" max="60" value="5" />
|
||||
<span id="opt-poll-interval-label">5s</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sync Section -->
|
||||
<section class="section">
|
||||
<h2>Sync</h2>
|
||||
<div class="field">
|
||||
<label class="toggle-label">
|
||||
<span>Auto-sync cookies</span>
|
||||
<input type="checkbox" id="opt-auto-sync" />
|
||||
<span class="toggle"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Whitelist (sync only these domains)</label>
|
||||
<div class="tag-list" id="whitelist-tags"></div>
|
||||
<div class="add-tag">
|
||||
<input type="text" id="whitelist-input" placeholder="example.com" />
|
||||
<button id="btn-add-whitelist" class="btn btn-small btn-secondary">Add</button>
|
||||
</div>
|
||||
<p class="field-hint">Leave empty to sync all domains (except blacklisted)</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Blacklist (never sync these domains)</label>
|
||||
<div class="tag-list" id="blacklist-tags"></div>
|
||||
<div class="add-tag">
|
||||
<input type="text" id="blacklist-input" placeholder="*.bank.com" />
|
||||
<button id="btn-add-blacklist" class="btn btn-small btn-secondary">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Paired Devices Section -->
|
||||
<section class="section">
|
||||
<h2>Paired Devices</h2>
|
||||
<div id="peer-list" class="peer-list">
|
||||
<p class="empty-state">No paired devices yet</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Security Section -->
|
||||
<section class="section">
|
||||
<h2>Security</h2>
|
||||
<div class="field-actions">
|
||||
<button id="btn-export-keys" class="btn btn-secondary">Export Keys</button>
|
||||
<button id="btn-import-keys" class="btn btn-secondary">Import Keys</button>
|
||||
<input type="file" id="file-import-keys" accept=".json" style="display: none" />
|
||||
</div>
|
||||
<div class="field-actions" style="margin-top: 12px;">
|
||||
<button id="btn-clear-data" class="btn btn-danger">Clear All Local Data</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Save -->
|
||||
<div class="save-bar">
|
||||
<button id="btn-save" class="btn btn-primary">Save Settings</button>
|
||||
<span id="save-status" class="save-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../../dist/options/options.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
252
extension/src/options/options.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Options page script — manages extension settings.
|
||||
* Uses the browser-agnostic compat layer.
|
||||
*/
|
||||
import { runtime, storage } from "../lib/compat";
|
||||
|
||||
export {};
|
||||
|
||||
// --- Messaging helper ---
|
||||
|
||||
async function sendMessage(type: string, payload?: unknown): Promise<any> {
|
||||
const response = await runtime.sendMessage({ type, payload });
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- Elements ---
|
||||
|
||||
const optDeviceName = document.getElementById("opt-device-name")!;
|
||||
const optDeviceId = document.getElementById("opt-device-id")!;
|
||||
const btnLogout = document.getElementById("btn-logout") as HTMLButtonElement;
|
||||
|
||||
const optServerUrl = document.getElementById("opt-server-url") as HTMLInputElement;
|
||||
const optConnectionMode = document.getElementById("opt-connection-mode") as HTMLSelectElement;
|
||||
const optPollInterval = document.getElementById("opt-poll-interval") as HTMLInputElement;
|
||||
const optPollIntervalLabel = document.getElementById("opt-poll-interval-label")!;
|
||||
const fieldPollInterval = document.getElementById("field-poll-interval")!;
|
||||
|
||||
const optAutoSync = document.getElementById("opt-auto-sync") as HTMLInputElement;
|
||||
const whitelistTags = document.getElementById("whitelist-tags")!;
|
||||
const whitelistInput = document.getElementById("whitelist-input") as HTMLInputElement;
|
||||
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
|
||||
const blacklistTags = document.getElementById("blacklist-tags")!;
|
||||
const blacklistInput = document.getElementById("blacklist-input") as HTMLInputElement;
|
||||
const btnAddBlacklist = document.getElementById("btn-add-blacklist") as HTMLButtonElement;
|
||||
|
||||
const peerList = document.getElementById("peer-list")!;
|
||||
|
||||
const btnExportKeys = document.getElementById("btn-export-keys") as HTMLButtonElement;
|
||||
const btnImportKeys = document.getElementById("btn-import-keys") as HTMLButtonElement;
|
||||
const fileImportKeys = document.getElementById("file-import-keys") as HTMLInputElement;
|
||||
const btnClearData = document.getElementById("btn-clear-data") as HTMLButtonElement;
|
||||
|
||||
const btnSave = document.getElementById("btn-save") as HTMLButtonElement;
|
||||
const saveStatus = document.getElementById("save-status")!;
|
||||
|
||||
// --- State ---
|
||||
|
||||
let whitelist: string[] = [];
|
||||
let blacklist: string[] = [];
|
||||
|
||||
// --- Load Settings ---
|
||||
|
||||
async function loadSettings() {
|
||||
const state = await storage.get(null);
|
||||
|
||||
optDeviceName.textContent = state.deviceName || "—";
|
||||
optDeviceId.textContent = state.deviceId || "—";
|
||||
|
||||
optServerUrl.value = state.serverUrl || "http://localhost:3000";
|
||||
optConnectionMode.value = state.connectionMode || "auto";
|
||||
optPollInterval.value = String(state.pollIntervalSec || 5);
|
||||
optPollIntervalLabel.textContent = `${state.pollIntervalSec || 5}s`;
|
||||
updatePollVisibility();
|
||||
|
||||
optAutoSync.checked = state.autoSync !== false;
|
||||
|
||||
whitelist = state.whitelist || [];
|
||||
blacklist = state.blacklist || [
|
||||
"*.bank.*",
|
||||
"*.paypal.com",
|
||||
"*.stripe.com",
|
||||
"accounts.google.com",
|
||||
"login.microsoftonline.com",
|
||||
];
|
||||
|
||||
renderTags(whitelistTags, whitelist, "whitelist");
|
||||
renderTags(blacklistTags, blacklist, "blacklist");
|
||||
renderPeers(state.peers || []);
|
||||
}
|
||||
|
||||
function renderTags(container: HTMLElement, items: string[], listName: string) {
|
||||
container.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "tag";
|
||||
tag.innerHTML = `${item}<span class="remove" data-list="${listName}" data-value="${item}">×</span>`;
|
||||
container.appendChild(tag);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPeers(peers: Array<{ deviceId: string; name: string; platform: string; pairedAt: string }>) {
|
||||
if (peers.length === 0) {
|
||||
peerList.innerHTML = '<p class="empty-state">No paired devices yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
peerList.innerHTML = "";
|
||||
for (const peer of peers) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "peer-item";
|
||||
item.innerHTML = `
|
||||
<div class="peer-info">
|
||||
<div class="peer-name">${peer.name} (${peer.platform})</div>
|
||||
<div class="peer-id">${peer.deviceId.slice(0, 16)}...</div>
|
||||
<div class="peer-date">Paired: ${new Date(peer.pairedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
`;
|
||||
peerList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePollVisibility() {
|
||||
fieldPollInterval.style.display =
|
||||
optConnectionMode.value === "polling" ? "block" : "none";
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
|
||||
optConnectionMode.addEventListener("change", updatePollVisibility);
|
||||
|
||||
optPollInterval.addEventListener("input", () => {
|
||||
optPollIntervalLabel.textContent = `${optPollInterval.value}s`;
|
||||
});
|
||||
|
||||
// Tag removal (event delegation)
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.classList.contains("remove")) return;
|
||||
|
||||
const listName = target.dataset.list;
|
||||
const value = target.dataset.value;
|
||||
if (!listName || !value) return;
|
||||
|
||||
if (listName === "whitelist") {
|
||||
whitelist = whitelist.filter((d) => d !== value);
|
||||
renderTags(whitelistTags, whitelist, "whitelist");
|
||||
} else {
|
||||
blacklist = blacklist.filter((d) => d !== value);
|
||||
renderTags(blacklistTags, blacklist, "blacklist");
|
||||
}
|
||||
});
|
||||
|
||||
btnAddWhitelist.addEventListener("click", () => {
|
||||
const domain = whitelistInput.value.trim();
|
||||
if (domain && !whitelist.includes(domain)) {
|
||||
whitelist.push(domain);
|
||||
renderTags(whitelistTags, whitelist, "whitelist");
|
||||
whitelistInput.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
btnAddBlacklist.addEventListener("click", () => {
|
||||
const domain = blacklistInput.value.trim();
|
||||
if (domain && !blacklist.includes(domain)) {
|
||||
blacklist.push(domain);
|
||||
renderTags(blacklistTags, blacklist, "blacklist");
|
||||
blacklistInput.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Save
|
||||
btnSave.addEventListener("click", async () => {
|
||||
await storage.set({
|
||||
serverUrl: optServerUrl.value.trim(),
|
||||
connectionMode: optConnectionMode.value,
|
||||
pollIntervalSec: parseInt(optPollInterval.value),
|
||||
autoSync: optAutoSync.checked,
|
||||
whitelist,
|
||||
blacklist,
|
||||
});
|
||||
|
||||
// Reconnect with new settings
|
||||
await sendMessage("RECONNECT");
|
||||
|
||||
saveStatus.textContent = "Saved!";
|
||||
setTimeout(() => {
|
||||
saveStatus.textContent = "";
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Logout
|
||||
btnLogout.addEventListener("click", async () => {
|
||||
if (!confirm("Are you sure you want to log out? This will remove your device identity.")) {
|
||||
return;
|
||||
}
|
||||
await sendMessage("LOGOUT");
|
||||
window.close();
|
||||
});
|
||||
|
||||
// Export keys
|
||||
btnExportKeys.addEventListener("click", async () => {
|
||||
const result = await sendMessage("EXPORT_KEYS");
|
||||
if (!result?.keys) {
|
||||
alert("No keys to export");
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(result.keys, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "cookiebridge-keys.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Import keys
|
||||
btnImportKeys.addEventListener("click", () => {
|
||||
fileImportKeys.click();
|
||||
});
|
||||
|
||||
fileImportKeys.addEventListener("change", async () => {
|
||||
const file = fileImportKeys.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const keys = JSON.parse(text);
|
||||
if (!keys.signPub || !keys.signSec || !keys.encPub || !keys.encSec) {
|
||||
throw new Error("Invalid key file");
|
||||
}
|
||||
await sendMessage("IMPORT_KEYS", keys);
|
||||
alert("Keys imported successfully. The extension will reconnect.");
|
||||
await loadSettings();
|
||||
} catch (err) {
|
||||
alert(`Failed to import keys: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear data
|
||||
btnClearData.addEventListener("click", async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"This will delete ALL local data including your encryption keys. Are you sure?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await sendMessage("LOGOUT");
|
||||
await storage.clear();
|
||||
alert("All data cleared.");
|
||||
window.close();
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
|
||||
loadSettings();
|
||||
317
extension/src/popup/popup.css
Normal file
@@ -0,0 +1,317 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 340px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.view {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Login View */
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
padding: 24px 0 16px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.link {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
width: auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #eff6ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-id {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Status Badge */
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge.connected {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-badge.connected .dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-badge.disconnected {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.status-badge.disconnected .dot {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-badge.error .dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.status-badge.connecting {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.status-badge.connecting .dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* Stats Row */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 12px 0;
|
||||
margin-bottom: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Pairing Dialog */
|
||||
.dialog {
|
||||
background: #f9fafb;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pairing-code {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
letter-spacing: 8px;
|
||||
color: #3b82f6;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pairing-input {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.dialog-hint {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
91
extension/src/popup/popup.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CookieBridge</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Not Logged In View -->
|
||||
<div id="view-login" class="view" style="display: none;">
|
||||
<div class="logo-section">
|
||||
<div class="logo">🍪</div>
|
||||
<h1>CookieBridge</h1>
|
||||
<p class="subtitle">Cross-device cookie sync</p>
|
||||
</div>
|
||||
<div class="login-section">
|
||||
<input type="text" id="device-name" placeholder="Device name (e.g. MacBook Pro)" />
|
||||
<button id="btn-register" class="btn btn-primary">Register Device</button>
|
||||
<a href="#" id="link-first-use" class="link">First time setup guide</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logged In View -->
|
||||
<div id="view-main" class="view" style="display: none;">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="user-info">
|
||||
<div class="avatar">🍪</div>
|
||||
<div class="user-details">
|
||||
<span id="display-name" class="name"></span>
|
||||
<span id="display-device-id" class="device-id"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="connection-status" class="status-badge disconnected">
|
||||
<span class="dot"></span>
|
||||
<span class="label">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat">
|
||||
<span id="stat-cookies" class="stat-value">0</span>
|
||||
<span class="stat-label">Cookies</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span id="stat-devices" class="stat-value">0</span>
|
||||
<span class="stat-label">Devices</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span id="stat-syncs" class="stat-value">0</span>
|
||||
<span class="stat-label">Syncs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button id="btn-sync-tab" class="btn btn-primary">Sync Current Site</button>
|
||||
<button id="btn-add-whitelist" class="btn btn-secondary">Add to Whitelist</button>
|
||||
<button id="btn-pair" class="btn btn-secondary">Pair Device</button>
|
||||
</div>
|
||||
|
||||
<!-- Pairing Dialog (hidden by default) -->
|
||||
<div id="pairing-dialog" class="dialog" style="display: none;">
|
||||
<div id="pairing-initiate" style="display: none;">
|
||||
<p class="dialog-title">Your Pairing Code</p>
|
||||
<div id="pairing-code" class="pairing-code"></div>
|
||||
<p class="dialog-hint">Enter this code on the other device within 5 minutes</p>
|
||||
</div>
|
||||
<div id="pairing-accept">
|
||||
<p class="dialog-title">Enter Pairing Code</p>
|
||||
<input type="text" id="input-pairing-code" placeholder="000000" maxlength="6" class="pairing-input" />
|
||||
<div class="dialog-actions">
|
||||
<button id="btn-pairing-accept" class="btn btn-primary btn-small">Pair</button>
|
||||
<button id="btn-pairing-generate" class="btn btn-secondary btn-small">Generate Code</button>
|
||||
<button id="btn-pairing-cancel" class="btn btn-ghost btn-small">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<a href="#" id="btn-settings" class="footer-link">Settings</a>
|
||||
<a href="https://github.com/Rc707Agency/cookiebridge" target="_blank" class="footer-link">Help</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../../dist/popup/popup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
201
extension/src/popup/popup.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Popup script — controls the popup UI interactions.
|
||||
* Uses the browser-agnostic compat layer.
|
||||
*/
|
||||
import { runtime, tabs } from "../lib/compat";
|
||||
|
||||
export {};
|
||||
|
||||
// --- Elements ---
|
||||
|
||||
const viewLogin = document.getElementById("view-login")!;
|
||||
const viewMain = document.getElementById("view-main")!;
|
||||
|
||||
// Login
|
||||
const inputDeviceName = document.getElementById("device-name") as HTMLInputElement;
|
||||
const btnRegister = document.getElementById("btn-register") as HTMLButtonElement;
|
||||
|
||||
// Main header
|
||||
const displayName = document.getElementById("display-name")!;
|
||||
const displayDeviceId = document.getElementById("display-device-id")!;
|
||||
const connectionStatus = document.getElementById("connection-status")!;
|
||||
|
||||
// Stats
|
||||
const statCookies = document.getElementById("stat-cookies")!;
|
||||
const statDevices = document.getElementById("stat-devices")!;
|
||||
const statSyncs = document.getElementById("stat-syncs")!;
|
||||
|
||||
// Actions
|
||||
const btnSyncTab = document.getElementById("btn-sync-tab") as HTMLButtonElement;
|
||||
const btnAddWhitelist = document.getElementById("btn-add-whitelist") as HTMLButtonElement;
|
||||
const btnPair = document.getElementById("btn-pair") as HTMLButtonElement;
|
||||
const btnSettings = document.getElementById("btn-settings")!;
|
||||
|
||||
// Pairing
|
||||
const pairingDialog = document.getElementById("pairing-dialog")!;
|
||||
const pairingInitiate = document.getElementById("pairing-initiate")!;
|
||||
const pairingAccept = document.getElementById("pairing-accept")!;
|
||||
const pairingCode = document.getElementById("pairing-code")!;
|
||||
const inputPairingCode = document.getElementById("input-pairing-code") as HTMLInputElement;
|
||||
const btnPairingAccept = document.getElementById("btn-pairing-accept") as HTMLButtonElement;
|
||||
const btnPairingGenerate = document.getElementById("btn-pairing-generate") as HTMLButtonElement;
|
||||
const btnPairingCancel = document.getElementById("btn-pairing-cancel") as HTMLButtonElement;
|
||||
|
||||
// --- Messaging helper ---
|
||||
|
||||
async function sendMessage(type: string, payload?: unknown): Promise<any> {
|
||||
const response = await runtime.sendMessage({ type, payload });
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- UI Updates ---
|
||||
|
||||
function showView(loggedIn: boolean) {
|
||||
viewLogin.style.display = loggedIn ? "none" : "block";
|
||||
viewMain.style.display = loggedIn ? "block" : "none";
|
||||
}
|
||||
|
||||
function updateConnectionBadge(status: string) {
|
||||
connectionStatus.className = `status-badge ${status}`;
|
||||
const label = connectionStatus.querySelector(".label")!;
|
||||
const labels: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting...",
|
||||
error: "Error",
|
||||
};
|
||||
label.textContent = labels[status] || status;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const status = await sendMessage("GET_STATUS");
|
||||
showView(status.loggedIn);
|
||||
|
||||
if (status.loggedIn) {
|
||||
displayName.textContent = status.deviceName || "My Device";
|
||||
displayDeviceId.textContent = status.deviceId
|
||||
? status.deviceId.slice(0, 12) + "..."
|
||||
: "";
|
||||
updateConnectionBadge(status.connectionStatus);
|
||||
statCookies.textContent = String(status.cookieCount);
|
||||
statDevices.textContent = String(status.peerCount);
|
||||
statSyncs.textContent = String(status.syncCount);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get status:", err);
|
||||
showView(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
|
||||
btnRegister.addEventListener("click", async () => {
|
||||
const name = inputDeviceName.value.trim();
|
||||
if (!name) {
|
||||
inputDeviceName.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btnRegister.disabled = true;
|
||||
btnRegister.textContent = "Registering...";
|
||||
|
||||
try {
|
||||
await sendMessage("REGISTER_DEVICE", { name });
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
console.error("Registration failed:", err);
|
||||
btnRegister.textContent = "Registration Failed";
|
||||
setTimeout(() => {
|
||||
btnRegister.textContent = "Register Device";
|
||||
btnRegister.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
btnSyncTab.addEventListener("click", async () => {
|
||||
btnSyncTab.disabled = true;
|
||||
btnSyncTab.textContent = "Syncing...";
|
||||
try {
|
||||
await sendMessage("SYNC_CURRENT_TAB");
|
||||
btnSyncTab.textContent = "Synced!";
|
||||
setTimeout(() => {
|
||||
btnSyncTab.textContent = "Sync Current Site";
|
||||
btnSyncTab.disabled = false;
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error("Sync failed:", err);
|
||||
btnSyncTab.textContent = "Sync Failed";
|
||||
setTimeout(() => {
|
||||
btnSyncTab.textContent = "Sync Current Site";
|
||||
btnSyncTab.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
btnAddWhitelist.addEventListener("click", async () => {
|
||||
const [tab] = await tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.url) return;
|
||||
|
||||
try {
|
||||
const url = new URL(tab.url);
|
||||
await sendMessage("ADD_WHITELIST", { domain: url.hostname });
|
||||
btnAddWhitelist.textContent = "Added!";
|
||||
setTimeout(() => {
|
||||
btnAddWhitelist.textContent = "Add to Whitelist";
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error("Failed to add whitelist:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Pairing
|
||||
btnPair.addEventListener("click", () => {
|
||||
pairingDialog.style.display = "block";
|
||||
pairingInitiate.style.display = "none";
|
||||
pairingAccept.style.display = "block";
|
||||
inputPairingCode.value = "";
|
||||
inputPairingCode.focus();
|
||||
});
|
||||
|
||||
btnPairingGenerate.addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await sendMessage("INITIATE_PAIRING");
|
||||
pairingAccept.style.display = "none";
|
||||
pairingInitiate.style.display = "block";
|
||||
pairingCode.textContent = result.pairingCode;
|
||||
} catch (err) {
|
||||
console.error("Failed to generate pairing code:", err);
|
||||
}
|
||||
});
|
||||
|
||||
btnPairingAccept.addEventListener("click", async () => {
|
||||
const code = inputPairingCode.value.trim();
|
||||
if (code.length !== 6) return;
|
||||
|
||||
btnPairingAccept.disabled = true;
|
||||
try {
|
||||
await sendMessage("ACCEPT_PAIRING", { code });
|
||||
pairingDialog.style.display = "none";
|
||||
await refreshStatus();
|
||||
} catch (err) {
|
||||
console.error("Pairing failed:", err);
|
||||
btnPairingAccept.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
btnPairingCancel.addEventListener("click", () => {
|
||||
pairingDialog.style.display = "none";
|
||||
});
|
||||
|
||||
btnSettings.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
|
||||
refreshStatus();
|
||||
18
extension/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["chrome"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||