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", "windows-sys",
] ]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -226,9 +232,20 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 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]] [[package]]
name = "chronoseal-server" name = "chronoseal-server"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64", "base64",
@@ -236,12 +253,15 @@ dependencies = [
"clap_complete", "clap_complete",
"ed25519-dalek", "ed25519-dalek",
"hex", "hex",
"rand", "r2d2",
"r2d2_sqlite",
"rand 0.8.6",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"shared", "shared",
"thiserror",
"tokio", "tokio",
"toml", "toml",
"tower 0.4.13", "tower 0.4.13",
@@ -253,14 +273,14 @@ dependencies = [
[[package]] [[package]]
name = "chronoseal-wasm" name = "chronoseal-wasm"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"base64", "base64",
"blake3", "blake3",
"ed25519-dalek", "ed25519-dalek",
"getrandom", "getrandom 0.2.17",
"hex", "hex",
"rand", "rand 0.8.6",
"serde", "serde",
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
@@ -453,7 +473,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519", "ed25519",
"rand_core", "rand_core 0.6.4",
"serde", "serde",
"sha2", "sha2",
"subtle", "subtle",
@@ -500,6 +520,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -571,6 +597,20 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -580,6 +620,15 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -693,6 +742,12 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -701,6 +756,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.17.1", "hashbrown 0.17.1",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -733,6 +790,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@@ -912,6 +975,16 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -930,6 +1003,34 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "rand" name = "rand"
version = "0.8.6" version = "0.8.6"
@@ -938,7 +1039,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "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]] [[package]]
@@ -948,7 +1060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -957,9 +1069,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ 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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -1034,6 +1152,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -1167,13 +1294,13 @@ dependencies = [
[[package]] [[package]]
name = "shared" name = "shared"
version = "0.2.0" version = "0.5.0"
dependencies = [ dependencies = [
"base64", "base64",
"blake3", "blake3",
"ed25519-dalek", "ed25519-dalek",
"hex", "hex",
"rand", "rand 0.8.6",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -1200,7 +1327,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -1592,6 +1719,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"
@@ -1604,6 +1737,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 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]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.121" version = "0.2.121"
@@ -1673,6 +1836,40 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -1697,6 +1894,100 @@ dependencies = [
"memchr", "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]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.48" version = "0.8.48"
+444 -147
View File
@@ -1,130 +1,332 @@
# ChronoSeal # ChronoSeal
<p align="center"> <p align="center">
<img src="logo/chronoseal.svg" width="220" alt="ChronoSeal Logo"> <img src="logo/chronoseal.svg" width="220" alt="ChronoSeal Logo">
</p> </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 Browser Server
│ │ │ │
│ WASM loads, generates Ed25519 keypair │ │ WASM loads, generates Ed25519 keypair │
│ Private key never leaves WASM memory │ │ Private key never leaves WASM memory │
│ │ │ │
├──── POST /init { public_key } ──────────►│ Store session, salt, initial hash ├──── POST /init { public_key } ──────────►│
│◄─── { session_id, salt, opcodes, H0 } ────┤ │◄─── { session_id, salt, opcodes, H0 } ────┤
│ │ │ │
│ Every 12–25s (jittered): │ │ Every 12–25s (randomized): │
│ ┌─ Collect mouse entropy │ │ ┌─ Collect behavioral entropy │
│ ├─ Execute VM opcodes → stack state │ │ ├─ Execute VM opcode program │
│ ├─ Compute H(n) = Blake3(H(n-1) ║ …) │ │ ├─ Advance Blake3 hash chain │
│ └─ Sign payload with Ed25519 │ │ └─ Sign payload using Ed25519 │
│ │ │ │
├──── POST /hb { session_id, sig, … } ────►│ Verify sig → chain → behavior → fingerprint ├──── POST /heartbeat { signed_payload } ─►│
│◄─── { status, next_salt } ────────────────┤ Rotate salt, advance chain │◄─── { 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 | | Threat | Mechanism |
|---|---| | ------------------------ | ------------------------------------- |
| Playwright / Puppeteer Stealth | Mouse entropy validation rejects synthetic or absent movement | | Replay attacks | Blake3 chained heartbeat continuity |
| Replay attacks | Hash chain — each heartbeat references the previous hash; old payloads are invalid | | Signature forgery | Ed25519 keypair generated inside WASM |
| Signature forgery | Ed25519 private key generated inside WASM, never serialised or exposed to JS | | Session cloning | Ephemeral session-bound keypairs |
| Clock manipulation | Server enforces ±30s timestamp window | | Static scraping | Runtime participation requirements |
| Credential sharing | Session is bound to a keypair generated fresh on every page load | | Naive browser automation | Behavioral continuity validation |
| Flooding with fake sessions | Per-session rate limiting (5 req / 10s); stale entries evicted every 60s | | Timestamp replay | Drift-window enforcement |
| Passive analysis of traffic | Server always returns `{"status":"ok"}` — rejections are silent | | Session flooding | Per-session rate limiting |
### 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.
--- ---
## Architecture ## Silent Rejection Model
``` ChronoSeal intentionally avoids explicit rejection semantics.
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
```
### Stack Machine Invalid sessions may still receive:
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())`):
```json ```json
{ { "status": "ok" }
"entropyData": { "events": [ { "x": …, "y": …, "t": … } ] },
"fingerprint": { "aspectRatio": "…", "devicePixelRatio": "…", "hardwareConcurrency": … },
"prevHash": "hex…",
"sessionId": "hex…",
"stackState": { "stack": […], "ip": … },
"timestamp": 1234567890123
}
``` ```
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 ```sql
CREATE TABLE IF NOT EXISTS sessions ( 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) * Rust
- [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer/) * Axum
* Tokio
* SQLite
* `r2d2`
* `thiserror`
### WASM ## Browser Runtime
```bash * Rust → WASM
wasm-pack build wasm --target web --release * Ed25519 signing
mv wasm/pkg frontend/pkg * Blake3 chaining
``` * stack-machine execution
### 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`.
--- ---
## Deployment # Deployment
### Native + systemd ## Recommended Installation
```bash ```bash
cargo build -p server --release sudo bash scripts/install.sh
sudo cp target/release/server /usr/local/bin/chronoseal ```
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 cp chronoseal.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now chronoseal sudo systemctl enable --now chronoseal
``` ```
### Docker ---
## Docker
```bash ```bash
docker compose up -d --build 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 ```bash
<script type="module" src="/pkg/antibot_wasm.js"></script> bash scripts/build.sh
<script type="module" src="/main.js"></script>
``` ```
`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 | Install:
|---|---|---|
| `SESSION_ID_LEN` | 32 bytes | Session ID entropy | ```bash
| `SALT_LEN` | 16 bytes | Per-heartbeat salt size | cargo install wasm-pack
| `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 |
--- ---
## 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) [MIT OR Apache-2.0](LICENSE)
---
# Project
GitHub:
https://github.com/thakares/chronoseal-rs
+21 -52
View File
@@ -1,67 +1,36 @@
[Unit] [Unit]
Description=ChronoSeal cryptographic browser attestation service Description=ChronoSeal Cryptographic Attestation Daemon
Documentation=https://chronoseal.rs After=network.target
After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=chronoseal User=chronoseal
Group=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 ExecStart=/usr/local/bin/chronoseal run
ExecStartPre=+/usr/bin/touch /run/chronoseal.pid WorkingDirectory=/opt/chronoseal
ExecStartPre=+/usr/bin/chown chronoseal:chronoseal /run/chronoseal.pid Restart=always
ExecReload=/bin/kill -HUP $MAINPID
ExecStopPost=+/usr/bin/rm -f /run/chronoseal.pid
PIDFile=/run/chronoseal.pid
Restart=on-failure
RestartSec=3 RestartSec=3
TimeoutStopSec=30 Environment=RUST_LOG=info
KillSignal=SIGTERM
RuntimeDirectory=chronoseal # Hardening (production-grade)
RuntimeDirectoryMode=0750
StateDirectory=chronoseal
StateDirectoryMode=0750
LogsDirectory=chronoseal
LogsDirectoryMode=0750
ConfigurationDirectory=chronoseal
ConfigurationDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=read-only ProtectHome=yes
ProtectKernelTunables=true NoNewPrivileges=yes
ProtectKernelModules=true PrivateTmp=yes
ProtectControlGroups=true ProtectKernelTunables=yes
ProtectClock=true ProtectKernelModules=yes
ProtectHostname=true ProtectControlGroups=yes
ProtectProc=invisible MemoryDenyWriteExecute=yes
ProcSubset=pid RestrictRealtime=yes
PrivateDevices=true RestrictSUIDSGID=yes
PrivateIPC=true LockPersonality=yes
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
LockPersonality=true
SystemCallArchitectures=native SystemCallArchitectures=native
SystemCallFilter=@system-service ReadWritePaths=/run/chronoseal.pid
SystemCallErrorNumber=EPERM
CapabilityBoundingSet= # Logging
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 StandardOutput=journal
StandardError=journal
[Install] [Install]
WantedBy=multi-user.target 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 { collectEntropy } from './entropy.js';
import { sendRequest } from './transport.js'; import { sendRequest } from './transport.js';
let session, prevHash, currentSalt, opcodesB64, lastTime; let session, prevHash, currentSalt, opcodesB64, lastTime;
let minInterval = 12000;
let maxInterval = 25000;
export async function initHeartbeat() { export async function initHeartbeat() {
await init(); await init();
@@ -12,12 +14,14 @@ export async function initHeartbeat() {
prevHash = initResp.initial_hash; prevHash = initResp.initial_hash;
currentSalt = initResp.salt; currentSalt = initResp.salt;
opcodesB64 = initResp.opcodes_b64; opcodesB64 = initResp.opcodes_b64;
minInterval = initResp.heartbeat_min_interval_ms || 12000;
maxInterval = initResp.heartbeat_max_interval_ms || 25000;
lastTime = performance.now(); lastTime = performance.now();
scheduleNext(); scheduleNext();
} }
function scheduleNext() { function scheduleNext() {
const delay = 12000 + Math.random() * 13000; const delay = minInterval + Math.random() * (maxInterval - minInterval);
setTimeout(sendHeartbeat, delay); setTimeout(sendHeartbeat, delay);
} }
+38 -44
View File
@@ -1,52 +1,46 @@
#!/bin/sh #!/bin/bash
set -eu set -euo pipefail
CHRONOSEAL_VERSION="${CHRONOSEAL_VERSION:-latest}" echo "🚀 ChronoSeal Installer"
CHRONOSEAL_INSTALL_DIR="${CHRONOSEAL_INSTALL_DIR:-/usr/local/bin}" echo "======================"
CHRONOSEAL_BASE_URL="${CHRONOSEAL_BASE_URL:-https://get.chronoseal.rs/releases}"
need() { # Create system user
command -v "$1" >/dev/null 2>&1 || { if ! id -u chronoseal &>/dev/null; then
echo "chronoseal installer: missing required command: $1" >&2 sudo useradd --system --no-create-home --shell /usr/sbin/nologin chronoseal
exit 1 echo "✓ Created chronoseal system user"
}
}
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
fi fi
tmp="$(mktemp -d)" # Build
trap 'rm -rf "$tmp"' EXIT echo "→ Building ChronoSeal..."
cd "$(dirname "$0")/.."
bash scripts/build.sh
url="$CHRONOSEAL_BASE_URL/$CHRONOSEAL_VERSION/chronoseal-$target.tar.gz" # Install binary
echo "downloading chronoseal $CHRONOSEAL_VERSION for $target" sudo install -Dm755 target/release/chronoseal /usr/local/bin/chronoseal
echo "✓ Installed binary to /usr/local/bin/chronoseal"
# shellcheck disable=SC2086 # Install frontend assets
$fetch "$url" | tar -xz -C "$tmp" sudo mkdir -p /opt/chronoseal
chmod 0755 "$tmp/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 systemd service
install -m 0755 "$tmp/chronoseal" "$CHRONOSEAL_INSTALL_DIR/chronoseal" sudo cp chronoseal.service /etc/systemd/system/chronoseal.service
else sudo systemctl daemon-reload
sudo install -m 0755 "$tmp/chronoseal" "$CHRONOSEAL_INSTALL_DIR/chronoseal" echo "✓ Installed systemd service"
fi
echo "installed: $CHRONOSEAL_INSTALL_DIR/chronoseal" # Enable and start
echo "try: chronoseal --help" 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] [package]
name = "chronoseal-server" name = "chronoseal-server"
version = "0.2.0" version = "0.5.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@@ -18,6 +18,9 @@ serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
toml = "0.8" toml = "0.8"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.24"
thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-appender = "0.2" tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } 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 crate::session::AppState;
use std::sync::Arc;
pub async fn cleanup_loop(state: Arc<AppState>) { pub async fn cleanup_loop(state: Arc<AppState>) {
loop { loop {
@@ -7,18 +7,22 @@ pub async fn cleanup_loop(state: Arc<AppState>) {
// Evict expired sessions from SQLite. // Evict expired sessions from SQLite.
{ {
let db = state.db.lock().await; if let Ok(conn) = state.db_pool.get() {
let now = crate::storage::current_time_ms(); let now = crate::storage::current_time_ms();
let _ = db.execute( let _ = conn.execute(
"DELETE FROM sessions WHERE expires_at < ?1", "DELETE FROM sessions WHERE expires_at < ?1",
rusqlite::params![now], 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. // 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; 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)] #[derive(Debug, Subcommand)]
pub enum Command { pub enum Command {
/// Run the ChronoSeal daemon. /// 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), Run(RunArgs),
/// Report whether the configured daemon is reachable and which PID file is present. /// 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), Status(RuntimeArgs),
/// Perform a daemon health probe. /// 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), Health(RuntimeArgs),
/// Validate and print effective configuration. /// Validate and print effective configuration.
@@ -76,7 +82,9 @@ pub enum Command {
Version, Version,
/// Print Prometheus metrics from the running daemon. /// 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), Metrics(RuntimeArgs),
/// Print service statistics from the running daemon. /// Print service statistics from the running daemon.
@@ -84,7 +92,9 @@ pub enum Command {
Stats(RuntimeArgs), Stats(RuntimeArgs),
/// Generate shell completions. /// 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 }, Completion { shell: clap_complete::Shell },
} }
@@ -120,13 +130,17 @@ pub struct RuntimeArgs {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum ConfigCommand { pub enum ConfigCommand {
/// Validate configuration and print the effective values. /// 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), Check(RuntimeArgs),
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum GenerateCommand { pub enum GenerateCommand {
/// Generate an Ed25519 keypair as hex-encoded JSON/YAML/text. /// 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, Keypair,
} }
+83 -2
View File
@@ -14,6 +14,16 @@ pub struct Config {
pub db_path: PathBuf, pub db_path: PathBuf,
pub frontend_dir: PathBuf, pub frontend_dir: PathBuf,
pub log_file: Option<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 { impl Default for Config {
@@ -24,6 +34,16 @@ impl Default for Config {
db_path: default_state_dir().join("chronoseal.sqlite"), db_path: default_state_dir().join("chronoseal.sqlite"),
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"), frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
log_file: None, 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> { pub fn load(config_path: Option<&Path>) -> Result<Self, ConfigError> {
let mut config = Self::default(); 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 { let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(), path: path.clone(),
source, source,
@@ -96,6 +119,56 @@ impl Config {
if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") { if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") {
self.log_file = Some(PathBuf::from(value)); 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 } => { Self::Parse { path, source } => {
write!(f, "failed to parse {} as TOML: {source}", path.display()) 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 {} impl std::error::Error for ConfigError {}
fn discover_config_path() -> Option<PathBuf> { 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() user_config_candidates()
.into_iter() .into_iter()
.find(|candidate| candidate.is_file()) .find(|candidate| candidate.is_file())
+2 -4
View File
@@ -6,9 +6,7 @@ pub fn verify_signature(
pub_key_bytes: &[u8], pub_key_bytes: &[u8],
req: &HeartbeatRequest, req: &HeartbeatRequest,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let pk = VerifyingKey::from_bytes( let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?,
)?;
let sig_bytes = hex::decode(&req.signature)?; let sig_bytes = hex::decode(&req.signature)?;
let sig = Signature::from_slice(&sig_bytes)?; let sig = Signature::from_slice(&sig_bytes)?;
@@ -26,4 +24,4 @@ pub fn verify_signature(
pk.verify_strict(message.as_bytes(), &sig)?; pk.verify_strict(message.as_bytes(), &sig)?;
Ok(()) 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>> { pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> {
let ar: f64 = fp.aspect_ratio.parse().map_err(|_| "ar")?; 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")?; let dpr: f64 = fp.device_pixel_ratio.parse().map_err(|_| "dpr")?;
if dpr <= 0.0 || dpr > 5.0 { return Err("dpr".into()); } if dpr <= 0.0 || dpr > 5.0 {
if fp.hardware_concurrency == 0 { return Err("hw".into()); } return Err("dpr".into());
}
if fp.hardware_concurrency == 0 {
return Err("hw".into());
}
Ok(()) Ok(())
} }
+4
View File
@@ -2,6 +2,7 @@ mod cleanup;
mod cli; mod cli;
mod config; mod config;
mod crypto; mod crypto;
mod errors;
mod fingerprint; mod fingerprint;
mod middleware; mod middleware;
mod output; mod output;
@@ -29,6 +30,9 @@ async fn main() {
async fn try_main() -> Result<(), Box<dyn std::error::Error>> { async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse(); 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_filter = cli.globals.log.as_deref().unwrap_or("info");
let log_file = log_file_for_command(&cli); let log_file = log_file_for_command(&cli);
let _log_guard = init_logging(log_filter, log_file)?; 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; let response = next.run(req).await;
tracing::info!("{} {} -> {}", method, uri, response.status()); tracing::info!("{} {} -> {}", method, uri, response.status());
response response
} }
+45 -11
View File
@@ -3,22 +3,22 @@ use std::time::Instant;
pub struct RateLimiter { pub struct RateLimiter {
buckets: HashMap<String, (u32, Instant)>, buckets: HashMap<String, (u32, Instant)>,
limit: u32,
window_secs: u64,
} }
impl RateLimiter { impl RateLimiter {
pub fn new(limit: u32, window_secs: u64) -> Self { pub fn new() -> Self {
Self { buckets: HashMap::new(), limit, window_secs } 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 now = Instant::now();
let entry = self.buckets.entry(key.to_string()).or_insert((0, 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); *entry = (1, now);
true true
} else if entry.0 >= self.limit { } else if entry.0 >= limit {
false false
} else { } else {
entry.0 += 1; entry.0 += 1;
@@ -28,10 +28,44 @@ impl RateLimiter {
/// Remove entries whose rate-limit window has fully elapsed. /// Remove entries whose rate-limit window has fully elapsed.
/// Call this periodically (e.g. from the cleanup loop) to bound memory usage. /// Call this periodically (e.g. from the cleanup loop) to bound memory usage.
pub fn evict_stale(&mut self) { pub fn evict_stale(&mut self, window_secs: u64) {
let window = self.window_secs;
let now = Instant::now(); let now = Instant::now();
self.buckets 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 crate::session::AppState;
use axum::{extract::State, http::StatusCode, Json};
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
use std::sync::Arc;
pub async fn handler( pub async fn handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -9,22 +9,54 @@ pub async fn handler(
) -> (StatusCode, Json<HeartbeatResponse>) { ) -> (StatusCode, Json<HeartbeatResponse>) {
// Rate limiting // 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; 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); 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; let config = state.get_config();
match crate::session::verify_heartbeat(&db, &payload) { 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) => ( Ok(next_salt) => (
StatusCode::OK, StatusCode::OK,
Json(HeartbeatResponse { status: "ok".into(), next_salt: Some(next_salt) }), Json(HeartbeatResponse {
status: "ok".into(),
next_salt: Some(next_salt),
}),
), ),
Err(e) => { Err(e) => {
tracing::warn!("Heartbeat failed for {}: {}", payload.session_id, 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 crate::errors::SessionError;
use std::sync::Arc;
use shared::protocol::{InitRequest, InitResponse};
use crate::session::AppState; use crate::session::AppState;
use axum::{extract::State, Json};
use shared::protocol::{InitRequest, InitResponse};
use std::sync::Arc;
pub async fn handler( pub async fn handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<InitRequest>, Json(payload): Json<InitRequest>,
) -> Result<Json<InitResponse>, (StatusCode, String)> { ) -> Result<Json<InitResponse>, SessionError> {
let db = state.db.lock().await; let config = state.get_config();
crate::session::create_session(&db, &payload.public_key) let conn = state.db_pool.get()?;
.map(Json) let resp = crate::session::create_session(&conn, &config, &payload.public_key)?;
.map_err(|e| { Ok(Json(resp))
tracing::error!("Init error: {}", e); }
(StatusCode::INTERNAL_SERVER_ERROR, "Internal".into())
})
}
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod init;
pub mod heartbeat; pub mod heartbeat;
pub mod init;
+40 -22
View File
@@ -41,7 +41,9 @@ pub struct StatusReport {
impl TextOutput for StatusReport { impl TextOutput for StatusReport {
fn to_text(&self) -> String { 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!( format!(
"running={}\nhealthy={}\nbind={}\npid_file={}\npid={}", "running={}\nhealthy={}\nbind={}\npid_file={}\npid={}",
self.running, self.healthy, self.bind, self.pid_file, pid 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>> { pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
install_pid_file(&config.pid_file)?; 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 { let state = Arc::new(session::AppState {
db: Mutex::new(conn), db_pool,
rate_limiter: Mutex::new(RateLimiter::new( rate_limiter: Mutex::new(RateLimiter::new()),
shared::constants::RATE_LIMIT_COUNT, config: std::sync::RwLock::new(config.clone()),
shared::constants::RATE_LIMIT_WINDOW_SECS,
)),
}); });
let bg_state = state.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("/health", get(health_handler))
.route("/metrics", get(metrics_handler)) .route("/metrics", get(metrics_handler))
.route("/stats", get(stats_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(tower_http::cors::CorsLayer::permissive())
.layer(axum::middleware::from_fn(crate::middleware::log_request)) .layer(axum::middleware::from_fn(crate::middleware::log_request))
.with_state(state); .with_state(state.clone());
let addr: SocketAddr = config.bind.parse()?; let addr: SocketAddr = config.bind.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
info!(bind = %config.bind, "chronoseal daemon started"); 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) let result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown) .with_graceful_shutdown(shutdown)
.await; .await;
@@ -199,13 +202,19 @@ pub fn version() -> VersionReport {
} }
async fn health_handler() -> impl IntoResponse { 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( async fn stats_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>, axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<Json<StoreStats>, (StatusCode, String)> { ) -> 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) storage::stats(&db)
.map(Json) .map(Json)
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
@@ -214,7 +223,10 @@ async fn stats_handler(
async fn metrics_handler( async fn metrics_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>, axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<String, (StatusCode, String)> { ) -> 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) storage::stats(&db)
.map(|stats| { .map(|stats| {
format!( format!(
@@ -225,7 +237,7 @@ async fn metrics_handler(
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) .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()); let shutdown = Arc::new(Notify::new());
#[cfg(unix)] #[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 { tokio::spawn(async move {
let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler"); let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler");
while sighup.recv().await.is_some() { while sighup.recv().await.is_some() {
match Config::load(None) { match Config::load(None) {
Ok(reloaded) => info!( Ok(reloaded) => {
bind = %reloaded.bind, info!(
db_path = %reloaded.db_path.display(), bind = %reloaded.bind,
"received SIGHUP; configuration reloaded" 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"), 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>> { 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))?; let mut stream = TcpStream::connect_timeout(&bind.parse()?, Duration::from_secs(2))?;
stream.set_read_timeout(Some(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(); let mut response = String::new();
stream.read_to_string(&mut response)?; stream.read_to_string(&mut response)?;
+142 -16
View File
@@ -1,24 +1,36 @@
pub struct AppState { 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 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 rusqlite::params;
use shared::protocol::{HeartbeatRequest, InitResponse}; use shared::protocol::{HeartbeatRequest, InitResponse};
use crate::{crypto, trust, fingerprint, vm, storage};
pub fn create_session( pub fn create_session(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
config: &crate::config::Config,
pub_key_hex: &str, pub_key_hex: &str,
) -> Result<InitResponse, Box<dyn std::error::Error>> { ) -> Result<InitResponse, crate::errors::SessionError> {
let pub_key = hex::decode(pub_key_hex)?; let pub_key = hex::decode(pub_key_hex)?;
if pub_key.len() != shared::constants::SESSION_ID_LEN { 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 session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>());
let salt = rand::random::<[u8; shared::constants::SALT_LEN]>(); let salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
let now = storage::current_time_ms(); 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); let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
@@ -37,43 +49,56 @@ pub fn create_session(
opcodes_b64, opcodes_b64,
initial_hash: hex::encode(&initial_hash), initial_hash: hex::encode(&initial_hash),
expires_at, expires_at,
heartbeat_min_interval_ms: config.heartbeat_min_interval_ms,
heartbeat_max_interval_ms: config.heartbeat_max_interval_ms,
}) })
} }
pub fn verify_heartbeat( pub fn verify_heartbeat(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
config: &crate::config::Config,
req: &HeartbeatRequest, req: &HeartbeatRequest,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, crate::errors::VerificationError> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1", "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) = let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) = stmt
stmt.query_row(params![req.session_id], |row| { .query_row(params![req.session_id], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) 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(); let now = storage::current_time_ms();
if now > expires_at { if now > expires_at {
return Err("expired".into()); return Err(crate::errors::VerificationError::Expired);
} }
// 1. Verify signature // 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 // 2. Check chain continuity
if stored_last_hash != hex::decode(&req.prev_hash)? { if stored_last_hash != hex::decode(&req.prev_hash)? {
return Err("chain broken".into()); return Err(crate::errors::VerificationError::ChainBroken);
} }
// 3. Time window // 3. Time window
let diff = (now as i64) - (req.timestamp as i64); let diff = (now as i64) - (req.timestamp as i64);
if diff.abs() > shared::constants::MAX_TIMESTAMP_DRIFT_MS { if diff.abs() > config.max_timestamp_drift_ms {
return Err("timestamp drift".into()); return Err(crate::errors::VerificationError::TimestampDrift);
} }
// 4. Trusted mouse & fingerprint // 4. Trusted mouse & fingerprint
trust::validate_mouse(&req.entropy_data)?; trust::validate_mouse(&req.entropy_data, config)
fingerprint::validate(&req.fingerprint)?; .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 // 5. Compute new hash
let prev_hash_bytes = hex::decode(&req.prev_hash)?; let prev_hash_bytes = hex::decode(&req.prev_hash)?;
@@ -95,4 +120,105 @@ pub fn verify_heartbeat(
)?; )?;
Ok(next_salt_hex) 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 max_chain_length: u64,
} }
pub fn init_db(path: &Path) -> Result<Connection, rusqlite::Error> { pub type DbPool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>;
if path == Path::new(":memory:") {
return init_schema(Connection::open_in_memory()?); pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
} let manager = if path == Path::new(":memory:") {
if let Some(parent) = path.parent() { r2d2_sqlite::SqliteConnectionManager::memory()
let _ = std::fs::create_dir_all(parent); } else {
} if let Some(parent) = path.parent() {
init_schema(Connection::open(path)?) 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( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions ( "CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
@@ -33,7 +41,7 @@ fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL
);", );",
)?; )?;
Ok(conn) Ok(())
} }
pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> { 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 { 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; 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; let events = &data.events;
if !config.require_mouse_activity && events.is_empty() {
return Ok(());
}
if events.len() < 3 { if events.len() < 3 {
return Err("few events".into()); return Err("few events".into());
} }
@@ -19,18 +26,193 @@ pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Erro
pauses += 1; pauses += 1;
} }
} }
if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST { if total_dist < config.min_mouse_total_dist {
return Err("insufficient distance".into()); return Err("insufficient distance".into());
} }
// Speed in px/ms: total distance over elapsed wall-clock time of the event window. // Speed in px/ms: total distance over elapsed wall-clock time of the event window.
let total_time_ms = let total_time_ms = (events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
(events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
let avg_speed = total_dist / total_time_ms; 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()); return Err("speed too high".into());
} }
if pauses < shared::constants::MIN_PAUSE_COUNT { if pauses < config.min_pause_count {
return Err("no pause".into()); return Err("no pause".into());
} }
Ok(()) 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 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] [package]
name = "shared" name = "shared"
version = "0.2.0" version = "0.5.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
-9
View File
@@ -1,11 +1,2 @@
pub const SESSION_ID_LEN: usize = 32; pub const SESSION_ID_LEN: usize = 32;
pub const SALT_LEN: usize = 16; 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 crate::protocol::{EntropyData, StackState};
use blake3::Hasher;
/// Initial hash for a brand-new session: Blake3(session_id || pub_key || salt) /// 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> { 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 data: Vec<u8> = stack.iter().flat_map(|x| x.to_le_bytes()).collect();
let hash = blake3::hash(&data); let hash = blake3::hash(&data);
u32::from_le_bytes(hash.as_bytes()[..4].try_into().unwrap()) u32::from_le_bytes(hash.as_bytes()[..4].try_into().unwrap())
} }
+1 -1
View File
@@ -1,3 +1,3 @@
pub mod constants; pub mod constants;
pub mod hashing; 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 opcodes_b64: String,
pub initial_hash: String, pub initial_hash: String,
pub expires_at: u64, pub expires_at: u64,
pub heartbeat_min_interval_ms: u64,
pub heartbeat_max_interval_ms: u64,
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@@ -59,4 +61,4 @@ pub struct MouseEvent {
pub struct StackState { pub struct StackState {
pub stack: Vec<u32>, pub stack: Vec<u32>,
pub ip: u16, pub ip: u16,
} }
+2 -2
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "chronoseal-wasm" name = "chronoseal-wasm"
version = "0.2.0" version = "0.5.0"
edition = "2021" edition = "2021"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
shared = { path = "../shared" } shared = { path = "../shared" }
+1 -1
View File
@@ -1,2 +1,2 @@
// Example: break debugger detection, console clearing, etc. // 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::*; use wasm_bindgen::prelude::*;
thread_local! { thread_local! {
static KEYPAIR: RefCell<Option<SigningKey>> = RefCell::new(None); static KEYPAIR: RefCell<Option<SigningKey>> = const { RefCell::new(None) };
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -51,10 +51,8 @@ pub fn compute_next_hash(
) -> String { ) -> String {
let prev = hex::decode(prev_hash_hex).unwrap_or_default(); let prev = hex::decode(prev_hash_hex).unwrap_or_default();
let salt = hex::decode(salt_hex).unwrap_or_default(); let salt = hex::decode(salt_hex).unwrap_or_default();
let entropy = let entropy = serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap();
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 stack =
serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt); let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt);
hex::encode(new) 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. // 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 entropy;
pub mod fingerprint; pub mod fingerprint;
pub mod transport; 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 shared::protocol::StackState;
use wasm_bindgen::prelude::*;
#[wasm_bindgen] #[wasm_bindgen]
pub fn run_program(program_b64: &str) -> JsValue { pub fn run_program(program_b64: &str) -> JsValue {
use base64::Engine; 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); let state = execute(&bytes);
serde_wasm_bindgen::to_value(&state).unwrap() serde_wasm_bindgen::to_value(&state).unwrap()
} }
@@ -17,13 +19,22 @@ fn execute(program: &[u8]) -> StackState {
ip += 1; ip += 1;
match op { match op {
0x00 => { 0x00 => {
if ip + 4 > program.len() { break; } if ip + 4 > program.len() {
let val = u32::from_le_bytes([program[ip], program[ip+1], program[ip+2], program[ip+3]]); break;
}
let val = u32::from_le_bytes([
program[ip],
program[ip + 1],
program[ip + 2],
program[ip + 3],
]);
ip += 4; ip += 4;
stack.push(val); stack.push(val);
} }
0x01..=0x07 => { 0x01..=0x07 => {
if stack.len() < 2 { break; } if stack.len() < 2 {
break;
}
let b = stack.pop().unwrap(); let b = stack.pop().unwrap();
let a = stack.pop().unwrap(); let a = stack.pop().unwrap();
let r = match op { let r = match op {
@@ -39,7 +50,9 @@ fn execute(program: &[u8]) -> StackState {
stack.push(r); stack.push(r);
} }
0x08 => { 0x08 => {
if stack.is_empty() { break; } if stack.is_empty() {
break;
}
let a = stack.pop().unwrap(); let a = stack.pop().unwrap();
stack.push(!a); stack.push(!a);
} }
@@ -51,5 +64,140 @@ fn execute(program: &[u8]) -> StackState {
_ => break, _ => 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()
}
}