Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
217dc5f92b | ||
|
|
0b43530651 | ||
|
|
e0f6d7c72c | ||
|
|
9e78daeeba |
No files matched your search
Generated
+303
-12
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,2 +1,2 @@
|
||||
pub mod init;
|
||||
pub mod heartbeat;
|
||||
pub mod init;
|
||||
+40
-22
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -1,3 +1,3 @@
|
||||
pub mod constants;
|
||||
pub mod hashing;
|
||||
pub mod protocol;
|
||||
pub mod protocol;
|
||||
@@ -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
@@ -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,2 +1,2 @@
|
||||
// Example: break debugger detection, console clearing, etc.
|
||||
// Currently empty.
|
||||
// Currently empty.
|
||||
+4
-6
@@ -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
@@ -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 @@
|
||||
// Fingerprint collection is done in JS, this module is a placeholder.
|
||||
// Fingerprint collection is done in JS, this module is a placeholder.
|
||||
+1
-1
@@ -3,4 +3,4 @@ pub mod crypto;
|
||||
pub mod entropy;
|
||||
pub mod fingerprint;
|
||||
pub mod transport;
|
||||
pub mod vm;
|
||||
pub mod vm;
|
||||
@@ -1 +1 @@
|
||||
// Could contain WebTransport related code if needed later.
|
||||
// Could contain WebTransport related code if needed later.
|
||||
+156
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user