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)
6.9 KiB
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
POST /init
Content-Type: application/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
{
"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
POST /hb
Content-Type: application/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.
{
"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
{
"status": "ok",
"next_salt": "32-char hex string (16 bytes)"
}
The client must:
- Capture
sentSalt = currentSaltbefore updating. - Set
currentSalt = next_salt. - Compute
prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackStateJson, sentSalt).
Response 200 OK — Rejected
{
"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.