diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..644be85 --- /dev/null +++ b/docs/API.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1af1e9d..0b03ad2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` 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 | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2a7b365..3974242 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 + + +``` + +--- + +## 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 diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..d0bccfe --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -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> }`. +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::().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.