Files
nx9-chronoseal-rs/docs/ARCHITECTURE.md
T

326 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
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.
---
## Core 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 |
---
## System Components
```
┌─────────────────────────────────────────────────────────────┐
│ 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
```
---
## Session Lifecycle
### 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 │
```
### 2. Heartbeat (`POST /hb`)
```
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
```
### 3. Session Expiry
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.
---
## Hash Chain
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.
```
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 rotation means that even a full replay of a captured heartbeat is invalid
on the next cycle — the salt has changed.
---
## 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.
### 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 |
### 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
}
```
---
## Behavioral Validation
### 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 |
Speed is computed as `total_distance / total_elapsed_ms` over the event window.
### Fingerprint Signals
| 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 |
---
## 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.
---
## 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.
```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,
chain_length INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
```
For persistence across restarts, replace `Connection::open_in_memory()` in
`server/src/storage.rs` with `Connection::open("/var/lib/chronoseal/sessions.db")`.
---
## Threat Model
### What ChronoSeal raises the cost of
- **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
### What ChronoSeal does not prevent
- 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
ChronoSeal is a **cost-raising** mechanism, not an impenetrable barrier.
The goal is to make scraping at scale impractical, not impossible.