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:
thakares committed 2026-05-09 18:04:13 +05:30
1 parent 851d3b4876
commit 4b27a342d1
4 files changed
+1017 -256

No files matched your search

+216
View File
@@ -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
View File
@@ -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
View File
@@ -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
+205
View File
@@ -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.