From 9e78daeeba1abbda5f71d22769a35efc546b0a8e Mon Sep 17 00:00:00 2001 From: Sunil Thakares Date: Fri, 22 May 2026 18:47:30 +0530 Subject: [PATCH] Refactor ChronoSeal daemon for dynamic configurations, connection pooling, custom error handling, and complete tests (v0.5.0) --- Cargo.lock | 315 +++++++++++++++++++++++++++++++-- frontend/heartbeat.js | 8 +- server/Cargo.toml | 5 +- server/src/cleanup.rs | 28 ++- server/src/cli.rs | 28 ++- server/src/config.rs | 85 ++++++++- server/src/crypto.rs | 6 +- server/src/errors.rs | 68 +++++++ server/src/fingerprint.rs | 14 +- server/src/main.rs | 4 + server/src/middleware.rs | 2 +- server/src/ratelimit.rs | 56 ++++-- server/src/routes/heartbeat.rs | 61 +++++-- server/src/routes/init.rs | 28 +-- server/src/routes/mod.rs | 2 +- server/src/runtime.rs | 62 ++++--- server/src/session.rs | 148 ++++++++++++++-- server/src/storage.rs | 33 ++-- server/src/trust.rs | 196 +++++++++++++++++++- server/src/vm.rs | 2 +- shared/Cargo.toml | 2 +- shared/src/constants.rs | 9 - shared/src/hashing.rs | 4 +- shared/src/lib.rs | 2 +- shared/src/protocol.rs | 4 +- wasm/Cargo.toml | 4 +- wasm/src/anti_debug.rs | 2 +- wasm/src/crypto.rs | 10 +- wasm/src/entropy.rs | 2 +- wasm/src/fingerprint.rs | 2 +- wasm/src/lib.rs | 2 +- wasm/src/transport.rs | 2 +- wasm/src/vm.rs | 164 ++++++++++++++++- 33 files changed, 1192 insertions(+), 168 deletions(-) create mode 100644 server/src/errors.rs diff --git a/Cargo.lock b/Cargo.lock index d293261..b020f94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/frontend/heartbeat.js b/frontend/heartbeat.js index a8430d4..2566d23 100644 --- a/frontend/heartbeat.js +++ b/frontend/heartbeat.js @@ -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); } diff --git a/server/Cargo.toml b/server/Cargo.toml index c7adb37..5d73026 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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"] } diff --git a/server/src/cleanup.rs b/server/src/cleanup.rs index fd372d6..d43021d 100644 --- a/server/src/cleanup.rs +++ b/server/src/cleanup.rs @@ -1,5 +1,5 @@ -use std::sync::Arc; use crate::session::AppState; +use std::sync::Arc; pub async fn cleanup_loop(state: Arc) { loop { @@ -7,18 +7,28 @@ pub async fn cleanup_loop(state: Arc) { // 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 = { + if let Ok(config) = state.config.read() { + config.rate_limit_window_secs + } else { + 10 // fallback default + } + }; let mut rl = state.rate_limiter.lock().await; - rl.evict_stale(); + rl.evict_stale(window_secs); } } -} \ No newline at end of file +} diff --git a/server/src/cli.rs b/server/src/cli.rs index edb542d..bf4858c 100644 --- a/server/src/cli.rs +++ b/server/src/cli.rs @@ -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, } diff --git a/server/src/config.rs b/server/src/config.rs index 8a0f8ef..3ee5c11 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -14,6 +14,16 @@ pub struct Config { pub db_path: PathBuf, pub frontend_dir: PathBuf, pub log_file: Option, + 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 { 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 { + 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()) diff --git a/server/src/crypto.rs b/server/src/crypto.rs index 9d7c741..561c332 100644 --- a/server/src/crypto.rs +++ b/server/src/crypto.rs @@ -6,9 +6,7 @@ pub fn verify_signature( pub_key_bytes: &[u8], req: &HeartbeatRequest, ) -> Result<(), Box> { - 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(()) -} \ No newline at end of file +} diff --git a/server/src/errors.rs b/server/src/errors.rs new file mode 100644 index 0000000..f2cabda --- /dev/null +++ b/server/src/errors.rs @@ -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), +} diff --git a/server/src/fingerprint.rs b/server/src/fingerprint.rs index 19f27b8..08238a9 100644 --- a/server/src/fingerprint.rs +++ b/server/src/fingerprint.rs @@ -2,9 +2,15 @@ use shared::protocol::Fingerprint; pub fn validate(fp: &Fingerprint) -> Result<(), Box> { 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(()) -} \ No newline at end of file +} diff --git a/server/src/main.rs b/server/src/main.rs index 969afe8..9a09617 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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> { 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)?; diff --git a/server/src/middleware.rs b/server/src/middleware.rs index a39396b..b2b6971 100644 --- a/server/src/middleware.rs +++ b/server/src/middleware.rs @@ -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 -} \ No newline at end of file +} diff --git a/server/src/ratelimit.rs b/server/src/ratelimit.rs index eea9c3e..7f43aa9 100644 --- a/server/src/ratelimit.rs +++ b/server/src/ratelimit.rs @@ -3,22 +3,22 @@ use std::time::Instant; pub struct RateLimiter { buckets: HashMap, - 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); } -} \ No newline at end of file +} + +#[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 + } +} diff --git a/server/src/routes/heartbeat.rs b/server/src/routes/heartbeat.rs index 731a37e..933daee 100644 --- a/server/src/routes/heartbeat.rs +++ b/server/src/routes/heartbeat.rs @@ -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>, @@ -9,22 +9,63 @@ pub async fn handler( ) -> (StatusCode, Json) { // Rate limiting { + let (limit, window_secs) = { + if let Ok(cfg) = state.config.read() { + (cfg.rate_limit_count, cfg.rate_limit_window_secs) + } else { + (5, 10) + } + }; 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 = { + if let Ok(cfg) = state.config.read() { + cfg.clone() + } else { + crate::config::Config::default() + } + }; + 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, + }), + ) } } -} \ No newline at end of file +} diff --git a/server/src/routes/init.rs b/server/src/routes/init.rs index f931968..abfe818 100644 --- a/server/src/routes/init.rs +++ b/server/src/routes/init.rs @@ -1,17 +1,21 @@ -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>, Json(payload): Json, -) -> Result, (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()) - }) -} \ No newline at end of file +) -> Result, SessionError> { + let config = { + if let Ok(cfg) = state.config.read() { + cfg.clone() + } else { + crate::config::Config::default() + } + }; + let conn = state.db_pool.get()?; + let resp = crate::session::create_session(&conn, &config, &payload.public_key)?; + Ok(Json(resp)) +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index f2317ce..638a8db 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,2 +1,2 @@ -pub mod init; pub mod heartbeat; +pub mod init; diff --git a/server/src/runtime.rs b/server/src/runtime.rs index b45fdd9..c5c8b32 100644 --- a/server/src/runtime.rs +++ b/server/src/runtime.rs @@ -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> { 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 .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>, ) -> Result, (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>, ) -> Result { - 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) { 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 { fn http_get(bind: &str, path: &str) -> Result> { 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)?; diff --git a/server/src/session.rs b/server/src/session.rs index de92101..83ae657 100644 --- a/server/src/session.rs +++ b/server/src/session.rs @@ -1,24 +1,26 @@ pub struct AppState { - pub db: tokio::sync::Mutex, + pub db_pool: crate::storage::DbPool, pub rate_limiter: tokio::sync::Mutex, + pub config: std::sync::RwLock, } +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> { +) -> Result { 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 +39,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> { +) -> Result { 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, Vec, Vec, u64) = - stmt.query_row(params![req.session_id], |row| { + let (pub_key, salt, stored_last_hash, expires_at): (Vec, Vec, Vec, 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 +110,105 @@ pub fn verify_heartbeat( )?; Ok(next_salt_hex) -} \ No newline at end of file +} + +#[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 + )); + } +} diff --git a/server/src/storage.rs b/server/src/storage.rs index 748fe08..263898a 100644 --- a/server/src/storage.rs +++ b/server/src/storage.rs @@ -10,17 +10,25 @@ pub struct StoreStats { pub max_chain_length: u64, } -pub fn init_db(path: &Path) -> Result { - 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; + +pub fn init_pool(path: &Path) -> Result> { + 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 { +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 { expires_at INTEGER NOT NULL );", )?; - Ok(conn) + Ok(()) } pub fn stats(conn: &Connection) -> Result { @@ -57,5 +65,8 @@ pub fn stats(conn: &Connection) -> Result { } 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 } diff --git a/server/src/trust.rs b/server/src/trust.rs index 9c31504..a52b344 100644 --- a/server/src/trust.rs +++ b/server/src/trust.rs @@ -1,7 +1,14 @@ +use crate::config::Config; use shared::protocol::EntropyData; -pub fn validate_mouse(data: &EntropyData) -> Result<(), Box> { +pub fn validate_mouse( + data: &EntropyData, + config: &Config, +) -> Result<(), Box> { 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 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(()) -} \ No newline at end of file +} + +#[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()); + } +} diff --git a/server/src/vm.rs b/server/src/vm.rs index b586d87..0e62dd7 100644 --- a/server/src/vm.rs +++ b/server/src/vm.rs @@ -43,4 +43,4 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive) -> Ve ops } -// Server does not need to execute the program; client does. \ No newline at end of file +// Server does not need to execute the program; client does. diff --git a/shared/Cargo.toml b/shared/Cargo.toml index c9c15f0..30d188b 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "0.2.0" +version = "0.5.0" edition = "2021" [dependencies] diff --git a/shared/src/constants.rs b/shared/src/constants.rs index 9a14d3a..430214d 100644 --- a/shared/src/constants.rs +++ b/shared/src/constants.rs @@ -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; \ No newline at end of file diff --git a/shared/src/hashing.rs b/shared/src/hashing.rs index 7c4ae30..3d10d00 100644 --- a/shared/src/hashing.rs +++ b/shared/src/hashing.rs @@ -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 { @@ -39,4 +39,4 @@ pub fn hash_stack(stack: &[u32]) -> u32 { let data: Vec = 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()) -} \ No newline at end of file +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 7e45bb6..896f578 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,3 +1,3 @@ pub mod constants; pub mod hashing; -pub mod protocol; \ No newline at end of file +pub mod protocol; diff --git a/shared/src/protocol.rs b/shared/src/protocol.rs index 44a62fc..c14efe2 100644 --- a/shared/src/protocol.rs +++ b/shared/src/protocol.rs @@ -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, pub ip: u16, -} \ No newline at end of file +} diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 31a9a28..f1b51c8 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -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" } diff --git a/wasm/src/anti_debug.rs b/wasm/src/anti_debug.rs index edc1522..65d5740 100644 --- a/wasm/src/anti_debug.rs +++ b/wasm/src/anti_debug.rs @@ -1,2 +1,2 @@ // Example: break debugger detection, console clearing, etc. -// Currently empty. \ No newline at end of file +// Currently empty. diff --git a/wasm/src/crypto.rs b/wasm/src/crypto.rs index 6428fc5..83eec88 100644 --- a/wasm/src/crypto.rs +++ b/wasm/src/crypto.rs @@ -3,7 +3,7 @@ use std::cell::RefCell; use wasm_bindgen::prelude::*; thread_local! { - static KEYPAIR: RefCell> = RefCell::new(None); + static KEYPAIR: RefCell> = 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::(entropy_data_json).unwrap(); - let stack = - serde_json::from_str::(stack_state_json).unwrap(); + let entropy = serde_json::from_str::(entropy_data_json).unwrap(); + let stack = serde_json::from_str::(stack_state_json).unwrap(); let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt); hex::encode(new) -} \ No newline at end of file +} diff --git a/wasm/src/entropy.rs b/wasm/src/entropy.rs index 67915a1..bd5f506 100644 --- a/wasm/src/entropy.rs +++ b/wasm/src/entropy.rs @@ -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). \ No newline at end of file +// Could be used to add extra entropy sources (e.g., from JS via import). diff --git a/wasm/src/fingerprint.rs b/wasm/src/fingerprint.rs index f3274de..ed91b8b 100644 --- a/wasm/src/fingerprint.rs +++ b/wasm/src/fingerprint.rs @@ -1 +1 @@ -// Fingerprint collection is done in JS, this module is a placeholder. \ No newline at end of file +// Fingerprint collection is done in JS, this module is a placeholder. diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index f6b57e6..88bced6 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -3,4 +3,4 @@ pub mod crypto; pub mod entropy; pub mod fingerprint; pub mod transport; -pub mod vm; \ No newline at end of file +pub mod vm; diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index 6b16e53..f781008 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -1 +1 @@ -// Could contain WebTransport related code if needed later. \ No newline at end of file +// Could contain WebTransport related code if needed later. diff --git a/wasm/src/vm.rs b/wasm/src/vm.rs index f07b695..2dee8c0 100644 --- a/wasm/src/vm.rs +++ b/wasm/src/vm.rs @@ -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 } -} \ No newline at end of file + 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::::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::::new()); + assert_eq!(state.ip, 1); // execution stopped at op 0x00 because ip + 4 > program.len() + } +}