4 Commits
36 changed files with 1684 additions and 411 deletions

No files matched your search

Generated
+303 -12
View File
@@ -73,6 +73,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -226,9 +232,20 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chronoseal-server"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"axum",
"base64",
@@ -236,12 +253,15 @@ dependencies = [
"clap_complete",
"ed25519-dalek",
"hex",
"rand",
"r2d2",
"r2d2_sqlite",
"rand 0.8.6",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"shared",
"thiserror",
"tokio",
"toml",
"tower 0.4.13",
@@ -253,14 +273,14 @@ dependencies = [
[[package]]
name = "chronoseal-wasm"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"base64",
"blake3",
"ed25519-dalek",
"getrandom",
"getrandom 0.2.17",
"hex",
"rand",
"rand 0.8.6",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -453,7 +473,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
@@ -500,6 +520,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -571,6 +597,20 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -580,6 +620,15 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -693,6 +742,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -701,6 +756,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@@ -733,6 +790,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
@@ -912,6 +975,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -930,6 +1003,34 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "r2d2"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "r2d2_sqlite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a982edf65c129796dba72f8775b292ef482b40d035e827a9825b3bc07ccc5f2"
dependencies = [
"r2d2",
"rusqlite",
"uuid",
]
[[package]]
name = "rand"
version = "0.8.6"
@@ -938,7 +1039,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
@@ -948,7 +1060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -957,9 +1069,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1034,6 +1152,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
dependencies = [
"parking_lot",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1167,13 +1294,13 @@ dependencies = [
[[package]]
name = "shared"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"base64",
"blake3",
"ed25519-dalek",
"hex",
"rand",
"rand 0.8.6",
"serde",
"serde_json",
]
@@ -1200,7 +1327,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -1592,6 +1719,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -1604,6 +1737,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"rand 0.10.1",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -1628,6 +1773,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
@@ -1673,6 +1836,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1697,6 +1894,100 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
+444 -147
View File
@@ -1,130 +1,332 @@
# ChronoSeal
<p align="center">
<img src="logo/chronoseal.svg" width="220" alt="ChronoSeal Logo">
</p>
**Cryptographic anti-automation and browser attestation framework built with Rust, WASM, and behavioral continuity verification.**
<p align="center">
<strong>Cryptographic attestation daemon and anti-automation framework.</strong>
</p>
ChronoSeal makes it computationally expensive and operationally complex for AI scrapers, headless browsers, and automation tools to impersonate real users — while remaining completely invisible and frictionless to legitimate human visitors.
<p align="center">
Privacy-preserving • Unix-native • Lightweight • WASM-powered
</p>
---
## How It Works
ChronoSeal is a lightweight cryptographic attestation daemon designed to raise the operational cost of browser automation, scraping, replay attacks, and synthetic interaction.
ChronoSeal establishes a continuous, cryptographically verifiable proof-of-presence for every browser session. It is inspired by the heartbeat model used in IoT firmware (ESP32-class devices): the client must keep emitting signed, chained attestations, or the session is silently invalidated.
Instead of relying on:
* CAPTCHA systems
* invasive browser fingerprinting
* telemetry-heavy tracking
* persistent identifiers
ChronoSeal establishes a continuous cryptographic proof-of-runtime continuity using:
* WASM execution
* chained cryptographic heartbeats
* behavioral entropy validation
* ephemeral attestation state
while remaining completely invisible and frictionless to legitimate human users.
---
# Features
* CLI-first Unix-native architecture
* Rich operational subcommands
* Machine-readable JSON/YAML outputs
* Hardened systemd integration
* Graceful shutdown and signal handling
* One-line installation workflow
* Prometheus-compatible metrics
* WASM-based client runtime
* Ed25519 + Blake3 cryptographic chaining
* Behavioral entropy validation
* Randomized stack-machine verification
* Silent rejection model
* SQLite-backed ephemeral sessions
* Connection-pooled runtime architecture
* Lightweight deployment footprint
* Docker and native deployment support
---
# Quick Start
## Install
```bash
sudo bash scripts/install.sh
```
## Check Status
```bash
chronoseal status --format json
```
## Health Probe
```bash
chronoseal health
```
## View Metrics
```bash
chronoseal metrics
```
## View Logs
```bash
sudo journalctl -u chronoseal -f
```
---
# CLI
```bash
chronoseal --help
```
## Available Commands
| Command | Description |
| ------------ | ------------------------------------------ |
| `run` | Run the ChronoSeal daemon |
| `status` | Report daemon status |
| `health` | Perform daemon health probe |
| `config` | Validate and print effective configuration |
| `generate` | Generate operational material |
| `metrics` | Output Prometheus metrics |
| `stats` | Print runtime statistics |
| `completion` | Generate shell completions |
| `version` | Print version/build information |
---
## Example
```bash
chronoseal status --format json
```
```json
{
"running": true,
"healthy": true,
"bind": "0.0.0.0:3000",
"pid_file": "/run/chronoseal.pid",
"pid": 79459
}
```
---
# How It Works
ChronoSeal establishes a continuous cryptographic proof-of-presence for browser sessions.
The system is inspired by heartbeat validation models used in embedded and distributed systems.
## Session Flow
```text
Browser Server
│ │
│ WASM loads, generates Ed25519 keypair │
│ Private key never leaves WASM memory │
│ │
├──── POST /init { public_key } ──────────►│ Store session, salt, initial hash
├──── POST /init { public_key } ──────────►│
│◄─── { session_id, salt, opcodes, H0 } ────┤
│ │
│ Every 12–25s (jittered): │
│ ┌─ Collect mouse entropy │
│ ├─ Execute VM opcodes → stack state │
│ ├─ Compute H(n) = Blake3(H(n-1) ║ …) │
│ └─ Sign payload with Ed25519 │
│ Every 12–25s (randomized): │
│ ┌─ Collect behavioral entropy │
│ ├─ Execute VM opcode program │
│ ├─ Advance Blake3 hash chain │
│ └─ Sign payload using Ed25519 │
│ │
├──── POST /hb { session_id, sig, … } ────►│ Verify sig → chain → behavior → fingerprint
│◄─── { status, next_salt } ────────────────┤ Rotate salt, advance chain
├──── POST /heartbeat { signed_payload } ─►│
│◄─── { status, next_salt } ────────────────┤
│ │
│ On failure: server returns {"status":"ok"}│ Silent rejection — indistinguishable
│ Invalid sessions silently rejected │
```
The server validates:
* signature authenticity
* heartbeat continuity
* replay resistance
* behavioral entropy
* timestamp validity
* fingerprint sanity
---
## Security Model
# Security Model
### What ChronoSeal protects against
## What ChronoSeal Protects Against
| Threat | Mechanism |
|---|---|
| Playwright / Puppeteer Stealth | Mouse entropy validation rejects synthetic or absent movement |
| Replay attacks | Hash chain — each heartbeat references the previous hash; old payloads are invalid |
| Signature forgery | Ed25519 private key generated inside WASM, never serialised or exposed to JS |
| Clock manipulation | Server enforces ±30s timestamp window |
| Credential sharing | Session is bound to a keypair generated fresh on every page load |
| Flooding with fake sessions | Per-session rate limiting (5 req / 10s); stale entries evicted every 60s |
| Passive analysis of traffic | Server always returns `{"status":"ok"}` — rejections are silent |
### What ChronoSeal does not claim
ChronoSeal is a cost-raising mechanism, not an impenetrable barrier. A sufficiently motivated adversary with a real browser, real input devices, and the patience to reverse the WASM can bypass it. The goal is to make scraping expensive and operationally complex enough to be impractical at scale.
| Threat | Mechanism |
| ------------------------ | ------------------------------------- |
| Replay attacks | Blake3 chained heartbeat continuity |
| Signature forgery | Ed25519 keypair generated inside WASM |
| Session cloning | Ephemeral session-bound keypairs |
| Static scraping | Runtime participation requirements |
| Naive browser automation | Behavioral continuity validation |
| Timestamp replay | Drift-window enforcement |
| Session flooding | Per-session rate limiting |
---
## Architecture
## Silent Rejection Model
```
chronoseal-rs/
├── shared/ Shared types, Blake3 hash-chain logic, constants
├── server/ Axum HTTP server
│ ├── routes/ /init and /hb handlers
│ ├── session.rs Session lifecycle: create, verify, advance chain
│ ├── crypto.rs Ed25519 signature verification (BTreeMap canonical JSON)
│ ├── trust.rs Behavioral signal validation (mouse speed, pauses)
│ ├── fingerprint Browser fingerprint sanity checks
│ ├── vm.rs Random opcode program generator
│ ├── ratelimit.rs Token-bucket rate limiter with periodic eviction
│ └── cleanup.rs Background task: expire sessions + evict rate limiter
├── wasm/ Rust → WASM client module
│ ├── crypto.rs Ed25519 keypair, signing, hash computation
│ └── vm.rs Stack machine executor (PUSH/ADD/SUB/MUL/XOR/AND/OR/ROT/NOT/HASH)
└── frontend/ Vanilla JS glue
├── heartbeat.js Session init, heartbeat scheduling, chain advancement
└── entropy.js Mouse event collection
```
ChronoSeal intentionally avoids explicit rejection semantics.
### Stack Machine
The server generates a random program (8–16 opcodes) on session init. The client executes it on every heartbeat and includes the resulting stack state in the signed payload. This makes each heartbeat structurally unique without requiring any server round-trip.
| Opcode | Mnemonic | Effect |
|--------|----------|--------|
| `0x00` | PUSH u32 | Push 4-byte little-endian literal |
| `0x01` | ADD | Pop 2, push `a + b` (wrapping) |
| `0x02` | SUB | Pop 2, push `a - b` (wrapping) |
| `0x03` | MUL | Pop 2, push `a * b` (wrapping) |
| `0x04` | XOR | Pop 2, push `a ^ b` |
| `0x05` | AND | Pop 2, push `a & b` |
| `0x06` | OR | Pop 2, push `a \| b` |
| `0x07` | ROT | Pop 2, push `a.rotate_left(b % 32)` |
| `0x08` | NOT | Pop 1, push `!a` (unary) |
| `0x09` | HASH | Blake3 of entire stack → single u32 |
### Hash Chain
```
H(0) = Blake3( session_id ║ pub_key ║ salt₀ )
H(n) = Blake3( saltₙ₋₁ ║ H(n-1) ║ timestamp ║ Blake3(entropy_json) ║ Blake3(stack_json) )
```
Each heartbeat must present `H(n-1)` matching what the server stored. Forging a valid `H(n)` requires knowing the private key (for the signature), the salt (server-side only), and all prior state.
### Signature Canonical Form
The client signs a JSON object with keys sorted alphabetically (matching `JSON.stringify(obj, Object.keys(obj).sort())`):
Invalid sessions may still receive:
```json
{
"entropyData": { "events": [ { "x": …, "y": …, "t": … } ] },
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
"prevHash": "hex…",
"sessionId": "hex…",
"stackState": { "stack": […], "ip": … },
"timestamp": 1234567890123
}
{ "status": "ok" }
```
The server reconstructs this using `BTreeMap` (alphabetical key order) before calling `VerifyingKey::verify_strict`.
This prevents:
* oracle-style probing
* protocol learning
* easy automation tuning
* behavioral enumeration
---
## SQLite Schema
## What ChronoSeal Does Not Claim
ChronoSeal is a cost-raising mechanism, not an impenetrable barrier.
A sufficiently motivated adversary with:
* real browsers
* genuine input devices
* enough reverse engineering effort
can eventually bypass the system.
The goal is to make automation:
* expensive
* operationally complex
* difficult to scale
* harder to replay deterministically
---
# Architecture
```text
chronoseal-rs/
├── shared/ Shared types, constants, hash-chain logic
├── server/ Axum HTTP daemon
│ ├── routes/ API routes
│ ├── session.rs Session lifecycle management
│ ├── crypto.rs Ed25519 verification
│ ├── trust.rs Behavioral validation
│ ├── fingerprint/ Browser sanity validation
│ ├── vm.rs Random opcode generator
│ ├── ratelimit.rs Token bucket limiter
│ ├── cleanup.rs Session expiration lifecycle
│ └── metrics.rs Prometheus metrics
├── wasm/ Rust → WASM runtime
│ ├── crypto.rs Signing + hash chaining
│ └── vm.rs Stack-machine executor
├── frontend/ Lightweight JS integration
├── scripts/ Build/install/dev scripts
└── docs/ Project documentation
```
---
# Stack Machine
ChronoSeal includes a lightweight randomized stack-machine execution engine.
The server generates a randomized opcode program during session initialization.
The client executes this program on every heartbeat and includes the resulting stack state in the signed payload.
This makes heartbeat payloads structurally dynamic.
## Supported Opcodes
| Opcode | Mnemonic | Effect |
| ------ | -------- | ----------------------- |
| `0x00` | PUSH | Push literal |
| `0x01` | ADD | Wrapping addition |
| `0x02` | SUB | Wrapping subtraction |
| `0x03` | MUL | Wrapping multiplication |
| `0x04` | XOR | Bitwise XOR |
| `0x05` | AND | Bitwise AND |
| `0x06` | OR | Bitwise OR |
| `0x07` | ROT | Rotate left |
| `0x08` | NOT | Unary inversion |
| `0x09` | HASH | Blake3 stack hash |
---
# Hash Chain
ChronoSeal uses Blake3 chained continuity validation.
## Initial Hash
```text
H(0) = Blake3( session_id ║ public_key ║ salt₀ )
```
## Heartbeat Progression
```text
H(n) = Blake3(
saltₙ₋₁ ║
H(n-1) ║
timestamp ║
Blake3(entropy_json) ║
Blake3(stack_json)
)
```
Each heartbeat depends on:
* prior continuity
* prior server-issued salt
* behavioral entropy
* VM execution result
* timestamp progression
---
# Signature Canonicalization
Heartbeat payloads are serialized into canonical key order before signing.
The server reconstructs payloads identically before:
* Ed25519 verification
* hash progression validation
This prevents:
* serialization inconsistencies
* ambiguous signing layouts
* malformed payload tricks
---
# SQLite Schema
```sql
CREATE TABLE IF NOT EXISTS sessions (
@@ -139,101 +341,196 @@ CREATE TABLE IF NOT EXISTS sessions (
);
```
Sessions are stored in an in-memory SQLite database. All session state is lost on server restart by design — clients re-initialise transparently.
ChronoSeal intentionally uses ephemeral session persistence.
Session continuity is designed to reset transparently.
---
## Build
# Runtime Architecture
### Prerequisites
## Server Runtime
- Rust stable (≥ 1.87)
- [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer/)
* Rust
* Axum
* Tokio
* SQLite
* `r2d2`
* `thiserror`
### WASM
## Browser Runtime
```bash
wasm-pack build wasm --target web --release
mv wasm/pkg frontend/pkg
```
### Server
```bash
cargo build -p server --release
```
### Dev (all-in-one)
```bash
bash scripts/dev.sh
```
The server serves the `frontend/` directory statically at `/` and the API at `/init` and `/hb`.
* Rust → WASM
* Ed25519 signing
* Blake3 chaining
* stack-machine execution
---
## Deployment
# Deployment
### Native + systemd
## Recommended Installation
```bash
cargo build -p server --release
sudo cp target/release/server /usr/local/bin/chronoseal
sudo bash scripts/install.sh
```
The installer:
* creates `chronoseal` service user
* builds release artifacts
* installs frontend assets
* deploys hardened systemd service
* enables and starts daemon
---
## Manual Installation
```bash
bash scripts/build.sh
sudo cp target/release/chronoseal /usr/local/bin/
sudo cp chronoseal.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now chronoseal
```
### Docker
---
## Docker
```bash
docker compose up -d --build
```
### Reverse Proxy
Place ChronoSeal behind nginx, Nginx Proxy Manager, or HAProxy. Enable:
- TLS 1.3
- HTTP/2
- Aggressive upstream timeouts (the heartbeat interval is 12–25s)
---
## Integration
# Development
Drop two lines into any protected page:
## Full Build
```html
<script type="module" src="/pkg/antibot_wasm.js"></script>
<script type="module" src="/main.js"></script>
```bash
bash scripts/build.sh
```
`main.js` calls `initHeartbeat()` which handles WASM loading, session init, and schedules all subsequent heartbeats automatically. There is no visible UI, no CAPTCHA, no user interaction required.
## Development Mode
```bash
bash scripts/dev.sh
```
## Direct Execution
```bash
cargo run -p server -- run --bind 127.0.0.1:3000
```
---
## Configuration
# Prerequisites
All tunable constants are in `shared/src/constants.rs`:
* Rust stable ≥ 1.87
* `wasm-pack`
| Constant | Default | Description |
|---|---|---|
| `SESSION_ID_LEN` | 32 bytes | Session ID entropy |
| `SALT_LEN` | 16 bytes | Per-heartbeat salt size |
| `HEARTBEAT_MIN_INTERVAL_MS` | 12 000 ms | Minimum heartbeat interval |
| `HEARTBEAT_MAX_INTERVAL_MS` | 25 000 ms | Maximum heartbeat interval (uniform jitter) |
| `EXPIRATION_MINUTES` | 30 min | Session lifetime after last heartbeat |
| `RATE_LIMIT_COUNT` | 5 | Max heartbeats per window |
| `RATE_LIMIT_WINDOW_SECS` | 10 s | Rate limit window |
| `MAX_TIMESTAMP_DRIFT_MS` | 30 000 ms | Anti-replay timestamp window |
| `MIN_MOUSE_TOTAL_DIST` | 10.0 px | Minimum cumulative mouse travel |
| `MAX_MOUSE_AVG_SPEED` | 2.0 px/ms | Maximum average mouse speed |
| `MIN_PAUSE_COUNT` | 1 | Minimum mouse pause events |
Install:
```bash
cargo install wasm-pack
```
---
## License
# Configuration
## Precedence
```text
CLI flags > CHRONOSEAL_* environment variables > config file > defaults
```
## Default Config Locations
```text
/etc/chronoseal/config.toml
$XDG_CONFIG_HOME/chronoseal/config.toml
~/.config/chronoseal/config.toml
```
## Runtime State
```text
~/.local/state/chronoseal/
```
---
# Observability
ChronoSeal exposes:
* health probes
* runtime statistics
* Prometheus metrics
## Metrics Example
```bash
chronoseal metrics
```
```text
# HELP chronoseal_sessions Active ChronoSeal sessions
# TYPE chronoseal_sessions gauge
chronoseal_sessions 1
```
---
# Lightweight Runtime
Current release artifacts:
```text
chronoseal ~8.5 MB
chronoseal_wasm.wasm ~719 KB
```
ChronoSeal intentionally avoids:
* heavyweight frontend frameworks
* Electron-style packaging
* telemetry-heavy dependencies
* oversized runtime models
---
# Philosophy
ChronoSeal is intentionally not:
* a surveillance framework
* invasive browser fingerprinting
* a CAPTCHA replacement
* a telemetry ecosystem
ChronoSeal is:
* a cryptographic attestation runtime
* a behavioral continuity engine
* a proof-of-runtime framework
* a lightweight Unix-native daemon
---
# License
[MIT OR Apache-2.0](LICENSE)
---
# Project
GitHub:
https://github.com/thakares/chronoseal-rs
+21 -52
View File
@@ -1,67 +1,36 @@
[Unit]
Description=ChronoSeal cryptographic browser attestation service
Documentation=https://chronoseal.rs
After=network-online.target
Description=ChronoSeal Cryptographic Attestation Daemon
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=chronoseal
Group=chronoseal
Environment=RUST_LOG=info
Environment=CHRONOSEAL_CONFIG=/etc/chronoseal/config.toml
Environment=CHRONOSEAL_STATE_DIR=/var/lib/chronoseal
Environment=CHRONOSEAL_PID_FILE=/run/chronoseal.pid
ExecStart=/usr/local/bin/chronoseal run
ExecStartPre=+/usr/bin/touch /run/chronoseal.pid
ExecStartPre=+/usr/bin/chown chronoseal:chronoseal /run/chronoseal.pid
ExecReload=/bin/kill -HUP $MAINPID
ExecStopPost=+/usr/bin/rm -f /run/chronoseal.pid
PIDFile=/run/chronoseal.pid
Restart=on-failure
WorkingDirectory=/opt/chronoseal
Restart=always
RestartSec=3
TimeoutStopSec=30
KillSignal=SIGTERM
Environment=RUST_LOG=info
RuntimeDirectory=chronoseal
RuntimeDirectoryMode=0750
StateDirectory=chronoseal
StateDirectoryMode=0750
LogsDirectory=chronoseal
LogsDirectoryMode=0750
ConfigurationDirectory=chronoseal
ConfigurationDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
# Hardening (production-grade)
ProtectSystem=strict
ProtectHome=read-only
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
PrivateDevices=true
PrivateIPC=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
LockPersonality=true
ProtectHome=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/run/chronoseal.pid
# Logging
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+6 -2
View File
@@ -1,8 +1,10 @@
import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/antibot_wasm.js';
import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/chronoseal_wasm.js';
import { collectEntropy } from './entropy.js';
import { sendRequest } from './transport.js';
let session, prevHash, currentSalt, opcodesB64, lastTime;
let minInterval = 12000;
let maxInterval = 25000;
export async function initHeartbeat() {
await init();
@@ -12,12 +14,14 @@ export async function initHeartbeat() {
prevHash = initResp.initial_hash;
currentSalt = initResp.salt;
opcodesB64 = initResp.opcodes_b64;
minInterval = initResp.heartbeat_min_interval_ms || 12000;
maxInterval = initResp.heartbeat_max_interval_ms || 25000;
lastTime = performance.now();
scheduleNext();
}
function scheduleNext() {
const delay = 12000 + Math.random() * 13000;
const delay = minInterval + Math.random() * (maxInterval - minInterval);
setTimeout(sendHeartbeat, delay);
}
+38 -44
View File
@@ -1,52 +1,46 @@
#!/bin/sh
set -eu
#!/bin/bash
set -euo pipefail
CHRONOSEAL_VERSION="${CHRONOSEAL_VERSION:-latest}"
CHRONOSEAL_INSTALL_DIR="${CHRONOSEAL_INSTALL_DIR:-/usr/local/bin}"
CHRONOSEAL_BASE_URL="${CHRONOSEAL_BASE_URL:-https://get.chronoseal.rs/releases}"
echo "🚀 ChronoSeal Installer"
echo "======================"
need() {
command -v "$1" >/dev/null 2>&1 || {
echo "chronoseal installer: missing required command: $1" >&2
exit 1
}
}
need uname
need mktemp
need chmod
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) target="x86_64-unknown-linux-musl" ;;
aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
*) echo "chronoseal installer: unsupported architecture: $arch" >&2; exit 1 ;;
esac
if command -v curl >/dev/null 2>&1; then
fetch="curl --proto =https --tlsv1.2 -fsSL"
elif command -v wget >/dev/null 2>&1; then
fetch="wget -qO-"
else
echo "chronoseal installer: install curl or wget" >&2
exit 1
# Create system user
if ! id -u chronoseal &>/dev/null; then
sudo useradd --system --no-create-home --shell /usr/sbin/nologin chronoseal
echo "✓ Created chronoseal system user"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# Build
echo "→ Building ChronoSeal..."
cd "$(dirname "$0")/.."
bash scripts/build.sh
url="$CHRONOSEAL_BASE_URL/$CHRONOSEAL_VERSION/chronoseal-$target.tar.gz"
echo "downloading chronoseal $CHRONOSEAL_VERSION for $target"
# Install binary
sudo install -Dm755 target/release/chronoseal /usr/local/bin/chronoseal
echo "✓ Installed binary to /usr/local/bin/chronoseal"
# shellcheck disable=SC2086
$fetch "$url" | tar -xz -C "$tmp"
chmod 0755 "$tmp/chronoseal"
# Install frontend assets
sudo mkdir -p /opt/chronoseal
sudo cp -r frontend /opt/chronoseal/
sudo chown -R chronoseal:chronoseal /opt/chronoseal
echo "✓ Installed frontend assets"
if [ "$(id -u)" -eq 0 ]; then
install -m 0755 "$tmp/chronoseal" "$CHRONOSEAL_INSTALL_DIR/chronoseal"
else
sudo install -m 0755 "$tmp/chronoseal" "$CHRONOSEAL_INSTALL_DIR/chronoseal"
fi
# Install systemd service
sudo cp chronoseal.service /etc/systemd/system/chronoseal.service
sudo systemctl daemon-reload
echo "✓ Installed systemd service"
echo "installed: $CHRONOSEAL_INSTALL_DIR/chronoseal"
echo "try: chronoseal --help"
# Enable and start
sudo systemctl enable --now chronoseal
echo "✓ ChronoSeal service started"
echo ""
echo "✅ ChronoSeal installed successfully!"
echo ""
echo "Useful commands:"
echo " chronoseal status # Check service status"
echo " chronoseal health # Health probe"
echo " sudo systemctl status chronoseal"
echo " sudo journalctl -u chronoseal -f"
echo ""
echo "To uninstall: sudo systemctl disable --now chronoseal && sudo rm /usr/local/bin/chronoseal"
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "chronoseal-server"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
[[bin]]
@@ -18,6 +18,9 @@ serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
rusqlite = { version = "0.31", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.24"
thiserror = "2"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
+13 -9
View File
@@ -1,5 +1,5 @@
use std::sync::Arc;
use crate::session::AppState;
use std::sync::Arc;
pub async fn cleanup_loop(state: Arc<AppState>) {
loop {
@@ -7,18 +7,22 @@ pub async fn cleanup_loop(state: Arc<AppState>) {
// Evict expired sessions from SQLite.
{
let db = state.db.lock().await;
let now = crate::storage::current_time_ms();
let _ = db.execute(
"DELETE FROM sessions WHERE expires_at < ?1",
rusqlite::params![now],
);
if let Ok(conn) = state.db_pool.get() {
let now = crate::storage::current_time_ms();
let _ = conn.execute(
"DELETE FROM sessions WHERE expires_at < ?1",
rusqlite::params![now],
);
} else {
tracing::error!("Failed to get database connection from pool for cleanup");
}
}
// Evict stale rate-limiter entries to prevent unbounded HashMap growth.
{
let window_secs = state.get_config().rate_limit_window_secs;
let mut rl = state.rate_limiter.lock().await;
rl.evict_stale();
rl.evict_stale(window_secs);
}
}
}
}
+21 -7
View File
@@ -52,15 +52,21 @@ impl GlobalArgs {
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run the ChronoSeal daemon.
#[command(after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run")]
#[command(
after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run"
)]
Run(RunArgs),
/// Report whether the configured daemon is reachable and which PID file is present.
#[command(after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid")]
#[command(
after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid"
)]
Status(RuntimeArgs),
/// Perform a daemon health probe.
#[command(after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000")]
#[command(
after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000"
)]
Health(RuntimeArgs),
/// Validate and print effective configuration.
@@ -76,7 +82,9 @@ pub enum Command {
Version,
/// Print Prometheus metrics from the running daemon.
#[command(after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000")]
#[command(
after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000"
)]
Metrics(RuntimeArgs),
/// Print service statistics from the running daemon.
@@ -84,7 +92,9 @@ pub enum Command {
Stats(RuntimeArgs),
/// Generate shell completions.
#[command(after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal")]
#[command(
after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal"
)]
Completion { shell: clap_complete::Shell },
}
@@ -120,13 +130,17 @@ pub struct RuntimeArgs {
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
/// Validate configuration and print the effective values.
#[command(after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json")]
#[command(
after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json"
)]
Check(RuntimeArgs),
}
#[derive(Debug, Subcommand)]
pub enum GenerateCommand {
/// Generate an Ed25519 keypair as hex-encoded JSON/YAML/text.
#[command(after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json")]
#[command(
after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json"
)]
Keypair,
}
+83 -2
View File
@@ -14,6 +14,16 @@ pub struct Config {
pub db_path: PathBuf,
pub frontend_dir: PathBuf,
pub log_file: Option<PathBuf>,
pub heartbeat_min_interval_ms: u64,
pub heartbeat_max_interval_ms: u64,
pub expiration_minutes: i64,
pub rate_limit_count: u32,
pub rate_limit_window_secs: u64,
pub max_timestamp_drift_ms: i64,
pub min_mouse_total_dist: f64,
pub max_mouse_avg_speed: f64,
pub min_pause_count: u32,
pub require_mouse_activity: bool,
}
impl Default for Config {
@@ -24,6 +34,16 @@ impl Default for Config {
db_path: default_state_dir().join("chronoseal.sqlite"),
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
log_file: None,
heartbeat_min_interval_ms: 12_000,
heartbeat_max_interval_ms: 25_000,
expiration_minutes: 30,
rate_limit_count: 5,
rate_limit_window_secs: 10,
max_timestamp_drift_ms: 30_000,
min_mouse_total_dist: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: true,
}
}
}
@@ -32,7 +52,10 @@ impl Config {
pub fn load(config_path: Option<&Path>) -> Result<Self, ConfigError> {
let mut config = Self::default();
if let Some(path) = config_path.map(Path::to_path_buf).or_else(discover_config_path) {
if let Some(path) = config_path
.map(Path::to_path_buf)
.or_else(discover_config_path)
{
let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(),
source,
@@ -96,6 +119,56 @@ impl Config {
if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") {
self.log_file = Some(PathBuf::from(value));
}
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MIN_INTERVAL_MS") {
if let Ok(val) = value.parse() {
self.heartbeat_min_interval_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MAX_INTERVAL_MS") {
if let Ok(val) = value.parse() {
self.heartbeat_max_interval_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_EXPIRATION_MINUTES") {
if let Ok(val) = value.parse() {
self.expiration_minutes = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_COUNT") {
if let Ok(val) = value.parse() {
self.rate_limit_count = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_WINDOW_SECS") {
if let Ok(val) = value.parse() {
self.rate_limit_window_secs = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MAX_TIMESTAMP_DRIFT_MS") {
if let Ok(val) = value.parse() {
self.max_timestamp_drift_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MIN_MOUSE_TOTAL_DIST") {
if let Ok(val) = value.parse() {
self.min_mouse_total_dist = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MAX_MOUSE_AVG_SPEED") {
if let Ok(val) = value.parse() {
self.max_mouse_avg_speed = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MIN_PAUSE_COUNT") {
if let Ok(val) = value.parse() {
self.min_pause_count = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_REQUIRE_MOUSE_ACTIVITY") {
if let Ok(val) = value.parse() {
self.require_mouse_activity = val;
}
}
}
}
@@ -122,7 +195,9 @@ impl std::fmt::Display for ConfigError {
Self::Parse { path, source } => {
write!(f, "failed to parse {} as TOML: {source}", path.display())
}
Self::InvalidBind { bind, source } => write!(f, "invalid bind address {bind}: {source}"),
Self::InvalidBind { bind, source } => {
write!(f, "invalid bind address {bind}: {source}")
}
}
}
}
@@ -130,6 +205,12 @@ impl std::fmt::Display for ConfigError {
impl std::error::Error for ConfigError {}
fn discover_config_path() -> Option<PathBuf> {
if let Ok(path) = env::var("CHRONOSEAL_CONFIG") {
let p = PathBuf::from(path);
if p.is_file() {
return Some(p);
}
}
user_config_candidates()
.into_iter()
.find(|candidate| candidate.is_file())
+2 -4
View File
@@ -6,9 +6,7 @@ pub fn verify_signature(
pub_key_bytes: &[u8],
req: &HeartbeatRequest,
) -> Result<(), Box<dyn std::error::Error>> {
let pk = VerifyingKey::from_bytes(
&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?,
)?;
let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
let sig_bytes = hex::decode(&req.signature)?;
let sig = Signature::from_slice(&sig_bytes)?;
@@ -26,4 +24,4 @@ pub fn verify_signature(
pk.verify_strict(message.as_bytes(), &sig)?;
Ok(())
}
}
+68
View File
@@ -0,0 +1,68 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SessionError {
#[error("Hex decoding error: {0}")]
Hex(#[from] hex::FromHexError),
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("R2D2 pool error: {0}")]
Pool(#[from] r2d2::Error),
#[error("Invalid public key length")]
InvalidPublicKeyLength,
}
impl IntoResponse for SessionError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
SessionError::InvalidPublicKeyLength => (StatusCode::BAD_REQUEST, self.to_string()),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
),
};
let body = Json(json!({
"error": error_message
}));
(status, body).into_response()
}
}
#[derive(Error, Debug)]
pub enum VerificationError {
#[error("Session not found")]
SessionNotFound,
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("Hex decoding error: {0}")]
Hex(#[from] hex::FromHexError),
#[error("Signature verification error: {0}")]
Signature(String),
#[error("Session has expired")]
Expired,
#[error("Chain is broken")]
ChainBroken,
#[error("Timestamp drift exceeded threshold")]
TimestampDrift,
#[error("Trust criteria failed: {0}")]
TrustFailed(String),
#[error("Fingerprint validation failed: {0}")]
FingerprintFailed(String),
}
+10 -4
View File
@@ -2,9 +2,15 @@ use shared::protocol::Fingerprint;
pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> {
let ar: f64 = fp.aspect_ratio.parse().map_err(|_| "ar")?;
if ar < 0.5 || ar > 3.0 { return Err("aspect ratio".into()); }
if !(0.5..=3.0).contains(&ar) {
return Err("aspect ratio".into());
}
let dpr: f64 = fp.device_pixel_ratio.parse().map_err(|_| "dpr")?;
if dpr <= 0.0 || dpr > 5.0 { return Err("dpr".into()); }
if fp.hardware_concurrency == 0 { return Err("hw".into()); }
if dpr <= 0.0 || dpr > 5.0 {
return Err("dpr".into());
}
if fp.hardware_concurrency == 0 {
return Err("hw".into());
}
Ok(())
}
}
+4
View File
@@ -2,6 +2,7 @@ mod cleanup;
mod cli;
mod config;
mod crypto;
mod errors;
mod fingerprint;
mod middleware;
mod output;
@@ -29,6 +30,9 @@ async fn main() {
async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if let Some(config_path) = cli.globals.config.as_deref() {
std::env::set_var("CHRONOSEAL_CONFIG", config_path);
}
let log_filter = cli.globals.log.as_deref().unwrap_or("info");
let log_file = log_file_for_command(&cli);
let _log_guard = init_logging(log_filter, log_file)?;
+1 -1
View File
@@ -8,4 +8,4 @@ pub async fn log_request(req: Request, next: Next) -> Response {
let response = next.run(req).await;
tracing::info!("{} {} -> {}", method, uri, response.status());
response
}
}
+45 -11
View File
@@ -3,22 +3,22 @@ use std::time::Instant;
pub struct RateLimiter {
buckets: HashMap<String, (u32, Instant)>,
limit: u32,
window_secs: u64,
}
impl RateLimiter {
pub fn new(limit: u32, window_secs: u64) -> Self {
Self { buckets: HashMap::new(), limit, window_secs }
pub fn new() -> Self {
Self {
buckets: HashMap::new(),
}
}
pub fn check(&mut self, key: &str) -> bool {
pub fn check(&mut self, key: &str, limit: u32, window_secs: u64) -> bool {
let now = Instant::now();
let entry = self.buckets.entry(key.to_string()).or_insert((0, now));
if now.duration_since(entry.1).as_secs() >= self.window_secs {
if now.duration_since(entry.1).as_secs() >= window_secs {
*entry = (1, now);
true
} else if entry.0 >= self.limit {
} else if entry.0 >= limit {
false
} else {
entry.0 += 1;
@@ -28,10 +28,44 @@ impl RateLimiter {
/// Remove entries whose rate-limit window has fully elapsed.
/// Call this periodically (e.g. from the cleanup loop) to bound memory usage.
pub fn evict_stale(&mut self) {
let window = self.window_secs;
pub fn evict_stale(&mut self, window_secs: u64) {
let now = Instant::now();
self.buckets
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window);
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window_secs);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_rate_limiter() {
let mut rl = RateLimiter::new();
// Limit of 2 requests per 1 second window
assert!(rl.check("user1", 2, 1));
assert!(rl.check("user1", 2, 1));
assert!(!rl.check("user1", 2, 1)); // 3rd fails
assert!(rl.check("user2", 2, 1)); // different key succeeds
thread::sleep(Duration::from_millis(1100));
assert!(rl.check("user1", 2, 1)); // succeeds after time window
}
#[test]
fn test_rate_limiter_eviction() {
let mut rl = RateLimiter::new();
assert!(rl.check("user1", 1, 1));
assert_eq!(rl.buckets.len(), 1);
rl.evict_stale(1);
assert_eq!(rl.buckets.len(), 1); // not stale yet
thread::sleep(Duration::from_millis(1100));
rl.evict_stale(1);
assert_eq!(rl.buckets.len(), 0); // evicted
}
}
+42 -10
View File
@@ -1,7 +1,7 @@
use axum::{extract::State, http::StatusCode, Json};
use std::sync::Arc;
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
use crate::session::AppState;
use axum::{extract::State, http::StatusCode, Json};
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
use std::sync::Arc;
pub async fn handler(
State(state): State<Arc<AppState>>,
@@ -9,22 +9,54 @@ pub async fn handler(
) -> (StatusCode, Json<HeartbeatResponse>) {
// Rate limiting
{
let (limit, window_secs) = {
let cfg = state.get_config();
(cfg.rate_limit_count, cfg.rate_limit_window_secs)
};
let mut rl = state.rate_limiter.lock().await;
if !rl.check(&payload.session_id) {
if !rl.check(&payload.session_id, limit, window_secs) {
tracing::debug!("Rate limit hit: {}", payload.session_id);
return (StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None }));
return (
StatusCode::OK,
Json(HeartbeatResponse {
status: "ok".into(),
next_salt: None,
}),
);
}
}
let db = state.db.lock().await;
match crate::session::verify_heartbeat(&db, &payload) {
let config = state.get_config();
let conn = match state.db_pool.get() {
Ok(c) => c,
Err(e) => {
tracing::error!("Db pool error: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(HeartbeatResponse {
status: "error".into(),
next_salt: None,
}),
);
}
};
match crate::session::verify_heartbeat(&conn, &config, &payload) {
Ok(next_salt) => (
StatusCode::OK,
Json(HeartbeatResponse { status: "ok".into(), next_salt: Some(next_salt) }),
Json(HeartbeatResponse {
status: "ok".into(),
next_salt: Some(next_salt),
}),
),
Err(e) => {
tracing::warn!("Heartbeat failed for {}: {}", payload.session_id, e);
(StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None }))
(
StatusCode::OK,
Json(HeartbeatResponse {
status: "ok".into(),
next_salt: None,
}),
)
}
}
}
}
+10 -12
View File
@@ -1,17 +1,15 @@
use axum::{extract::State, http::StatusCode, Json};
use std::sync::Arc;
use shared::protocol::{InitRequest, InitResponse};
use crate::errors::SessionError;
use crate::session::AppState;
use axum::{extract::State, Json};
use shared::protocol::{InitRequest, InitResponse};
use std::sync::Arc;
pub async fn handler(
State(state): State<Arc<AppState>>,
Json(payload): Json<InitRequest>,
) -> Result<Json<InitResponse>, (StatusCode, String)> {
let db = state.db.lock().await;
crate::session::create_session(&db, &payload.public_key)
.map(Json)
.map_err(|e| {
tracing::error!("Init error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal".into())
})
}
) -> Result<Json<InitResponse>, SessionError> {
let config = state.get_config();
let conn = state.db_pool.get()?;
let resp = crate::session::create_session(&conn, &config, &payload.public_key)?;
Ok(Json(resp))
}
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod init;
pub mod heartbeat;
pub mod init;
+40 -22
View File
@@ -41,7 +41,9 @@ pub struct StatusReport {
impl TextOutput for StatusReport {
fn to_text(&self) -> String {
let pid = self.pid.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
let pid = self
.pid
.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
format!(
"running={}\nhealthy={}\nbind={}\npid_file={}\npid={}",
self.running, self.healthy, self.bind, self.pid_file, pid
@@ -106,13 +108,11 @@ impl TextOutput for StoreStats {
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
install_pid_file(&config.pid_file)?;
let conn = storage::init_db(&config.db_path)?;
let db_pool = storage::init_pool(&config.db_path)?;
let state = Arc::new(session::AppState {
db: Mutex::new(conn),
rate_limiter: Mutex::new(RateLimiter::new(
shared::constants::RATE_LIMIT_COUNT,
shared::constants::RATE_LIMIT_WINDOW_SECS,
)),
db_pool,
rate_limiter: Mutex::new(RateLimiter::new()),
config: std::sync::RwLock::new(config.clone()),
});
let bg_state = state.clone();
@@ -124,16 +124,19 @@ pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>
.route("/health", get(health_handler))
.route("/metrics", get(metrics_handler))
.route("/stats", get(stats_handler))
.nest_service("/", tower_http::services::ServeDir::new(&config.frontend_dir))
.nest_service(
"/",
tower_http::services::ServeDir::new(&config.frontend_dir),
)
.layer(tower_http::cors::CorsLayer::permissive())
.layer(axum::middleware::from_fn(crate::middleware::log_request))
.with_state(state);
.with_state(state.clone());
let addr: SocketAddr = config.bind.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!(bind = %config.bind, "chronoseal daemon started");
let shutdown = signal_task(config.clone());
let shutdown = signal_task(state.clone());
let result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await;
@@ -199,13 +202,19 @@ pub fn version() -> VersionReport {
}
async fn health_handler() -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "status": "healthy" })))
(
StatusCode::OK,
Json(serde_json::json!({ "status": "healthy" })),
)
}
async fn stats_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<Json<StoreStats>, (StatusCode, String)> {
let db = state.db.lock().await;
let db = state
.db_pool
.get()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
storage::stats(&db)
.map(Json)
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
@@ -214,7 +223,10 @@ async fn stats_handler(
async fn metrics_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<String, (StatusCode, String)> {
let db = state.db.lock().await;
let db = state
.db_pool
.get()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
storage::stats(&db)
.map(|stats| {
format!(
@@ -225,7 +237,7 @@ async fn metrics_handler(
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
}
async fn signal_task(config: Config) {
async fn signal_task(state: Arc<session::AppState>) {
let shutdown = Arc::new(Notify::new());
#[cfg(unix)]
@@ -248,19 +260,23 @@ async fn signal_task(config: Config) {
}
});
let hup_config = config.clone();
let state_for_hup = state.clone();
tokio::spawn(async move {
let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler");
while sighup.recv().await.is_some() {
match Config::load(None) {
Ok(reloaded) => info!(
bind = %reloaded.bind,
db_path = %reloaded.db_path.display(),
"received SIGHUP; configuration reloaded"
),
Ok(reloaded) => {
info!(
bind = %reloaded.bind,
db_path = %reloaded.db_path.display(),
"received SIGHUP; configuration reloaded"
);
if let Ok(mut config_write) = state_for_hup.config.write() {
*config_write = reloaded;
}
}
Err(err) => warn!(error = %err, "received SIGHUP; configuration reload failed"),
}
let _ = &hup_config;
}
});
@@ -312,7 +328,9 @@ fn read_pid(path: &Path) -> Option<u32> {
fn http_get(bind: &str, path: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut stream = TcpStream::connect_timeout(&bind.parse()?, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes())?;
stream.write_all(
format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes(),
)?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
+142 -16
View File
@@ -1,24 +1,36 @@
pub struct AppState {
pub db: tokio::sync::Mutex<rusqlite::Connection>,
pub db_pool: crate::storage::DbPool,
pub rate_limiter: tokio::sync::Mutex<crate::ratelimit::RateLimiter>,
pub config: std::sync::RwLock<crate::config::Config>,
}
impl AppState {
pub fn get_config(&self) -> crate::config::Config {
if let Ok(cfg) = self.config.read() {
cfg.clone()
} else {
crate::config::Config::default()
}
}
}
use crate::{crypto, fingerprint, storage, trust, vm};
use rusqlite::params;
use shared::protocol::{HeartbeatRequest, InitResponse};
use crate::{crypto, trust, fingerprint, vm, storage};
pub fn create_session(
conn: &rusqlite::Connection,
config: &crate::config::Config,
pub_key_hex: &str,
) -> Result<InitResponse, Box<dyn std::error::Error>> {
) -> Result<InitResponse, crate::errors::SessionError> {
let pub_key = hex::decode(pub_key_hex)?;
if pub_key.len() != shared::constants::SESSION_ID_LEN {
return Err("invalid pubkey len".into());
return Err(crate::errors::SessionError::InvalidPublicKeyLength);
}
let session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>());
let salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
let now = storage::current_time_ms();
let expires_at = now + (shared::constants::EXPIRATION_MINUTES as u64) * 60 * 1000;
let expires_at = now + (config.expiration_minutes as u64) * 60 * 1000;
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
@@ -37,43 +49,56 @@ pub fn create_session(
opcodes_b64,
initial_hash: hex::encode(&initial_hash),
expires_at,
heartbeat_min_interval_ms: config.heartbeat_min_interval_ms,
heartbeat_max_interval_ms: config.heartbeat_max_interval_ms,
})
}
pub fn verify_heartbeat(
conn: &rusqlite::Connection,
config: &crate::config::Config,
req: &HeartbeatRequest,
) -> Result<String, Box<dyn std::error::Error>> {
) -> Result<String, crate::errors::VerificationError> {
let mut stmt = conn.prepare(
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1",
)?;
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) =
stmt.query_row(params![req.session_id], |row| {
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) = stmt
.query_row(params![req.session_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.map_err(|e| {
if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
crate::errors::VerificationError::SessionNotFound
} else {
crate::errors::VerificationError::Database(e)
}
})?;
let now = storage::current_time_ms();
if now > expires_at {
return Err("expired".into());
return Err(crate::errors::VerificationError::Expired);
}
// 1. Verify signature
crypto::verify_signature(&pub_key, req)?;
crypto::verify_signature(&pub_key, req)
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
// 2. Check chain continuity
if stored_last_hash != hex::decode(&req.prev_hash)? {
return Err("chain broken".into());
return Err(crate::errors::VerificationError::ChainBroken);
}
// 3. Time window
let diff = (now as i64) - (req.timestamp as i64);
if diff.abs() > shared::constants::MAX_TIMESTAMP_DRIFT_MS {
return Err("timestamp drift".into());
if diff.abs() > config.max_timestamp_drift_ms {
return Err(crate::errors::VerificationError::TimestampDrift);
}
// 4. Trusted mouse & fingerprint
trust::validate_mouse(&req.entropy_data)?;
fingerprint::validate(&req.fingerprint)?;
trust::validate_mouse(&req.entropy_data, config)
.map_err(|e| crate::errors::VerificationError::TrustFailed(e.to_string()))?;
fingerprint::validate(&req.fingerprint)
.map_err(|e| crate::errors::VerificationError::FingerprintFailed(e.to_string()))?;
// 5. Compute new hash
let prev_hash_bytes = hex::decode(&req.prev_hash)?;
@@ -95,4 +120,105 @@ pub fn verify_heartbeat(
)?;
Ok(next_salt_hex)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, StackState};
use std::path::Path;
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) {
let mut payload: std::collections::BTreeMap<&str, serde_json::Value> =
std::collections::BTreeMap::new();
payload.insert(
"entropyData",
serde_json::to_value(&req.entropy_data).unwrap(),
);
payload.insert(
"fingerprint",
serde_json::to_value(&req.fingerprint).unwrap(),
);
payload.insert("prevHash", serde_json::json!(req.prev_hash));
payload.insert("sessionId", serde_json::json!(req.session_id));
payload.insert(
"stackState",
serde_json::to_value(&req.stack_state).unwrap(),
);
payload.insert("timestamp", serde_json::json!(req.timestamp));
let message = serde_json::to_string(&payload).unwrap();
let sig = sk.sign(message.as_bytes());
req.signature = hex::encode(sig.to_bytes());
}
#[test]
fn test_session_lifecycle_and_verification() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = crate::config::Config {
expiration_minutes: 30,
max_timestamp_drift_ms: 30000,
min_mouse_total_dist: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: false, // simpler for tests
..crate::config::Config::default()
};
// Generate Ed25519 keypair
let mut rng = rand::thread_rng();
let sk = SigningKey::generate(&mut rng);
let pk = sk.verifying_key();
let pub_key_hex = hex::encode(pk.to_bytes());
// 1. Create Session
let start_time = storage::current_time_ms();
let init_resp = create_session(&conn, &config, &pub_key_hex).unwrap();
assert!(init_resp.expires_at >= start_time + 30 * 60 * 1000);
assert!(init_resp.expires_at <= storage::current_time_ms() + 30 * 60 * 1000);
// Verify stats
let stats = storage::stats(&conn).unwrap();
assert_eq!(stats.sessions, 1);
assert_eq!(stats.expired_sessions, 0);
// 2. Heartbeat Verification
let now = storage::current_time_ms();
let entropy_data = EntropyData { events: vec![] };
let stack_state = StackState {
stack: vec![42],
ip: 5,
};
let fingerprint = Fingerprint {
aspect_ratio: "1.77".to_string(),
device_pixel_ratio: "2.0".to_string(),
hardware_concurrency: 8,
};
let mut req = HeartbeatRequest {
session_id: init_resp.session_id.clone(),
prev_hash: init_resp.initial_hash.clone(),
timestamp: now,
entropy_data,
stack_state,
fingerprint,
signature: "".to_string(),
};
sign_request(&sk, &mut req);
// Verify successful heartbeat
let next_salt = verify_heartbeat(&conn, &config, &req).unwrap();
assert!(!next_salt.is_empty());
// Try duplicate/broken hash chain (prev_hash unchanged but expected next hash in DB)
let res = verify_heartbeat(&conn, &config, &req);
assert!(res.is_err());
assert!(matches!(
res.unwrap_err(),
crate::errors::VerificationError::ChainBroken
));
}
}
+22 -11
View File
@@ -10,17 +10,25 @@ pub struct StoreStats {
pub max_chain_length: u64,
}
pub fn init_db(path: &Path) -> Result<Connection, rusqlite::Error> {
if path == Path::new(":memory:") {
return init_schema(Connection::open_in_memory()?);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
init_schema(Connection::open(path)?)
pub type DbPool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>;
pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
let manager = if path == Path::new(":memory:") {
r2d2_sqlite::SqliteConnectionManager::memory()
} else {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
r2d2_sqlite::SqliteConnectionManager::file(path)
};
let pool = r2d2::Pool::new(manager)?;
let conn = pool.get()?;
init_schema(&conn)?;
Ok(pool)
}
fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
fn init_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
@@ -33,7 +41,7 @@ fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
expires_at INTEGER NOT NULL
);",
)?;
Ok(conn)
Ok(())
}
pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
@@ -57,5 +65,8 @@ pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
}
pub fn current_time_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
+189 -7
View File
@@ -1,7 +1,14 @@
use crate::config::Config;
use shared::protocol::EntropyData;
pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Error>> {
pub fn validate_mouse(
data: &EntropyData,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
let events = &data.events;
if !config.require_mouse_activity && events.is_empty() {
return Ok(());
}
if events.len() < 3 {
return Err("few events".into());
}
@@ -19,18 +26,193 @@ pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Erro
pauses += 1;
}
}
if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST {
if total_dist < config.min_mouse_total_dist {
return Err("insufficient distance".into());
}
// Speed in px/ms: total distance over elapsed wall-clock time of the event window.
let total_time_ms =
(events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
let total_time_ms = (events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
let avg_speed = total_dist / total_time_ms;
if avg_speed > shared::constants::MAX_MOUSE_AVG_SPEED {
if avg_speed > config.max_mouse_avg_speed {
return Err("speed too high".into());
}
if pauses < shared::constants::MIN_PAUSE_COUNT {
if pauses < config.min_pause_count {
return Err("no pause".into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use shared::protocol::MouseEvent;
fn get_default_config() -> Config {
Config {
min_mouse_total_dist: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: true,
..Config::default()
}
}
#[test]
fn test_validate_mouse_success() {
let config = get_default_config();
// Mouse moves from (0,0) to (5,0) then (15,0) with a pause
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
// Pause here (dist = 0, time diff = 100ms > 50ms)
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 15.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
assert!(validate_mouse(&data, &config).is_ok());
}
#[test]
fn test_validate_mouse_insufficient_events() {
let config = get_default_config();
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "few events");
}
#[test]
fn test_validate_mouse_insufficient_distance() {
let config = get_default_config();
// Total distance is only 5.0 < 10.0
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 2.0,
y: 0.0,
timestamp_ms: 200.0,
},
MouseEvent {
x: 2.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "insufficient distance");
}
#[test]
fn test_validate_mouse_too_fast() {
let config = get_default_config();
// Distance is 200.0, time difference is 70ms -> speed 2.85 > 2.0
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 100.0,
y: 0.0,
timestamp_ms: 105.0,
},
MouseEvent {
x: 100.0,
y: 0.0,
timestamp_ms: 165.0,
}, // pause
MouseEvent {
x: 200.0,
y: 0.0,
timestamp_ms: 170.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "speed too high");
}
#[test]
fn test_validate_mouse_no_pauses() {
let config = get_default_config();
// Constant movement without any pause
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
MouseEvent {
x: 10.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 15.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "no pause");
}
#[test]
fn test_validate_mouse_require_activity_toggle() {
let mut config = get_default_config();
config.require_mouse_activity = false;
let data = EntropyData { events: vec![] };
assert!(validate_mouse(&data, &config).is_ok());
config.require_mouse_activity = true;
assert!(validate_mouse(&data, &config).is_err());
}
}
+1 -1
View File
@@ -43,4 +43,4 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
ops
}
// Server does not need to execute the program; client does.
// Server does not need to execute the program; client does.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
[dependencies]
-9
View File
@@ -1,11 +1,2 @@
pub const SESSION_ID_LEN: usize = 32;
pub const SALT_LEN: usize = 16;
pub const HEARTBEAT_MIN_INTERVAL_MS: u64 = 12_000;
pub const HEARTBEAT_MAX_INTERVAL_MS: u64 = 25_000;
pub const EXPIRATION_MINUTES: i64 = 30;
pub const RATE_LIMIT_COUNT: u32 = 5;
pub const RATE_LIMIT_WINDOW_SECS: u64 = 10;
pub const MAX_TIMESTAMP_DRIFT_MS: i64 = 30_000;
pub const MIN_MOUSE_TOTAL_DIST: f64 = 10.0;
pub const MAX_MOUSE_AVG_SPEED: f64 = 2.0; // px/ms
pub const MIN_PAUSE_COUNT: u32 = 1;
+2 -2
View File
@@ -1,5 +1,5 @@
use blake3::Hasher;
use crate::protocol::{EntropyData, StackState};
use blake3::Hasher;
/// Initial hash for a brand-new session: Blake3(session_id || pub_key || salt)
pub fn initial_hash(session_id: &str, pub_key: &[u8], salt: &[u8]) -> Vec<u8> {
@@ -39,4 +39,4 @@ pub fn hash_stack(stack: &[u32]) -> u32 {
let data: Vec<u8> = stack.iter().flat_map(|x| x.to_le_bytes()).collect();
let hash = blake3::hash(&data);
u32::from_le_bytes(hash.as_bytes()[..4].try_into().unwrap())
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
pub mod constants;
pub mod hashing;
pub mod protocol;
pub mod protocol;
+3 -1
View File
@@ -12,6 +12,8 @@ pub struct InitResponse {
pub opcodes_b64: String,
pub initial_hash: String,
pub expires_at: u64,
pub heartbeat_min_interval_ms: u64,
pub heartbeat_max_interval_ms: u64,
}
#[derive(Deserialize, Serialize)]
@@ -59,4 +61,4 @@ pub struct MouseEvent {
pub struct StackState {
pub stack: Vec<u32>,
pub ip: u16,
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "chronoseal-wasm"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
[dependencies]
shared = { path = "../shared" }
+1 -1
View File
@@ -1,2 +1,2 @@
// Example: break debugger detection, console clearing, etc.
// Currently empty.
// Currently empty.
+4 -6
View File
@@ -3,7 +3,7 @@ use std::cell::RefCell;
use wasm_bindgen::prelude::*;
thread_local! {
static KEYPAIR: RefCell<Option<SigningKey>> = RefCell::new(None);
static KEYPAIR: RefCell<Option<SigningKey>> = const { RefCell::new(None) };
}
#[wasm_bindgen]
@@ -51,10 +51,8 @@ pub fn compute_next_hash(
) -> String {
let prev = hex::decode(prev_hash_hex).unwrap_or_default();
let salt = hex::decode(salt_hex).unwrap_or_default();
let entropy =
serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap();
let stack =
serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
let entropy = serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap();
let stack = serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt);
hex::encode(new)
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
// This module is handled on the JS side; WASM only receives the prepared entropy data.
// Could be used to add extra entropy sources (e.g., from JS via import).
// Could be used to add extra entropy sources (e.g., from JS via import).
+1 -1
View File
@@ -1 +1 @@
// Fingerprint collection is done in JS, this module is a placeholder.
// Fingerprint collection is done in JS, this module is a placeholder.
+1 -1
View File
@@ -3,4 +3,4 @@ pub mod crypto;
pub mod entropy;
pub mod fingerprint;
pub mod transport;
pub mod vm;
pub mod vm;
+1 -1
View File
@@ -1 +1 @@
// Could contain WebTransport related code if needed later.
// Could contain WebTransport related code if needed later.
+156 -8
View File
@@ -1,10 +1,12 @@
use wasm_bindgen::prelude::*;
use shared::protocol::StackState;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn run_program(program_b64: &str) -> JsValue {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(program_b64).unwrap();
let bytes = base64::engine::general_purpose::STANDARD
.decode(program_b64)
.unwrap();
let state = execute(&bytes);
serde_wasm_bindgen::to_value(&state).unwrap()
}
@@ -17,13 +19,22 @@ fn execute(program: &[u8]) -> StackState {
ip += 1;
match op {
0x00 => {
if ip + 4 > program.len() { break; }
let val = u32::from_le_bytes([program[ip], program[ip+1], program[ip+2], program[ip+3]]);
if ip + 4 > program.len() {
break;
}
let val = u32::from_le_bytes([
program[ip],
program[ip + 1],
program[ip + 2],
program[ip + 3],
]);
ip += 4;
stack.push(val);
}
0x01..=0x07 => {
if stack.len() < 2 { break; }
if stack.len() < 2 {
break;
}
let b = stack.pop().unwrap();
let a = stack.pop().unwrap();
let r = match op {
@@ -39,7 +50,9 @@ fn execute(program: &[u8]) -> StackState {
stack.push(r);
}
0x08 => {
if stack.is_empty() { break; }
if stack.is_empty() {
break;
}
let a = stack.pop().unwrap();
stack.push(!a);
}
@@ -51,5 +64,140 @@ fn execute(program: &[u8]) -> StackState {
_ => break,
}
}
StackState { stack, ip: ip as u16 }
}
StackState {
stack,
ip: ip as u16,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_push() {
// PUSH 42, PUSH 100
let program = vec![0x00, 42, 0, 0, 0, 0x00, 100, 0, 0, 0];
let state = execute(&program);
assert_eq!(state.stack, vec![42, 100]);
assert_eq!(state.ip, 10);
}
#[test]
fn test_add() {
// PUSH 5, PUSH 10, ADD
let program = vec![0x00, 5, 0, 0, 0, 0x00, 10, 0, 0, 0, 0x01];
let state = execute(&program);
assert_eq!(state.stack, vec![15]);
}
#[test]
fn test_add_wrapping() {
// PUSH u32::MAX, PUSH 1, ADD
let program = vec![0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 1, 0, 0, 0, 0x01];
let state = execute(&program);
assert_eq!(state.stack, vec![0]);
}
#[test]
fn test_sub() {
// PUSH 20, PUSH 7, SUB
let program = vec![0x00, 20, 0, 0, 0, 0x00, 7, 0, 0, 0, 0x02];
let state = execute(&program);
assert_eq!(state.stack, vec![13]);
}
#[test]
fn test_sub_wrapping() {
// PUSH 0, PUSH 1, SUB
let program = vec![0x00, 0, 0, 0, 0, 0x00, 1, 0, 0, 0, 0x02];
let state = execute(&program);
assert_eq!(state.stack, vec![u32::MAX]);
}
#[test]
fn test_mul() {
// PUSH 6, PUSH 7, MUL
let program = vec![0x00, 6, 0, 0, 0, 0x00, 7, 0, 0, 0, 0x03];
let state = execute(&program);
assert_eq!(state.stack, vec![42]);
}
#[test]
fn test_xor() {
// PUSH 0b1010, PUSH 0b1100, XOR
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x04];
let state = execute(&program);
assert_eq!(state.stack, vec![0b0110]);
}
#[test]
fn test_and() {
// PUSH 0b1010, PUSH 0b1100, AND
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x05];
let state = execute(&program);
assert_eq!(state.stack, vec![0b1000]);
}
#[test]
fn test_or() {
// PUSH 0b1010, PUSH 0b1100, OR
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x06];
let state = execute(&program);
assert_eq!(state.stack, vec![0b1110]);
}
#[test]
fn test_rot() {
// PUSH 1, PUSH 4, ROT
let program = vec![0x00, 1, 0, 0, 0, 0x00, 4, 0, 0, 0, 0x07];
let state = execute(&program);
assert_eq!(state.stack, vec![16]);
}
#[test]
fn test_not() {
// PUSH 0, NOT
let program = vec![0x00, 0, 0, 0, 0, 0x08];
let state = execute(&program);
assert_eq!(state.stack, vec![u32::MAX]);
}
#[test]
fn test_hash() {
// PUSH 10, PUSH 20, HASH
let program = vec![0x00, 10, 0, 0, 0, 0x00, 20, 0, 0, 0, 0x09];
let state = execute(&program);
assert_eq!(state.stack.len(), 1);
let expected_hash = shared::hashing::hash_stack(&[10, 20]);
assert_eq!(state.stack[0], expected_hash);
}
#[test]
fn test_underflow_binary() {
// PUSH 42, ADD (needs 2 values, only 1 on stack)
let program = vec![0x00, 42, 0, 0, 0, 0x01];
let state = execute(&program);
// ADD breaks when stack.len() < 2, stack has 42 left, ip is at the opcode ADD (6)
assert_eq!(state.stack, vec![42]);
assert_eq!(state.ip, 6);
}
#[test]
fn test_underflow_unary() {
// NOT (needs 1 value, empty stack)
let program = vec![0x08];
let state = execute(&program);
assert_eq!(state.stack, Vec::<u32>::new());
assert_eq!(state.ip, 1);
}
#[test]
fn test_incomplete_push() {
// PUSH opcode, but only 2 bytes instead of 4
let program = vec![0x00, 42, 0];
let state = execute(&program);
assert_eq!(state.stack, Vec::<u32>::new());
assert_eq!(state.ip, 1); // execution stopped at op 0x00 because ip + 4 > program.len()
}
}