docs: comprehensive ARCHITECTURE, DEPLOYMENT, API, and THREAT_MODEL
ARCHITECTURE.md - Full component map with ASCII diagram - Complete session lifecycle (init + heartbeat + failure path) - Cryptographic protocol spec (hash chain formula, canonical JSON) - Stack machine instruction set table with stack effects - Behavioral validation thresholds - SQLite schema, threat model summary, module reference DEPLOYMENT.md - Build instructions (WASM + server + convenience script) - native binary, systemd (with hardened sandbox notes), Docker - nginx, Nginx Proxy Manager, and HAProxy reverse proxy configs - Integration options (sidecar vs proxy-only) - Full configuration table with all constants - Observability (RUST_LOG levels), health check, security checklist API.md - Full /init and /hb request/response schemas with field tables - Canonical signing payload specification - Complete validation rules table (all 13 rejection conditions) - Hash chain byte-level specification - WASM exported function reference THREAT_MODEL.md - Four attacker profiles (script kiddie → sophisticated adversary) - Eight attack vectors with mitigations (replay, forgery, hijack, DoS…) - Explicit out-of-scope limitations - Operational security notes (CORS, TLS, log level, SQLite)
This commit is contained in:
1 parent
851d3b4876
commit
4b27a342d1
4 files changed
+1017
-256
No files matched your search
+216
@@ -0,0 +1,216 @@
|
||||
# ChronoSeal — API Reference
|
||||
|
||||
## Base URL
|
||||
|
||||
All endpoints are relative to the server root. In development: `http://localhost:3000`.
|
||||
In production: your HTTPS domain via reverse proxy.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `POST /init`
|
||||
|
||||
Initialise a new session. Called once per page load, immediately after the
|
||||
WASM module generates an Ed25519 keypair.
|
||||
|
||||
#### Request
|
||||
|
||||
```http
|
||||
POST /init
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"public_key": "hex-encoded 32-byte Ed25519 verifying key"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `public_key` | `string` | Hex-encoded 32-byte Ed25519 verifying key generated by the WASM module |
|
||||
|
||||
#### Response `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "64-char hex string (32 bytes)",
|
||||
"salt": "32-char hex string (16 bytes)",
|
||||
"opcodes_b64": "base64-encoded VM program (8–16 opcodes)",
|
||||
"initial_hash": "64-char hex string (32 bytes Blake3)",
|
||||
"expires_at": 1234567890123
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `session_id` | `string` | Opaque session identifier; include in every heartbeat |
|
||||
| `salt` | `string` | Initial salt; used to compute `H(0)` and first `H(1)` |
|
||||
| `opcodes_b64` | `string` | Base64 VM program; execute with `run_program()` on every heartbeat |
|
||||
| `initial_hash` | `string` | `H(0) = Blake3(session_id ║ pub_key ║ salt)`; the first `prev_hash` |
|
||||
| `expires_at` | `number` | Unix timestamp in milliseconds; session expires after 30 minutes of inactivity |
|
||||
|
||||
#### Error
|
||||
|
||||
Returns `500 Internal Server Error` only on server-side failures (DB errors,
|
||||
invalid public key length). No meaningful error body is returned.
|
||||
|
||||
---
|
||||
|
||||
### `POST /hb`
|
||||
|
||||
Submit a heartbeat. Called every 12–25 seconds with uniform random jitter.
|
||||
|
||||
#### Request
|
||||
|
||||
```http
|
||||
POST /hb
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "64-char hex",
|
||||
"prev_hash": "64-char hex",
|
||||
"timestamp": 1234567890123,
|
||||
"entropy_data": {
|
||||
"events": [
|
||||
{ "x": 412.0, "y": 308.5, "t": 1234.567 },
|
||||
{ "x": 415.2, "y": 310.1, "t": 1285.123 }
|
||||
]
|
||||
},
|
||||
"stack_state": {
|
||||
"stack": [2971406957, 1234567890],
|
||||
"ip": 42
|
||||
},
|
||||
"fingerprint": {
|
||||
"aspectRatio": "1.7777777778",
|
||||
"devicePixelRatio": "2",
|
||||
"hardwareConcurrency": 8
|
||||
},
|
||||
"signature": "128-char hex Ed25519 signature"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `session_id` | `string` | Session ID from `/init` |
|
||||
| `prev_hash` | `string` | Hash chain head from previous heartbeat (or `initial_hash` for the first) |
|
||||
| `timestamp` | `number` | `Date.now()` in milliseconds; must be within ±30s of server time |
|
||||
| `entropy_data.events` | `array` | Mouse events since previous heartbeat; each has `x`, `y` (px), `t` (performance.now ms) |
|
||||
| `stack_state.stack` | `array` | `u32[]` result of executing the VM program |
|
||||
| `stack_state.ip` | `number` | Instruction pointer after execution |
|
||||
| `fingerprint.aspectRatio` | `string` | `(screen.width / screen.height).toFixed(10)` |
|
||||
| `fingerprint.devicePixelRatio` | `string` | `String(window.devicePixelRatio)` |
|
||||
| `fingerprint.hardwareConcurrency` | `number` | `navigator.hardwareConcurrency \|\| 1` |
|
||||
| `signature` | `string` | Hex-encoded 64-byte Ed25519 signature over the canonical payload |
|
||||
|
||||
#### Canonical Signing Payload
|
||||
|
||||
The client signs the following JSON object. Top-level keys must be sorted
|
||||
alphabetically. Nested object keys follow their natural serialisation order.
|
||||
|
||||
```json
|
||||
{
|
||||
"entropyData": { "events": [{ "t": …, "x": …, "y": … }] },
|
||||
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
|
||||
"prevHash": "…",
|
||||
"sessionId": "…",
|
||||
"stackState": { "ip": …, "stack": […] },
|
||||
"timestamp": …
|
||||
}
|
||||
```
|
||||
|
||||
Note: field names in the signing payload use camelCase (`sessionId`,
|
||||
`prevHash`, `entropyData`, `stackState`) while the request body uses
|
||||
snake_case (`session_id`, `prev_hash`, `entropy_data`, `stack_state`).
|
||||
|
||||
#### Response `200 OK` — Accepted
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"next_salt": "32-char hex string (16 bytes)"
|
||||
}
|
||||
```
|
||||
|
||||
The client must:
|
||||
1. Capture `sentSalt = currentSalt` before updating.
|
||||
2. Set `currentSalt = next_salt`.
|
||||
3. Compute `prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt)`.
|
||||
|
||||
#### Response `200 OK` — Rejected
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
`next_salt` is absent. The response body is intentionally identical in
|
||||
structure. Rejections are silent — the caller cannot distinguish a validation
|
||||
failure from a rate limit hit or an expired session.
|
||||
|
||||
The client should log a warning and continue scheduling heartbeats (they will
|
||||
continue to fail until the page is reloaded and a new session is established).
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules (Server-Side)
|
||||
|
||||
Heartbeats are rejected (silently) if any of the following checks fail:
|
||||
|
||||
| Check | Condition for rejection |
|
||||
|---|---|
|
||||
| Rate limit | > 5 requests per 10-second window for this `session_id` |
|
||||
| Session not found | `session_id` not in SQLite |
|
||||
| Session expired | `current_time_ms > expires_at` |
|
||||
| Signature invalid | Ed25519 verification fails against stored public key |
|
||||
| Hash chain broken | `hex(prev_hash) ≠ stored last_hash` |
|
||||
| Timestamp drift | `\|server_now_ms - timestamp\| > 30 000` |
|
||||
| Insufficient mouse events | `events.len() < 3` |
|
||||
| Insufficient mouse distance | `total_dist < 10.0 px` |
|
||||
| Mouse speed too high | `total_dist / total_time_ms > 2.0 px/ms` |
|
||||
| No mouse pauses | `pause_count < 1` |
|
||||
| Invalid aspect ratio | `ar < 0.5` or `ar > 3.0` |
|
||||
| Invalid devicePixelRatio | `dpr ≤ 0.0` or `dpr > 5.0` |
|
||||
| Zero hardwareConcurrency | `hardware_concurrency == 0` |
|
||||
|
||||
---
|
||||
|
||||
## Hash Chain Specification
|
||||
|
||||
```
|
||||
H(0) = Blake3( session_id_bytes ║ pub_key_bytes ║ salt₀_bytes )
|
||||
|
||||
H(n) = Blake3(
|
||||
saltₙ₋₁_bytes
|
||||
║ H(n-1)_bytes
|
||||
║ timestamp_u64_le_bytes
|
||||
║ Blake3( UTF-8( JSON(entropy_data) ) )
|
||||
║ Blake3( UTF-8( JSON(stack_state) ) )
|
||||
)
|
||||
```
|
||||
|
||||
All inputs are concatenated in the order shown. `timestamp` is encoded as a
|
||||
64-bit unsigned integer in little-endian byte order. JSON serialisation of
|
||||
`entropy_data` and `stack_state` uses the field order defined by the shared
|
||||
Rust types (serde derive, no custom ordering).
|
||||
|
||||
---
|
||||
|
||||
## WASM API
|
||||
|
||||
The WASM module (`antibot_wasm`) exports the following functions to JavaScript:
|
||||
|
||||
| Function | Signature | Description |
|
||||
|---|---|---|
|
||||
| `generate_keypair()` | `() → string` | Generate Ed25519 keypair; return hex public key. Private key stored in WASM memory. |
|
||||
| `get_public_key()` | `() → string` | Return hex public key, or `""` if not initialised. |
|
||||
| `sign_message(msg)` | `(string) → string` | Sign UTF-8 string; return hex signature, or `""` if not initialised. |
|
||||
| `compute_next_hash(prev, ts, entropy, stack, salt)` | `(string, u64, string, string, string) → string` | Compute next Blake3 chain hash; all inputs/output hex or JSON strings. |
|
||||
| `run_program(b64)` | `(string) → JsValue` | Execute base64 VM program; return `{ stack: u32[], ip: number }`. |
|
||||
|
||||
All functions return empty strings on error rather than panicking.
|
||||
Callers must check for empty return values before using the result.
|
||||
+270
-240
@@ -1,245 +1,253 @@
|
||||
# ChronoSeal Architecture
|
||||
# ChronoSeal — Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
ChronoSeal is a stateless, cryptographic browser-attestation system. Its primary
|
||||
goal is to make automated scraping and AI-driven crawling computationally
|
||||
expensive and operationally complex, while remaining completely invisible to
|
||||
real human users.
|
||||
ChronoSeal is a stateless, cryptographic browser attestation framework. Its
|
||||
purpose is to make automated clients (headless browsers, AI scrapers, API
|
||||
harvesters) computationally expensive and operationally complex to operate,
|
||||
while remaining completely invisible to real human users.
|
||||
|
||||
The design is inspired by the heartbeat model used in IoT firmware: a device
|
||||
must continuously emit authenticated, chained proofs of liveness or the session
|
||||
is invalidated. ChronoSeal applies this model to browser sessions.
|
||||
The design is inspired by the heartbeat model used in embedded IoT firmware:
|
||||
a device that stops sending signed, chained attestations is assumed to be
|
||||
offline or compromised. ChronoSeal applies the same principle to browser
|
||||
sessions.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
## Design Principles
|
||||
|
||||
| Principle | Implementation |
|
||||
|---|---|
|
||||
| Stateless per request | Only `session_id` is sent; all state lives server-side in SQLite |
|
||||
| Cryptographic continuity | Blake3 hash chain — each heartbeat references and extends the previous |
|
||||
| Secret isolation | Ed25519 private key generated inside WASM, never serialised to JS |
|
||||
| Silent failure | All rejections return `{"status":"ok"}` — indistinguishable from success |
|
||||
| Behavioral validation | Mouse entropy, speed, and pause patterns validated server-side |
|
||||
| Frictionless to humans | No CAPTCHA, no visible UI, zero interaction required |
|
||||
**Stateless per request.** The server carries no per-request state beyond what
|
||||
is stored in SQLite keyed on `session_id`. Every HTTP request is independently
|
||||
verifiable.
|
||||
|
||||
**Silent failure.** Validation failures never return an error status or an
|
||||
error body. The server always responds `{"status":"ok"}` and simply omits
|
||||
`next_salt`. The client degrades gracefully. Attackers cannot enumerate
|
||||
validation rules by probing error responses.
|
||||
|
||||
**Private key isolation.** The Ed25519 signing key is generated inside the
|
||||
WASM module and never serialised, never exposed to the JavaScript environment,
|
||||
and never transmitted. It exists only in WASM linear memory for the lifetime
|
||||
of the page.
|
||||
|
||||
**Layered validation.** A heartbeat must pass five independent checks: session
|
||||
existence, expiry, signature, hash chain, and behavioral signals. Bypassing
|
||||
one layer is not sufficient.
|
||||
|
||||
**Cost asymmetry.** Each heartbeat requires a real browser environment, mouse
|
||||
activity, correct WASM execution, chain state synchronisation, and a valid
|
||||
Ed25519 signature over a time-windowed payload. For an automated client, the
|
||||
synchronisation burden alone makes scaled operation expensive.
|
||||
|
||||
---
|
||||
|
||||
## System Components
|
||||
## Component Map
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌───────────────────────────────────┐ │
|
||||
│ │ JavaScript │ │ WASM Module (antibot_wasm) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ heartbeat │◄──►│ crypto.rs — Ed25519 keypair │ │
|
||||
│ │ entropy │ │ vm.rs — stack machine │ │
|
||||
│ │ transport │ │ (private key never leaves here) │ │
|
||||
│ └──────────────┘ └───────────────────────────────────┘ │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Server (Axum / Tokio) │
|
||||
│ │
|
||||
│ POST /init ──► session.rs ──► storage.rs (SQLite) │
|
||||
│ POST /hb ──► session.rs │
|
||||
│ ├── crypto.rs (Ed25519 verify) │
|
||||
│ ├── trust.rs (mouse entropy) │
|
||||
│ ├── fingerprint (browser signals) │
|
||||
│ └── vm.rs (opcode generation) │
|
||||
│ │
|
||||
│ Background: cleanup.rs (session expiry + RL eviction) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workspace Layout
|
||||
|
||||
```
|
||||
chronoseal-rs/
|
||||
│
|
||||
├── shared/ Shared between server and WASM
|
||||
│ ├── src/constants.rs All tunable parameters
|
||||
│ ├── src/protocol.rs Request/response types (serde)
|
||||
│ └── src/hashing.rs Blake3 hash-chain primitives
|
||||
│
|
||||
├── server/ Axum HTTP server
|
||||
│ ├── src/main.rs Router, state init, background tasks
|
||||
│ ├── src/routes/
|
||||
│ │ ├── init.rs POST /init handler
|
||||
│ │ └── heartbeat.rs POST /hb handler
|
||||
│ ├── src/session.rs Session lifecycle: create + verify
|
||||
│ ├── src/crypto.rs Ed25519 signature verification
|
||||
│ ├── src/trust.rs Behavioral signal validation
|
||||
│ ├── src/fingerprint.rs Browser fingerprint sanity checks
|
||||
│ ├── src/vm.rs Random opcode program generator
|
||||
│ ├── src/ratelimit.rs Token-bucket rate limiter
|
||||
│ ├── src/cleanup.rs Background expiry + eviction loop
|
||||
│ ├── src/storage.rs SQLite schema init + time utilities
|
||||
│ └── src/middleware.rs Request logging
|
||||
│
|
||||
├── wasm/ Rust → WASM client module
|
||||
│ ├── src/lib.rs Module declarations
|
||||
│ ├── src/crypto.rs Keypair generation, signing, hashing
|
||||
│ └── src/vm.rs Stack machine executor
|
||||
│
|
||||
└── frontend/ Vanilla JS glue
|
||||
├── index.html Demo page
|
||||
├── main.js Entry point
|
||||
├── heartbeat.js Session init + heartbeat loop
|
||||
├── entropy.js Mouse event collector
|
||||
└── transport.js fetch() wrapper
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │ entropy.js │ │ heartbeat.js │ │ transport.js│ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ mousemove │──►│ orchestrates │──►│ fetch POST │ │
|
||||
│ │ event ring │ │ init + HB │ │ /init /hb │ │
|
||||
│ └─────────────┘ └──────┬───────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼───────────────────────┐ │
|
||||
│ │ WASM Module (antibot_wasm) │ │
|
||||
│ │ │ │
|
||||
│ │ crypto.rs vm.rs │ │
|
||||
│ │ ├ generate_keypair() │ │
|
||||
│ │ ├ sign_message() │ │
|
||||
│ │ ├ compute_next_hash() │ │
|
||||
│ │ └ run_program() │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│ HTTPS
|
||||
┌─────────────────────────▼───────────────────────────────┐
|
||||
│ Server (Axum) │
|
||||
│ │
|
||||
│ routes/init.rs routes/heartbeat.rs │
|
||||
│ │ │ │
|
||||
│ └──────────┬───────────────┘ │
|
||||
│ ▼ │
|
||||
│ session.rs │
|
||||
│ ├ create_session() │
|
||||
│ └ verify_heartbeat() │
|
||||
│ │ │
|
||||
│ ┌──────────┼──────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ crypto.rs trust.rs fingerprint.rs │
|
||||
│ (sig verify) (mouse (aspect ratio, │
|
||||
│ speed) DPR, HW conc.) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ shared::hashing (Blake3 hash chain) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ storage.rs (in-memory SQLite) │
|
||||
│ │
|
||||
│ ratelimit.rs cleanup.rs vm.rs middleware.rs │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
### 1. Initialisation (`POST /init`)
|
||||
### 1. Initialisation — `POST /init`
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
│ WASM: generate Ed25519 keypair │
|
||||
│ private key → thread_local storage │
|
||||
│ │
|
||||
├─── { public_key: hex } ─────────────────►│
|
||||
│ │ Generate session_id (32 bytes CSPRNG)
|
||||
│ │ Generate salt₀ (16 bytes CSPRNG)
|
||||
│ │ H₀ = Blake3(session_id ║ pub_key ║ salt₀)
|
||||
│ │ Generate random VM program (8–16 opcodes)
|
||||
│ │ Store: session_id, pub_key, salt₀, H₀
|
||||
│ │
|
||||
│◄── { session_id, salt, opcodes, H₀ } ────┤
|
||||
│ │
|
||||
│ Store: session_id, prevHash=H₀, │
|
||||
│ currentSalt=salt₀, opcodes │
|
||||
Client Server
|
||||
│ │
|
||||
│ generate Ed25519 keypair (in WASM) │
|
||||
│ pub_key = verifying_key.to_bytes() │
|
||||
│ │
|
||||
├─── { public_key: hex(pub_key) } ──────►│
|
||||
│ │ session_id = rand::random::<[u8;32]>()
|
||||
│ │ salt₀ = rand::random::<[u8;16]>()
|
||||
│ │ H(0) = Blake3(session_id║pub_key║salt₀)
|
||||
│ │ opcodes = generate_random_program(8..=16)
|
||||
│ │ INSERT INTO sessions …
|
||||
│ │
|
||||
│◄── { session_id, salt, opcodes_b64, │
|
||||
│ initial_hash, expires_at } ───────┤
|
||||
│ │
|
||||
│ prevHash = initial_hash │
|
||||
│ currentSalt = salt │
|
||||
│ opcodesB64 = opcodes_b64 │
|
||||
```
|
||||
|
||||
### 2. Heartbeat (`POST /hb`)
|
||||
### 2. Heartbeat — `POST /hb`
|
||||
|
||||
Fired every 12–25 seconds with uniform random jitter.
|
||||
|
||||
```
|
||||
Client Server
|
||||
│ │
|
||||
│ Collect mouse events since last HB │
|
||||
│ Execute VM opcodes → stack state │
|
||||
│ Build signable payload (sorted JSON): │
|
||||
│ entropyData, fingerprint, prevHash, │
|
||||
│ sessionId, stackState, timestamp │
|
||||
│ Sign with Ed25519 private key │
|
||||
│ │
|
||||
├─── { session_id, prev_hash, timestamp, │
|
||||
│ entropy_data, stack_state, │
|
||||
│ fingerprint, signature } ─────────►│
|
||||
│ │ Rate limit check
|
||||
│ │ Look up session by session_id
|
||||
│ │ Check expiry
|
||||
│ │ Verify Ed25519 signature
|
||||
│ │ Verify prev_hash == stored last_hash
|
||||
│ │ Verify timestamp within ±30s
|
||||
│ │ Validate mouse entropy (speed, pauses)
|
||||
│ │ Validate fingerprint signals
|
||||
│ │ Compute: H(n) = Blake3(salt ║ H(n-1) ║ …)
|
||||
│ │ Generate next_salt
|
||||
│ │ Update: last_hash=H(n), salt=next_salt
|
||||
│ │
|
||||
│◄── { status: "ok", next_salt } ──────────┤
|
||||
│ │
|
||||
│ sentSalt = currentSalt │
|
||||
│ currentSalt = next_salt │
|
||||
│ prevHash = Blake3(sentSalt ║ H(n-1) ║ … │ ← must mirror server computation
|
||||
Client Server
|
||||
│ │
|
||||
│ stackState = run_program(opcodesB64) │
|
||||
│ events = collectEntropy(lastTime) │
|
||||
│ ts = Date.now() │
|
||||
│ │
|
||||
│ signable = { │
|
||||
│ entropyData, fingerprint, │ ← keys sorted alphabetically
|
||||
│ prevHash, sessionId, │
|
||||
│ stackState, timestamp │
|
||||
│ } │
|
||||
│ sig = sign_message( │
|
||||
│ JSON.stringify(signable, keys.sort))│
|
||||
│ │
|
||||
├─── { session_id, prev_hash, timestamp,│
|
||||
│ entropy_data, stack_state, │
|
||||
│ fingerprint, signature } ────────►│
|
||||
│ │ 1. Rate limit check
|
||||
│ │ 2. Lookup session, check expiry
|
||||
│ │ 3. Verify Ed25519 signature
|
||||
│ │ 4. Verify hash chain continuity
|
||||
│ │ 5. Validate timestamp window ±30s
|
||||
│ │ 6. Validate mouse behavior
|
||||
│ │ 7. Validate fingerprint signals
|
||||
│ │ 8. Compute H(n), rotate salt
|
||||
│ │ 9. UPDATE sessions …
|
||||
│ │
|
||||
│◄── { status: "ok", next_salt } ────────┤
|
||||
│ │
|
||||
│ sentSalt = currentSalt ◄── captured BEFORE rotation
|
||||
│ currentSalt = next_salt │
|
||||
│ prevHash = compute_next_hash( │
|
||||
│ prevHash, ts, entropy, │
|
||||
│ stackState, sentSalt) │
|
||||
```
|
||||
|
||||
### 3. Session Expiry
|
||||
### 3. Failure Path
|
||||
|
||||
Sessions expire after 30 minutes of inactivity. A background task runs every
|
||||
60 seconds to delete expired rows from SQLite and evict stale rate-limiter
|
||||
entries from memory.
|
||||
On any validation failure the server returns `{"status":"ok"}` with no
|
||||
`next_salt`. The client logs a warning and continues scheduling heartbeats.
|
||||
The chain is broken — subsequent heartbeats will also fail silently.
|
||||
No error is surfaced to the page or its visitors.
|
||||
|
||||
---
|
||||
|
||||
## Hash Chain
|
||||
## Cryptographic Protocol
|
||||
|
||||
The chain provides tamper-evidence: forging a valid heartbeat at position `n`
|
||||
requires knowledge of `H(n-1)`, the current `salt`, and the Ed25519 private key.
|
||||
None of these are available to an attacker who does not control the client WASM.
|
||||
### Key Generation
|
||||
|
||||
```
|
||||
Ed25519 keypair generated via ed25519-dalek + rand::thread_rng (OS-seeded)
|
||||
Private key: stored in WASM thread_local, never leaves WASM memory
|
||||
Public key: 32 bytes, hex-encoded, sent to server at init
|
||||
```
|
||||
|
||||
### Hash Chain
|
||||
|
||||
```
|
||||
H(0) = Blake3( session_id ║ pub_key ║ salt₀ )
|
||||
|
||||
H(n) = Blake3(
|
||||
saltₙ₋₁ ← server-side only, rotated each heartbeat
|
||||
║ H(n-1) ← stored server-side, sent by client
|
||||
║ timestamp ← 8 bytes LE
|
||||
║ Blake3(entropy) ← hash of mouse event JSON
|
||||
║ Blake3(stack) ← hash of VM stack state JSON
|
||||
saltₙ₋₁ ← server-side only, rotated each heartbeat
|
||||
║ H(n-1) ← must match stored last_hash
|
||||
║ timestamp_u64_le
|
||||
║ Blake3( JSON(entropy_data) )
|
||||
║ Blake3( JSON(stack_state) )
|
||||
)
|
||||
```
|
||||
|
||||
Salt rotation means that even a full replay of a captured heartbeat is invalid
|
||||
on the next cycle — the salt has changed.
|
||||
Salt rotation means an attacker who intercepts a heartbeat cannot compute
|
||||
future chain links without also intercepting every subsequent server response.
|
||||
|
||||
### Canonical Signing Payload
|
||||
|
||||
The signed message is a JSON object with top-level keys sorted alphabetically,
|
||||
serialised with no extra whitespace:
|
||||
|
||||
```json
|
||||
{
|
||||
"entropyData": { "events": [{"t":…,"x":…,"y":…}] },
|
||||
"fingerprint": { "aspectRatio":"…","devicePixelRatio":"…","hardwareConcurrency":… },
|
||||
"prevHash": "hex…",
|
||||
"sessionId": "hex…",
|
||||
"stackState": { "ip":…,"stack":[…] },
|
||||
"timestamp": 1234567890123
|
||||
}
|
||||
```
|
||||
|
||||
The server reconstructs this using `std::collections::BTreeMap` (alphabetical
|
||||
key order) before calling `VerifyingKey::verify_strict`. Any field mismatch,
|
||||
key order difference, or whitespace difference causes a signature failure.
|
||||
|
||||
### Hashing Algorithm
|
||||
|
||||
Blake3 is used throughout: hash chain links, entropy data digest, stack state
|
||||
digest, and the VM HASH opcode. Blake3 is chosen for speed in WASM,
|
||||
resistance to length-extension attacks, and a clean Rust API.
|
||||
|
||||
---
|
||||
|
||||
## Stack Machine
|
||||
|
||||
The server generates a random program on session init. The client executes it
|
||||
on every heartbeat. The resulting stack state is included in the signed payload
|
||||
and the hash chain, making each heartbeat structurally unique.
|
||||
on every heartbeat and includes the resulting `StackState { stack, ip }` in
|
||||
the signed payload. This ensures each heartbeat carries unique, verifiable
|
||||
computation without additional round-trips.
|
||||
|
||||
### Instruction Set
|
||||
|
||||
| Opcode | Mnemonic | Stack effect | Description |
|
||||
|--------|----------|-------------|-------------|
|
||||
| `0x00` + 4 bytes | PUSH | `→ val` | Push 32-bit LE literal |
|
||||
| `0x01` | ADD | `a b → a+b` | Wrapping addition |
|
||||
| `0x02` | SUB | `a b → a-b` | Wrapping subtraction |
|
||||
| `0x03` | MUL | `a b → a*b` | Wrapping multiplication |
|
||||
| `0x04` | XOR | `a b → a^b` | Bitwise XOR |
|
||||
| `0x05` | AND | `a b → a&b` | Bitwise AND |
|
||||
| `0x06` | OR | `a b → a\|b` | Bitwise OR |
|
||||
| `0x07` | ROT | `a b → a.rotate_left(b%32)` | Bit rotation |
|
||||
| `0x08` | NOT | `a → !a` | Bitwise NOT (unary) |
|
||||
| `0x09` | HASH | `[…] → u32` | Blake3 of full stack → single u32 |
|
||||
| Opcode | Mnemonic | Operand | Stack effect | Description |
|
||||
|--------|----------|---------------|--------------|-------------|
|
||||
| `0x00` | PUSH | u32 (4B LE) | +1 | Push literal |
|
||||
| `0x01` | ADD | — | −1 | `a + b` wrapping |
|
||||
| `0x02` | SUB | — | −1 | `a - b` wrapping |
|
||||
| `0x03` | MUL | — | −1 | `a * b` wrapping |
|
||||
| `0x04` | XOR | — | −1 | `a ^ b` |
|
||||
| `0x05` | AND | — | −1 | `a & b` |
|
||||
| `0x06` | OR | — | −1 | `a \| b` |
|
||||
| `0x07` | ROT | — | −1 | `a.rotate_left(b % 32)` |
|
||||
| `0x08` | NOT | — | 0 | `!a` (unary) |
|
||||
| `0x09` | HASH | — | -(depth-1) | Blake3 of all stack items → single u32 |
|
||||
|
||||
### Program Generation
|
||||
|
||||
The server generates programs with a depth-tracking algorithm that guarantees
|
||||
at least 2 operands before any binary op is emitted. Programs are 8–16
|
||||
instructions. The client VM halts gracefully on underflow — invalid opcodes
|
||||
produce a partial stack state that still participates in the hash chain.
|
||||
|
||||
---
|
||||
|
||||
## Cryptographic Primitives
|
||||
|
||||
| Primitive | Usage |
|
||||
|---|---|
|
||||
| **Ed25519** (ed25519-dalek v2) | Client keypair; signs each heartbeat payload |
|
||||
| **Blake3** | Hash chain; entropy hashing; stack hashing |
|
||||
| **CSPRNG** (rand / getrandom) | Session ID, salt, VM opcodes |
|
||||
|
||||
### Canonical Signing Format
|
||||
|
||||
The signed payload is a JSON object with keys sorted **alphabetically**,
|
||||
matching `JSON.stringify(obj, Object.keys(obj).sort())` on the client and
|
||||
`BTreeMap` serialisation on the server:
|
||||
|
||||
```json
|
||||
{
|
||||
"entropyData": { "events": [ { "x": 123.0, "y": 456.0, "t": 1234.5 } ] },
|
||||
"fingerprint": { "aspectRatio": "1.7777777778", "devicePixelRatio": "2", "hardwareConcurrency": 8 },
|
||||
"prevHash": "a3f2…",
|
||||
"sessionId": "9c1b…",
|
||||
"stackState": { "stack": [2147483648], "ip": 12 },
|
||||
"timestamp": 1746700000000
|
||||
}
|
||||
```
|
||||
The generator ensures ≥ 2 items on the stack before any binary opcode.
|
||||
NOT (0x08) does not change depth. HASH resets depth to 1.
|
||||
|
||||
---
|
||||
|
||||
@@ -247,79 +255,101 @@ matching `JSON.stringify(obj, Object.keys(obj).sort())` on the client and
|
||||
|
||||
### Mouse Entropy
|
||||
|
||||
| Check | Threshold | Rationale |
|
||||
|---|---|---|
|
||||
| Minimum events | ≥ 3 | Single-point or no-movement signals headless |
|
||||
| Total distance | ≥ 10 px | Rules out stationary cursors |
|
||||
| Average speed | ≤ 2.0 px/ms | Rules out programmatic linear sweeps |
|
||||
| Pause count | ≥ 1 | Human movement includes micro-stops |
|
||||
Every heartbeat includes the mouse events collected since the previous
|
||||
heartbeat. Server checks:
|
||||
|
||||
Speed is computed as `total_distance / total_elapsed_ms` over the event window.
|
||||
| Check | Threshold |
|
||||
|---|---|
|
||||
| Minimum event count | ≥ 3 |
|
||||
| Minimum cumulative distance | ≥ 10 px |
|
||||
| Maximum average speed | ≤ 2.0 px/ms (distance / elapsed ms) |
|
||||
| Minimum pause count | ≥ 1 (movement < 0.2 px over > 50 ms) |
|
||||
|
||||
### Fingerprint Signals
|
||||
### Browser Fingerprint
|
||||
|
||||
| Signal | Valid range | Rationale |
|
||||
|---|---|---|
|
||||
| Aspect ratio | 0.5 – 3.0 | Headless defaults are often 1:1 or extreme values |
|
||||
| Device pixel ratio | 0.0 – 5.0 | Zero DPR is impossible on real hardware |
|
||||
| Hardware concurrency | ≥ 1 | Zero is impossible; headless may report 0 |
|
||||
| Signal | Valid range |
|
||||
|---|---|
|
||||
| `aspectRatio` (width / height) | 0.5 – 3.0 |
|
||||
| `devicePixelRatio` | 0 < dpr ≤ 5.0 |
|
||||
| `hardwareConcurrency` | ≥ 1 |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Per-session token bucket: 5 requests per 10-second window. Rate limit state
|
||||
is held in a `HashMap<String, (u32, Instant)>` in process memory. The cleanup
|
||||
loop calls `evict_stale()` every 60 seconds to prevent unbounded growth from
|
||||
unique session IDs.
|
||||
Token bucket per `session_id`: 5 requests / 10-second window.
|
||||
Stale entries evicted every 60 seconds by the cleanup task.
|
||||
Rate-limited responses are indistinguishable from validation failures.
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
ChronoSeal uses an **in-memory SQLite** database. All session state is lost on
|
||||
server restart. Clients transparently re-initialise on the next page load.
|
||||
## SQLite Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
public_key BLOB NOT NULL,
|
||||
salt BLOB NOT NULL,
|
||||
last_hash BLOB NOT NULL,
|
||||
public_key BLOB NOT NULL, -- 32-byte Ed25519 verifying key
|
||||
salt BLOB NOT NULL, -- 16-byte current salt
|
||||
last_hash BLOB NOT NULL, -- 32-byte Blake3 chain head
|
||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
created_at INTEGER NOT NULL, -- Unix ms
|
||||
last_seen INTEGER NOT NULL, -- Unix ms
|
||||
expires_at INTEGER NOT NULL -- Unix ms
|
||||
);
|
||||
```
|
||||
|
||||
For persistence across restarts, replace `Connection::open_in_memory()` in
|
||||
`server/src/storage.rs` with `Connection::open("/var/lib/chronoseal/sessions.db")`.
|
||||
In-memory SQLite — all sessions lost on server restart by design.
|
||||
Clients re-initialise transparently on the next page load.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
### What ChronoSeal raises the cost of
|
||||
### In Scope
|
||||
|
||||
- **Playwright / Puppeteer Stealth** — mouse entropy validation detects absent
|
||||
or synthetic movement
|
||||
- **Replay attacks** — hash chain + salt rotation invalidates captured payloads
|
||||
on the next cycle
|
||||
- **Signature forgery** — Ed25519 private key is generated inside WASM and
|
||||
never exposed to the JS context
|
||||
- **Clock manipulation** — server enforces ±30s timestamp window
|
||||
- **Credential sharing** — keypair is generated fresh on every page load;
|
||||
session is bound to that keypair
|
||||
- **Traffic analysis** — all responses return `{"status":"ok"}`; rejections
|
||||
are indistinguishable from successes
|
||||
| Threat | Mitigation |
|
||||
|---|---|
|
||||
| Playwright / Puppeteer / Selenium | Mouse entropy + behavioral validation |
|
||||
| Puppeteer Stealth, undetected-chromedriver | Signature over VM execution state |
|
||||
| Heartbeat replay | Hash chain + ±30s timestamp window |
|
||||
| Signature forgery | Private key isolated in WASM memory |
|
||||
| Parallel session sharing | Each session bound to a unique keypair |
|
||||
| Brute-forced session IDs | 256-bit random entropy |
|
||||
| Flooding with fake session IDs | Rate limiter + periodic HashMap eviction |
|
||||
| Traffic analysis | Uniform `{"status":"ok"}` on all failure paths |
|
||||
|
||||
### What ChronoSeal does not prevent
|
||||
### Out of Scope
|
||||
|
||||
- An attacker running a real browser with a real mouse on real hardware
|
||||
- A sufficiently motivated adversary who reverse-engineers the WASM, replicates
|
||||
the hash chain, and synthesises mouse events
|
||||
- Server-side data exfiltration once a valid session is established
|
||||
| Threat | Reason |
|
||||
|---|---|
|
||||
| Real browser with real human input | Indistinguishable from a legitimate user |
|
||||
| WASM reverse engineering | Obfuscation is not a security primitive |
|
||||
| Server-side compromise | Outside the scope of client attestation |
|
||||
|
||||
ChronoSeal is a **cost-raising** mechanism, not an impenetrable barrier.
|
||||
The goal is to make scraping at scale impractical, not impossible.
|
||||
ChronoSeal raises cost and complexity of automated access. It is not a
|
||||
cryptographic proof of humanity and does not claim to be.
|
||||
|
||||
---
|
||||
|
||||
## Module Reference
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `shared/src/protocol.rs` | Shared types: `InitRequest`, `HeartbeatRequest`, `StackState`, … |
|
||||
| `shared/src/hashing.rs` | `initial_hash`, `next_chain_hash`, `hash_stack` |
|
||||
| `shared/src/constants.rs` | All tunable parameters |
|
||||
| `server/src/routes/init.rs` | `POST /init` handler |
|
||||
| `server/src/routes/heartbeat.rs` | `POST /hb` handler |
|
||||
| `server/src/session.rs` | `create_session`, `verify_heartbeat` |
|
||||
| `server/src/crypto.rs` | `verify_signature` — BTreeMap canonical JSON |
|
||||
| `server/src/trust.rs` | `validate_mouse` — speed, distance, pauses |
|
||||
| `server/src/fingerprint.rs` | `validate` — aspect ratio, DPR, HW concurrency |
|
||||
| `server/src/vm.rs` | `generate_random_program` |
|
||||
| `server/src/ratelimit.rs` | `RateLimiter::check`, `evict_stale` |
|
||||
| `server/src/cleanup.rs` | Background loop: expire sessions + evict rate limiter |
|
||||
| `server/src/storage.rs` | SQLite init, `current_time_ms` |
|
||||
| `wasm/src/crypto.rs` | `generate_keypair`, `sign_message`, `compute_next_hash` |
|
||||
| `wasm/src/vm.rs` | `run_program` — stack machine executor |
|
||||
| `frontend/heartbeat.js` | Session init, heartbeat loop, chain advancement |
|
||||
| `frontend/entropy.js` | Mouse event ring buffer, `collectEntropy` |
|
||||
| `frontend/transport.js` | `sendRequest` fetch wrapper |
|
||||
+326
-16
@@ -1,36 +1,346 @@
|
||||
# Deployment
|
||||
# ChronoSeal — Deployment Guide
|
||||
|
||||
## Native
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Minimum version | Purpose |
|
||||
|---|---|---|
|
||||
| Rust | 1.87 stable | Server + WASM compilation |
|
||||
| wasm-pack | 0.13 | WASM build and packaging |
|
||||
| Docker + Compose | 24 / 2.x | Container deployment |
|
||||
| nginx / NPM / HAProxy | any | TLS termination, reverse proxy |
|
||||
|
||||
Install Rust: https://rustup.rs
|
||||
Install wasm-pack: `cargo install wasm-pack`
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
### 1. Build the WASM module
|
||||
|
||||
```bash
|
||||
wasm-pack build wasm --target web --release
|
||||
mv wasm/pkg frontend/pkg
|
||||
```
|
||||
|
||||
This produces `frontend/pkg/antibot_wasm.js` and `frontend/pkg/antibot_wasm_bg.wasm`,
|
||||
which are loaded by `frontend/main.js` at runtime.
|
||||
|
||||
### 2. Build the server
|
||||
|
||||
```bash
|
||||
cargo build -p server --release
|
||||
```
|
||||
|
||||
Binary output: `target/release/server`
|
||||
|
||||
### 3. Build both (convenience script)
|
||||
|
||||
```bash
|
||||
bash scripts/build.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
bash scripts/dev.sh
|
||||
```
|
||||
|
||||
Runs the server with `cargo run --release`. The server serves the `frontend/`
|
||||
directory statically at `/` via tower-http `ServeDir`.
|
||||
|
||||
Open `http://localhost:3000` in a browser. Open DevTools console — heartbeats
|
||||
should appear every 12–25 seconds. No visible UI is rendered; the protection
|
||||
is entirely silent.
|
||||
|
||||
### Production (native binary)
|
||||
|
||||
```bash
|
||||
cargo build -p server --release
|
||||
sudo cp target/release/server /usr/local/bin/chronoseal
|
||||
```
|
||||
|
||||
## systemd
|
||||
Set environment variables before running:
|
||||
|
||||
```bash
|
||||
sudo cp chronoseal.service /etc/systemd/system/
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable chronoseal
|
||||
sudo systemctl start chronoseal
|
||||
export RUST_LOG=info # or warn for quieter output
|
||||
chronoseal
|
||||
```
|
||||
|
||||
The server binds to `0.0.0.0:3000` by default. Place behind a reverse proxy
|
||||
for TLS — do not expose port 3000 directly.
|
||||
|
||||
---
|
||||
|
||||
## systemd
|
||||
|
||||
### Service file
|
||||
|
||||
The provided `chronoseal.service` includes hardened systemd sandboxing:
|
||||
|
||||
```
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
SystemCallArchitectures=native
|
||||
```
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Create a dedicated system user
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin chronoseal
|
||||
|
||||
# Install binary and frontend
|
||||
sudo cp target/release/server /usr/local/bin/chronoseal
|
||||
sudo mkdir -p /opt/chronoseal/frontend
|
||||
sudo cp -r frontend/ /opt/chronoseal/frontend/
|
||||
sudo chown -R chronoseal:chronoseal /opt/chronoseal
|
||||
|
||||
# Install and enable service
|
||||
sudo cp chronoseal.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now chronoseal
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
sudo systemctl status chronoseal
|
||||
journalctl -u chronoseal -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
### Build and run
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### docker-compose.yml overview
|
||||
|
||||
```yaml
|
||||
services:
|
||||
chronoseal:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
tmpfs:
|
||||
- /tmp
|
||||
```
|
||||
|
||||
The `tmpfs` mount ensures the in-memory SQLite database is never written to
|
||||
disk, even if Docker's storage driver were to flush the container filesystem.
|
||||
|
||||
### Dockerfile stages
|
||||
|
||||
The Dockerfile uses a two-stage build:
|
||||
|
||||
1. `rust:1.87-bookworm` — compiles the server binary
|
||||
2. `debian:bookworm-slim` — minimal runtime image with only `ca-certificates`
|
||||
|
||||
The WASM module and frontend must be built separately (wasm-pack requires a
|
||||
browser toolchain not present in the server image) and mounted or copied into
|
||||
the container at `/opt/chronoseal/frontend/`.
|
||||
|
||||
```bash
|
||||
# Build WASM first
|
||||
wasm-pack build wasm --target web --release
|
||||
mv wasm/pkg frontend/pkg
|
||||
|
||||
# Then build and run the container
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Or mount the pre-built frontend as a volume:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./frontend:/opt/chronoseal/frontend:ro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Recommended:
|
||||
- nginx
|
||||
- Nginx Proxy Manager
|
||||
- HAProxy
|
||||
ChronoSeal must be served over HTTPS. The heartbeat payload contains a
|
||||
timestamp; if traffic is observable in plaintext, timing attacks become
|
||||
easier. TLS 1.3 is strongly recommended.
|
||||
|
||||
Enable:
|
||||
- HTTP/2
|
||||
- TLS 1.3
|
||||
- aggressive timeout policies
|
||||
### nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your.domain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your.domain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your.domain.com/privkey.pem;
|
||||
ssl_protocols TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
|
||||
# Tight timeouts — heartbeat interval is 12–25s
|
||||
proxy_read_timeout 35s;
|
||||
proxy_send_timeout 10s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your.domain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx Proxy Manager
|
||||
|
||||
1. Add a new Proxy Host pointing to `http://chronoseal:3000`
|
||||
2. Enable SSL, Request Let's Encrypt certificate
|
||||
3. Enable HTTP/2, Force SSL
|
||||
4. Under Advanced, add:
|
||||
```
|
||||
proxy_read_timeout 35s;
|
||||
proxy_send_timeout 10s;
|
||||
```
|
||||
|
||||
### HAProxy
|
||||
|
||||
```haproxy
|
||||
frontend https_front
|
||||
bind *:443 ssl crt /etc/haproxy/certs/your.domain.pem alpn h2,http/1.1
|
||||
default_backend chronoseal_back
|
||||
|
||||
backend chronoseal_back
|
||||
server chronoseal 127.0.0.1:3000 check
|
||||
timeout connect 5s
|
||||
timeout server 35s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration into an Existing Site
|
||||
|
||||
ChronoSeal is designed to run as a sidecar — its `/init` and `/hb` endpoints
|
||||
can be proxied from any existing web server. The frontend assets (`pkg/`) need
|
||||
to be served from the same origin as the protected page (or CORS must be
|
||||
configured).
|
||||
|
||||
### Option A — Serve everything from ChronoSeal
|
||||
|
||||
ChronoSeal serves `frontend/` statically. Put your protected HTML inside
|
||||
`frontend/` and let ChronoSeal serve it directly.
|
||||
|
||||
### Option B — Proxy only the API endpoints
|
||||
|
||||
Keep your existing server. Proxy `/init` and `/hb` to ChronoSeal, and serve
|
||||
the WASM and JS assets from your CDN or existing static file server.
|
||||
|
||||
```nginx
|
||||
# On your existing server:
|
||||
location ~ ^/(init|hb)$ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
}
|
||||
```
|
||||
|
||||
Add to your protected pages:
|
||||
|
||||
```html
|
||||
<script type="module" src="/pkg/antibot_wasm.js"></script>
|
||||
<script type="module" src="/main.js"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All parameters are in `shared/src/constants.rs`. Recompile after changes.
|
||||
|
||||
| Constant | Default | Notes |
|
||||
|---|---|---|
|
||||
| `SESSION_ID_LEN` | 32 bytes | 256-bit entropy — do not reduce |
|
||||
| `SALT_LEN` | 16 bytes | Per-heartbeat salt |
|
||||
| `HEARTBEAT_MIN_INTERVAL_MS` | 12 000 ms | Increase to reduce server load |
|
||||
| `HEARTBEAT_MAX_INTERVAL_MS` | 25 000 ms | Jitter upper bound |
|
||||
| `EXPIRATION_MINUTES` | 30 min | Session TTL after last heartbeat |
|
||||
| `RATE_LIMIT_COUNT` | 5 | Max heartbeats per window per session |
|
||||
| `RATE_LIMIT_WINDOW_SECS` | 10 s | Rate limit window |
|
||||
| `MAX_TIMESTAMP_DRIFT_MS` | 30 000 ms | Anti-replay window; account for NTP skew |
|
||||
| `MIN_MOUSE_TOTAL_DIST` | 10.0 px | Lower for low-activity pages |
|
||||
| `MAX_MOUSE_AVG_SPEED` | 2.0 px/ms | Raise if legitimate users are rejected |
|
||||
| `MIN_PAUSE_COUNT` | 1 | Minimum natural pause events |
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
ChronoSeal uses `tracing` with `tracing-subscriber`. Log levels:
|
||||
|
||||
| Level | Events |
|
||||
|---|---|
|
||||
| `INFO` | Server start, request method + path + status |
|
||||
| `WARN` | Heartbeat validation failures (with session ID and reason) |
|
||||
| `DEBUG` | Rate limit hits |
|
||||
|
||||
```bash
|
||||
RUST_LOG=info chronoseal # production
|
||||
RUST_LOG=debug chronoseal # development
|
||||
RUST_LOG=warn chronoseal # minimal output
|
||||
```
|
||||
|
||||
Log format is plain text to stdout. Pipe to `journald`, `fluentd`, or any
|
||||
log aggregator via stdout capture.
|
||||
|
||||
---
|
||||
|
||||
## Health Check
|
||||
|
||||
The server has no dedicated `/health` endpoint. Use a TCP check on port 3000,
|
||||
or a lightweight HTTP check on `GET /` (which serves `index.html`).
|
||||
|
||||
```bash
|
||||
# Docker health check (add to docker-compose.yml if needed)
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] TLS 1.3 enabled, TLS 1.0/1.1 disabled
|
||||
- [ ] HTTP/2 enabled
|
||||
- [ ] Port 3000 not exposed to the public internet (only via reverse proxy)
|
||||
- [ ] `RUST_LOG=warn` or `info` in production (not `debug` — session IDs appear in logs)
|
||||
- [ ] systemd service running as `chronoseal` user with hardened sandbox
|
||||
- [ ] `MemoryDenyWriteExecute=true` in service file (prevents JIT in process)
|
||||
- [ ] CORS `CorsLayer::permissive()` replaced with origin-restricted policy for production
|
||||
- [ ] Frontend assets served over the same HTTPS origin as protected pages
|
||||
@@ -0,0 +1,205 @@
|
||||
# ChronoSeal — Threat Model
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines what ChronoSeal is designed to protect against, what
|
||||
it explicitly does not protect against, and the reasoning behind each
|
||||
design decision in security terms.
|
||||
|
||||
ChronoSeal is a **cost-raising mechanism**. It does not claim to make
|
||||
automated access impossible. It makes automated access expensive, complex
|
||||
to maintain, and operationally fragile at scale.
|
||||
|
||||
---
|
||||
|
||||
## Assets Being Protected
|
||||
|
||||
| Asset | Description |
|
||||
|---|---|
|
||||
| Web page content | HTML, rendered data, scraped text |
|
||||
| API responses | JSON endpoints that serve structured data |
|
||||
| Server compute | CPU and bandwidth consumed by automated clients |
|
||||
| Rate-limited resources | Endpoints with per-user quotas |
|
||||
| Behavioral analytics | Metrics polluted by bot traffic |
|
||||
|
||||
---
|
||||
|
||||
## Attacker Profiles
|
||||
|
||||
### Level 1 — Script Kiddie / Commodity Scraper
|
||||
|
||||
**Tools:** `curl`, `requests`, `scrapy`, simple HTTP clients.
|
||||
**Capability:** No browser environment. Cannot execute JavaScript or WASM.
|
||||
**ChronoSeal response:** Session never initialises. No `session_id` is ever
|
||||
presented to `/hb`. Content gated behind session validation is never served.
|
||||
|
||||
### Level 2 — Headless Browser Operator
|
||||
|
||||
**Tools:** Playwright, Puppeteer, Selenium, undetected-chromedriver.
|
||||
**Capability:** Full browser environment. Can execute JavaScript and WASM.
|
||||
Cannot easily synthesise realistic mouse entropy or maintain hash chain state
|
||||
across concurrent sessions.
|
||||
**ChronoSeal response:** Mouse entropy validation rejects absent or synthetic
|
||||
movement. Hash chain requires per-session state synchronisation. Scaling to
|
||||
hundreds of concurrent sessions requires proportional infrastructure.
|
||||
|
||||
### Level 3 — Stealth Automation
|
||||
|
||||
**Tools:** Puppeteer Stealth, rebrowser-patches, custom CDP clients with
|
||||
evasion patches.
|
||||
**Capability:** Patches `navigator.webdriver`, spoofs browser fingerprints,
|
||||
can inject synthetic mouse events. May partially pass behavioral checks.
|
||||
**ChronoSeal response:** Ed25519 signature over the full payload (including
|
||||
behavioral state and VM execution result) means the attacker must also
|
||||
correctly execute the WASM program and maintain chain continuity. The private
|
||||
key is generated fresh per page load and never exposed — it cannot be
|
||||
extracted from a legitimate session and reused.
|
||||
|
||||
### Level 4 — Sophisticated Adversary
|
||||
|
||||
**Tools:** Full browser farm with real input devices, WASM reverse engineering,
|
||||
custom chain maintenance infrastructure.
|
||||
**Capability:** Can pass all current ChronoSeal checks given sufficient
|
||||
engineering effort.
|
||||
**ChronoSeal response:** Significantly increases operational cost. A browser
|
||||
farm with real input devices costs orders of magnitude more than a commodity
|
||||
scraper fleet. ChronoSeal is not designed to stop this attacker — no client-
|
||||
side protection can.
|
||||
|
||||
---
|
||||
|
||||
## Attack Vectors and Mitigations
|
||||
|
||||
### Replay Attack
|
||||
|
||||
**Attack:** Capture a valid heartbeat payload and retransmit it.
|
||||
**Mitigation:**
|
||||
- Timestamp window (±30 seconds): replayed payloads are rejected after 30s.
|
||||
- Hash chain: each heartbeat must present `H(n-1)` matching the server's
|
||||
stored state. A replayed heartbeat presents a stale hash that no longer
|
||||
matches after one successful heartbeat has advanced the chain.
|
||||
|
||||
### Signature Forgery
|
||||
|
||||
**Attack:** Construct a valid-looking heartbeat payload without the private key.
|
||||
**Mitigation:** Ed25519 with 128-bit security. The private key is generated
|
||||
inside WASM `thread_local` memory, never serialised, never passed to
|
||||
JavaScript, never transmitted. Forgery requires breaking Ed25519 or
|
||||
extracting the key from WASM memory — neither is practical.
|
||||
|
||||
### Key Extraction
|
||||
|
||||
**Attack:** Inspect WASM linear memory to extract the private signing key.
|
||||
**Mitigation:** The key is stored in a Rust `thread_local! { RefCell<Option<SigningKey>> }`.
|
||||
It has no exported symbol and is not referenced by any exported WASM function
|
||||
that returns raw memory. An attacker with full DevTools access to the WASM
|
||||
memory can extract it from one session, but it is useless for other sessions
|
||||
(fresh keypair per page load) and expires with the session.
|
||||
|
||||
### Hash Chain Forgery
|
||||
|
||||
**Attack:** Compute a valid `H(n)` without the server-side salt.
|
||||
**Mitigation:** Each chain link incorporates `saltₙ₋₁`, which is a 16-byte
|
||||
random value known only to the server and returned (once) in the heartbeat
|
||||
response. An attacker cannot compute `H(n+1)` without first receiving
|
||||
`saltₙ` from a successful heartbeat response, which requires a valid signature
|
||||
and all other checks to pass.
|
||||
|
||||
### Session Hijacking
|
||||
|
||||
**Attack:** Steal a `session_id` and use it from a different client.
|
||||
**Mitigation:** `session_id` alone is insufficient — the attacker also needs
|
||||
the private key (to produce valid signatures) and the current chain state
|
||||
(to present the correct `prev_hash`). All three are required simultaneously.
|
||||
|
||||
### Enumeration of Validation Rules
|
||||
|
||||
**Attack:** Send malformed heartbeats and analyse error responses to map
|
||||
validation logic.
|
||||
**Mitigation:** All failure paths return `{"status":"ok"}` with no `next_salt`.
|
||||
There is no error code, no error message, and no status difference between
|
||||
a rate limit hit, an invalid signature, a broken chain, and a behavioral
|
||||
rejection.
|
||||
|
||||
### DoS via Session Flooding
|
||||
|
||||
**Attack:** Open thousands of sessions to exhaust the rate limiter's HashMap
|
||||
memory.
|
||||
**Mitigation:** Rate limiter entries are evicted every 60 seconds by the
|
||||
cleanup task. Each entry is a small `(u32, Instant)` tuple; even at 100,000
|
||||
concurrent fake sessions, the HashMap occupies roughly 10–15 MB, which is
|
||||
well within normal server memory budgets. Sessions themselves expire after 30
|
||||
minutes of inactivity and are purged from SQLite.
|
||||
|
||||
### Clock Manipulation
|
||||
|
||||
**Attack:** Manipulate the client's `Date.now()` to bypass the timestamp
|
||||
window.
|
||||
**Mitigation:** The timestamp is included in the signed payload. Manipulating
|
||||
it requires also forging the signature. The server validates against its own
|
||||
clock — client-side clock manipulation cannot help without the private key.
|
||||
|
||||
### Synthetic Mouse Events
|
||||
|
||||
**Attack:** Inject programmatic `mousemove` events via `dispatchEvent` or
|
||||
CDP input simulation.
|
||||
**Mitigation:** Synthetic events often fail the pause check (no natural dwell
|
||||
periods), produce unrealistically uniform speed profiles, or fail the minimum
|
||||
distance threshold. Generating convincingly human mouse traces at scale
|
||||
requires either real input devices or sophisticated probabilistic models —
|
||||
both significantly increase operational cost.
|
||||
|
||||
---
|
||||
|
||||
## What ChronoSeal Does Not Protect Against
|
||||
|
||||
| Limitation | Explanation |
|
||||
|---|---|
|
||||
| Real browsers with real users acting as bots | A human operating a browser manually is indistinguishable from a legitimate visitor. ChronoSeal cannot address this. |
|
||||
| Server-side vulnerabilities | ChronoSeal is a client attestation layer. It does not protect the server from injection, authentication bypass, or other backend vulnerabilities. |
|
||||
| Highly resourced nation-state actors | Out of scope for a client-side protection layer. |
|
||||
| Content visible before session establishment | If the protected content is rendered before the first heartbeat, it can be scraped without a session. Gate content on session validity server-side. |
|
||||
| Perfect bot prevention | No client-side mechanism can be. WASM can be reverse engineered. ChronoSeal raises cost, not an impenetrable barrier. |
|
||||
|
||||
---
|
||||
|
||||
## Operational Security Notes
|
||||
|
||||
### Log Level
|
||||
|
||||
Do not run with `RUST_LOG=debug` in production. The debug log includes
|
||||
`session_id` values, which are sensitive identifiers. Use `warn` or `info`.
|
||||
|
||||
### CORS Policy
|
||||
|
||||
The default `CorsLayer::permissive()` is suitable for development only.
|
||||
In production, restrict allowed origins to your own domain:
|
||||
|
||||
```rust
|
||||
CorsLayer::new()
|
||||
.allow_origin("https://your.domain.com".parse::<HeaderValue>().unwrap())
|
||||
.allow_methods([Method::POST])
|
||||
.allow_headers([header::CONTENT_TYPE])
|
||||
```
|
||||
|
||||
### TLS
|
||||
|
||||
Serve exclusively over TLS 1.3. The heartbeat payload contains timestamps
|
||||
and behavioral signals. While each payload is signed and cannot be forged,
|
||||
plaintext transmission leaks behavioral patterns and timing information that
|
||||
could assist a sophisticated attacker.
|
||||
|
||||
### In-Memory SQLite
|
||||
|
||||
All session state is lost on server restart. This is intentional — there is
|
||||
no persistent state to steal. Clients transparently re-initialise. If your
|
||||
deployment restarts frequently (e.g. rolling deploys), sessions will be lost
|
||||
more often; tune `HEARTBEAT_MIN_INTERVAL_MS` and `EXPIRATION_MINUTES`
|
||||
accordingly so clients recover quickly.
|
||||
|
||||
---
|
||||
|
||||
## Security Disclosure
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the vulnerability disclosure policy
|
||||
and contact details.
|
||||
Reference in new issue
Block a user