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)
16 KiB
ChronoSeal — Architecture
Overview
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 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.
Design Principles
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.
Component Map
┌─────────────────────────────────────────────────────────┐
│ 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
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
Fired every 12–25 seconds with uniform random jitter.
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. Failure Path
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.
Cryptographic Protocol
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) ← must match stored last_hash
║ timestamp_u64_le
║ Blake3( JSON(entropy_data) )
║ Blake3( JSON(stack_state) )
)
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:
{
"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 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 | 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 |
The generator ensures ≥ 2 items on the stack before any binary opcode. NOT (0x08) does not change depth. HASH resets depth to 1.
Behavioral Validation
Mouse Entropy
Every heartbeat includes the mouse events collected since the previous heartbeat. Server checks:
| 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) |
Browser Fingerprint
| Signal | Valid range |
|---|---|
aspectRatio (width / height) |
0.5 – 3.0 |
devicePixelRatio |
0 < dpr ≤ 5.0 |
hardwareConcurrency |
≥ 1 |
Rate Limiting
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.
SQLite Schema
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
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, -- Unix ms
last_seen INTEGER NOT NULL, -- Unix ms
expires_at INTEGER NOT NULL -- Unix ms
);
In-memory SQLite — all sessions lost on server restart by design. Clients re-initialise transparently on the next page load.
Threat Model
In Scope
| 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 |
Out of Scope
| 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 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 |