1 Commits
25 changed files with 1172 additions and 148 deletions

No files matched your search

Generated
+303 -12
View File
@@ -73,6 +73,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -226,9 +232,20 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chronoseal-server"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"axum",
"base64",
@@ -236,12 +253,15 @@ dependencies = [
"clap_complete",
"ed25519-dalek",
"hex",
"rand",
"r2d2",
"r2d2_sqlite",
"rand 0.8.6",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"shared",
"thiserror",
"tokio",
"toml",
"tower 0.4.13",
@@ -253,14 +273,14 @@ dependencies = [
[[package]]
name = "chronoseal-wasm"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"base64",
"blake3",
"ed25519-dalek",
"getrandom",
"getrandom 0.2.17",
"hex",
"rand",
"rand 0.8.6",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -453,7 +473,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
@@ -500,6 +520,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -571,6 +597,20 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -580,6 +620,15 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -693,6 +742,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -701,6 +756,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
@@ -733,6 +790,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
@@ -912,6 +975,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -930,6 +1003,34 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "r2d2"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "r2d2_sqlite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a982edf65c129796dba72f8775b292ef482b40d035e827a9825b3bc07ccc5f2"
dependencies = [
"r2d2",
"rusqlite",
"uuid",
]
[[package]]
name = "rand"
version = "0.8.6"
@@ -938,7 +1039,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
@@ -948,7 +1060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -957,9 +1069,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1034,6 +1152,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
dependencies = [
"parking_lot",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1167,13 +1294,13 @@ dependencies = [
[[package]]
name = "shared"
version = "0.2.0"
version = "0.5.0"
dependencies = [
"base64",
"blake3",
"ed25519-dalek",
"hex",
"rand",
"rand 0.8.6",
"serde",
"serde_json",
]
@@ -1200,7 +1327,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -1592,6 +1719,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -1604,6 +1737,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"rand 0.10.1",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -1628,6 +1773,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
@@ -1673,6 +1836,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1697,6 +1894,100 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
+6 -2
View File
@@ -1,8 +1,10 @@
import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/antibot_wasm.js';
import init, { generate_keypair, sign_message, compute_next_hash, run_program } from './pkg/chronoseal_wasm.js';
import { collectEntropy } from './entropy.js';
import { sendRequest } from './transport.js';
let session, prevHash, currentSalt, opcodesB64, lastTime;
let minInterval = 12000;
let maxInterval = 25000;
export async function initHeartbeat() {
await init();
@@ -12,12 +14,14 @@ export async function initHeartbeat() {
prevHash = initResp.initial_hash;
currentSalt = initResp.salt;
opcodesB64 = initResp.opcodes_b64;
minInterval = initResp.heartbeat_min_interval_ms || 12000;
maxInterval = initResp.heartbeat_max_interval_ms || 25000;
lastTime = performance.now();
scheduleNext();
}
function scheduleNext() {
const delay = 12000 + Math.random() * 13000;
const delay = minInterval + Math.random() * (maxInterval - minInterval);
setTimeout(sendHeartbeat, delay);
}
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "chronoseal-server"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
[[bin]]
@@ -18,6 +18,9 @@ serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
rusqlite = { version = "0.31", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.24"
thiserror = "2"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
+18 -8
View File
@@ -1,5 +1,5 @@
use std::sync::Arc;
use crate::session::AppState;
use std::sync::Arc;
pub async fn cleanup_loop(state: Arc<AppState>) {
loop {
@@ -7,18 +7,28 @@ 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 = {
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);
}
}
}
+21 -7
View File
@@ -52,15 +52,21 @@ impl GlobalArgs {
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run the ChronoSeal daemon.
#[command(after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run")]
#[command(
after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run"
)]
Run(RunArgs),
/// Report whether the configured daemon is reachable and which PID file is present.
#[command(after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid")]
#[command(
after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid"
)]
Status(RuntimeArgs),
/// Perform a daemon health probe.
#[command(after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000")]
#[command(
after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000"
)]
Health(RuntimeArgs),
/// Validate and print effective configuration.
@@ -76,7 +82,9 @@ pub enum Command {
Version,
/// Print Prometheus metrics from the running daemon.
#[command(after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000")]
#[command(
after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000"
)]
Metrics(RuntimeArgs),
/// Print service statistics from the running daemon.
@@ -84,7 +92,9 @@ pub enum Command {
Stats(RuntimeArgs),
/// Generate shell completions.
#[command(after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal")]
#[command(
after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal"
)]
Completion { shell: clap_complete::Shell },
}
@@ -120,13 +130,17 @@ pub struct RuntimeArgs {
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
/// Validate configuration and print the effective values.
#[command(after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json")]
#[command(
after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json"
)]
Check(RuntimeArgs),
}
#[derive(Debug, Subcommand)]
pub enum GenerateCommand {
/// Generate an Ed25519 keypair as hex-encoded JSON/YAML/text.
#[command(after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json")]
#[command(
after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json"
)]
Keypair,
}
+83 -2
View File
@@ -14,6 +14,16 @@ pub struct Config {
pub db_path: PathBuf,
pub frontend_dir: PathBuf,
pub log_file: Option<PathBuf>,
pub heartbeat_min_interval_ms: u64,
pub heartbeat_max_interval_ms: u64,
pub expiration_minutes: i64,
pub rate_limit_count: u32,
pub rate_limit_window_secs: u64,
pub max_timestamp_drift_ms: i64,
pub min_mouse_total_dist: f64,
pub max_mouse_avg_speed: f64,
pub min_pause_count: u32,
pub require_mouse_activity: bool,
}
impl Default for Config {
@@ -24,6 +34,16 @@ impl Default for Config {
db_path: default_state_dir().join("chronoseal.sqlite"),
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
log_file: None,
heartbeat_min_interval_ms: 12_000,
heartbeat_max_interval_ms: 25_000,
expiration_minutes: 30,
rate_limit_count: 5,
rate_limit_window_secs: 10,
max_timestamp_drift_ms: 30_000,
min_mouse_total_dist: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: true,
}
}
}
@@ -32,7 +52,10 @@ impl Config {
pub fn load(config_path: Option<&Path>) -> Result<Self, ConfigError> {
let mut config = Self::default();
if let Some(path) = config_path.map(Path::to_path_buf).or_else(discover_config_path) {
if let Some(path) = config_path
.map(Path::to_path_buf)
.or_else(discover_config_path)
{
let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
path: path.clone(),
source,
@@ -96,6 +119,56 @@ impl Config {
if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") {
self.log_file = Some(PathBuf::from(value));
}
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MIN_INTERVAL_MS") {
if let Ok(val) = value.parse() {
self.heartbeat_min_interval_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MAX_INTERVAL_MS") {
if let Ok(val) = value.parse() {
self.heartbeat_max_interval_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_EXPIRATION_MINUTES") {
if let Ok(val) = value.parse() {
self.expiration_minutes = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_COUNT") {
if let Ok(val) = value.parse() {
self.rate_limit_count = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_WINDOW_SECS") {
if let Ok(val) = value.parse() {
self.rate_limit_window_secs = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MAX_TIMESTAMP_DRIFT_MS") {
if let Ok(val) = value.parse() {
self.max_timestamp_drift_ms = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MIN_MOUSE_TOTAL_DIST") {
if let Ok(val) = value.parse() {
self.min_mouse_total_dist = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MAX_MOUSE_AVG_SPEED") {
if let Ok(val) = value.parse() {
self.max_mouse_avg_speed = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_MIN_PAUSE_COUNT") {
if let Ok(val) = value.parse() {
self.min_pause_count = val;
}
}
if let Ok(value) = env::var("CHRONOSEAL_REQUIRE_MOUSE_ACTIVITY") {
if let Ok(val) = value.parse() {
self.require_mouse_activity = val;
}
}
}
}
@@ -122,7 +195,9 @@ impl std::fmt::Display for ConfigError {
Self::Parse { path, source } => {
write!(f, "failed to parse {} as TOML: {source}", path.display())
}
Self::InvalidBind { bind, source } => write!(f, "invalid bind address {bind}: {source}"),
Self::InvalidBind { bind, source } => {
write!(f, "invalid bind address {bind}: {source}")
}
}
}
}
@@ -130,6 +205,12 @@ impl std::fmt::Display for ConfigError {
impl std::error::Error for ConfigError {}
fn discover_config_path() -> Option<PathBuf> {
if let Ok(path) = env::var("CHRONOSEAL_CONFIG") {
let p = PathBuf::from(path);
if p.is_file() {
return Some(p);
}
}
user_config_candidates()
.into_iter()
.find(|candidate| candidate.is_file())
+1 -3
View File
@@ -6,9 +6,7 @@ pub fn verify_signature(
pub_key_bytes: &[u8],
req: &HeartbeatRequest,
) -> Result<(), Box<dyn std::error::Error>> {
let pk = VerifyingKey::from_bytes(
&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?,
)?;
let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
let sig_bytes = hex::decode(&req.signature)?;
let sig = Signature::from_slice(&sig_bytes)?;
+68
View File
@@ -0,0 +1,68 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SessionError {
#[error("Hex decoding error: {0}")]
Hex(#[from] hex::FromHexError),
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("R2D2 pool error: {0}")]
Pool(#[from] r2d2::Error),
#[error("Invalid public key length")]
InvalidPublicKeyLength,
}
impl IntoResponse for SessionError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
SessionError::InvalidPublicKeyLength => (StatusCode::BAD_REQUEST, self.to_string()),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
),
};
let body = Json(json!({
"error": error_message
}));
(status, body).into_response()
}
}
#[derive(Error, Debug)]
pub enum VerificationError {
#[error("Session not found")]
SessionNotFound,
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("Hex decoding error: {0}")]
Hex(#[from] hex::FromHexError),
#[error("Signature verification error: {0}")]
Signature(String),
#[error("Session has expired")]
Expired,
#[error("Chain is broken")]
ChainBroken,
#[error("Timestamp drift exceeded threshold")]
TimestampDrift,
#[error("Trust criteria failed: {0}")]
TrustFailed(String),
#[error("Fingerprint validation failed: {0}")]
FingerprintFailed(String),
}
+9 -3
View File
@@ -2,9 +2,15 @@ use shared::protocol::Fingerprint;
pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> {
let ar: f64 = fp.aspect_ratio.parse().map_err(|_| "ar")?;
if ar < 0.5 || ar > 3.0 { return Err("aspect ratio".into()); }
if !(0.5..=3.0).contains(&ar) {
return Err("aspect ratio".into());
}
let dpr: f64 = fp.device_pixel_ratio.parse().map_err(|_| "dpr")?;
if dpr <= 0.0 || dpr > 5.0 { return Err("dpr".into()); }
if fp.hardware_concurrency == 0 { return Err("hw".into()); }
if dpr <= 0.0 || dpr > 5.0 {
return Err("dpr".into());
}
if fp.hardware_concurrency == 0 {
return Err("hw".into());
}
Ok(())
}
+4
View File
@@ -2,6 +2,7 @@ mod cleanup;
mod cli;
mod config;
mod crypto;
mod errors;
mod fingerprint;
mod middleware;
mod output;
@@ -29,6 +30,9 @@ async fn main() {
async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if let Some(config_path) = cli.globals.config.as_deref() {
std::env::set_var("CHRONOSEAL_CONFIG", config_path);
}
let log_filter = cli.globals.log.as_deref().unwrap_or("info");
let log_file = log_file_for_command(&cli);
let _log_guard = init_logging(log_filter, log_file)?;
+44 -10
View File
@@ -3,22 +3,22 @@ use std::time::Instant;
pub struct RateLimiter {
buckets: HashMap<String, (u32, Instant)>,
limit: u32,
window_secs: u64,
}
impl RateLimiter {
pub fn new(limit: u32, window_secs: u64) -> Self {
Self { buckets: HashMap::new(), limit, window_secs }
pub fn new() -> Self {
Self {
buckets: HashMap::new(),
}
}
pub fn check(&mut self, key: &str) -> bool {
pub fn check(&mut self, key: &str, limit: u32, window_secs: u64) -> bool {
let now = Instant::now();
let entry = self.buckets.entry(key.to_string()).or_insert((0, now));
if now.duration_since(entry.1).as_secs() >= self.window_secs {
if now.duration_since(entry.1).as_secs() >= window_secs {
*entry = (1, now);
true
} else if entry.0 >= self.limit {
} else if entry.0 >= limit {
false
} else {
entry.0 += 1;
@@ -28,10 +28,44 @@ impl RateLimiter {
/// Remove entries whose rate-limit window has fully elapsed.
/// Call this periodically (e.g. from the cleanup loop) to bound memory usage.
pub fn evict_stale(&mut self) {
let window = self.window_secs;
pub fn evict_stale(&mut self, window_secs: u64) {
let now = Instant::now();
self.buckets
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window);
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window_secs);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn test_rate_limiter() {
let mut rl = RateLimiter::new();
// Limit of 2 requests per 1 second window
assert!(rl.check("user1", 2, 1));
assert!(rl.check("user1", 2, 1));
assert!(!rl.check("user1", 2, 1)); // 3rd fails
assert!(rl.check("user2", 2, 1)); // different key succeeds
thread::sleep(Duration::from_millis(1100));
assert!(rl.check("user1", 2, 1)); // succeeds after time window
}
#[test]
fn test_rate_limiter_eviction() {
let mut rl = RateLimiter::new();
assert!(rl.check("user1", 1, 1));
assert_eq!(rl.buckets.len(), 1);
rl.evict_stale(1);
assert_eq!(rl.buckets.len(), 1); // not stale yet
thread::sleep(Duration::from_millis(1100));
rl.evict_stale(1);
assert_eq!(rl.buckets.len(), 0); // evicted
}
}
+50 -9
View File
@@ -1,7 +1,7 @@
use axum::{extract::State, http::StatusCode, Json};
use std::sync::Arc;
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
use crate::session::AppState;
use axum::{extract::State, http::StatusCode, Json};
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
use std::sync::Arc;
pub async fn handler(
State(state): State<Arc<AppState>>,
@@ -9,22 +9,63 @@ pub async fn handler(
) -> (StatusCode, Json<HeartbeatResponse>) {
// 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,
}),
)
}
}
}
+15 -11
View File
@@ -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<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 = {
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))
}
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod init;
pub mod heartbeat;
pub mod init;
+40 -22
View File
@@ -41,7 +41,9 @@ pub struct StatusReport {
impl TextOutput for StatusReport {
fn to_text(&self) -> String {
let pid = self.pid.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
let pid = self
.pid
.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
format!(
"running={}\nhealthy={}\nbind={}\npid_file={}\npid={}",
self.running, self.healthy, self.bind, self.pid_file, pid
@@ -106,13 +108,11 @@ impl TextOutput for StoreStats {
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
install_pid_file(&config.pid_file)?;
let conn = storage::init_db(&config.db_path)?;
let db_pool = storage::init_pool(&config.db_path)?;
let state = Arc::new(session::AppState {
db: Mutex::new(conn),
rate_limiter: Mutex::new(RateLimiter::new(
shared::constants::RATE_LIMIT_COUNT,
shared::constants::RATE_LIMIT_WINDOW_SECS,
)),
db_pool,
rate_limiter: Mutex::new(RateLimiter::new()),
config: std::sync::RwLock::new(config.clone()),
});
let bg_state = state.clone();
@@ -124,16 +124,19 @@ pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>
.route("/health", get(health_handler))
.route("/metrics", get(metrics_handler))
.route("/stats", get(stats_handler))
.nest_service("/", tower_http::services::ServeDir::new(&config.frontend_dir))
.nest_service(
"/",
tower_http::services::ServeDir::new(&config.frontend_dir),
)
.layer(tower_http::cors::CorsLayer::permissive())
.layer(axum::middleware::from_fn(crate::middleware::log_request))
.with_state(state);
.with_state(state.clone());
let addr: SocketAddr = config.bind.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!(bind = %config.bind, "chronoseal daemon started");
let shutdown = signal_task(config.clone());
let shutdown = signal_task(state.clone());
let result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await;
@@ -199,13 +202,19 @@ pub fn version() -> VersionReport {
}
async fn health_handler() -> impl IntoResponse {
(StatusCode::OK, Json(serde_json::json!({ "status": "healthy" })))
(
StatusCode::OK,
Json(serde_json::json!({ "status": "healthy" })),
)
}
async fn stats_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<Json<StoreStats>, (StatusCode, String)> {
let db = state.db.lock().await;
let db = state
.db_pool
.get()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
storage::stats(&db)
.map(Json)
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
@@ -214,7 +223,10 @@ async fn stats_handler(
async fn metrics_handler(
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
) -> Result<String, (StatusCode, String)> {
let db = state.db.lock().await;
let db = state
.db_pool
.get()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
storage::stats(&db)
.map(|stats| {
format!(
@@ -225,7 +237,7 @@ async fn metrics_handler(
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
}
async fn signal_task(config: Config) {
async fn signal_task(state: Arc<session::AppState>) {
let shutdown = Arc::new(Notify::new());
#[cfg(unix)]
@@ -248,19 +260,23 @@ async fn signal_task(config: Config) {
}
});
let hup_config = config.clone();
let state_for_hup = state.clone();
tokio::spawn(async move {
let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler");
while sighup.recv().await.is_some() {
match Config::load(None) {
Ok(reloaded) => info!(
bind = %reloaded.bind,
db_path = %reloaded.db_path.display(),
"received SIGHUP; configuration reloaded"
),
Ok(reloaded) => {
info!(
bind = %reloaded.bind,
db_path = %reloaded.db_path.display(),
"received SIGHUP; configuration reloaded"
);
if let Ok(mut config_write) = state_for_hup.config.write() {
*config_write = reloaded;
}
}
Err(err) => warn!(error = %err, "received SIGHUP; configuration reload failed"),
}
let _ = &hup_config;
}
});
@@ -312,7 +328,9 @@ fn read_pid(path: &Path) -> Option<u32> {
fn http_get(bind: &str, path: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut stream = TcpStream::connect_timeout(&bind.parse()?, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes())?;
stream.write_all(
format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes(),
)?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
+131 -15
View File
@@ -1,24 +1,26 @@
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>,
}
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 +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<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)?;
@@ -96,3 +111,104 @@ pub fn verify_heartbeat(
Ok(next_salt_hex)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, StackState};
use std::path::Path;
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) {
let mut payload: std::collections::BTreeMap<&str, serde_json::Value> =
std::collections::BTreeMap::new();
payload.insert(
"entropyData",
serde_json::to_value(&req.entropy_data).unwrap(),
);
payload.insert(
"fingerprint",
serde_json::to_value(&req.fingerprint).unwrap(),
);
payload.insert("prevHash", serde_json::json!(req.prev_hash));
payload.insert("sessionId", serde_json::json!(req.session_id));
payload.insert(
"stackState",
serde_json::to_value(&req.stack_state).unwrap(),
);
payload.insert("timestamp", serde_json::json!(req.timestamp));
let message = serde_json::to_string(&payload).unwrap();
let sig = sk.sign(message.as_bytes());
req.signature = hex::encode(sig.to_bytes());
}
#[test]
fn test_session_lifecycle_and_verification() {
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
let conn = pool.get().unwrap();
let config = crate::config::Config {
expiration_minutes: 30,
max_timestamp_drift_ms: 30000,
min_mouse_total_dist: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: false, // simpler for tests
..crate::config::Config::default()
};
// Generate Ed25519 keypair
let mut rng = rand::thread_rng();
let sk = SigningKey::generate(&mut rng);
let pk = sk.verifying_key();
let pub_key_hex = hex::encode(pk.to_bytes());
// 1. Create Session
let start_time = storage::current_time_ms();
let init_resp = create_session(&conn, &config, &pub_key_hex).unwrap();
assert!(init_resp.expires_at >= start_time + 30 * 60 * 1000);
assert!(init_resp.expires_at <= storage::current_time_ms() + 30 * 60 * 1000);
// Verify stats
let stats = storage::stats(&conn).unwrap();
assert_eq!(stats.sessions, 1);
assert_eq!(stats.expired_sessions, 0);
// 2. Heartbeat Verification
let now = storage::current_time_ms();
let entropy_data = EntropyData { events: vec![] };
let stack_state = StackState {
stack: vec![42],
ip: 5,
};
let fingerprint = Fingerprint {
aspect_ratio: "1.77".to_string(),
device_pixel_ratio: "2.0".to_string(),
hardware_concurrency: 8,
};
let mut req = HeartbeatRequest {
session_id: init_resp.session_id.clone(),
prev_hash: init_resp.initial_hash.clone(),
timestamp: now,
entropy_data,
stack_state,
fingerprint,
signature: "".to_string(),
};
sign_request(&sk, &mut req);
// Verify successful heartbeat
let next_salt = verify_heartbeat(&conn, &config, &req).unwrap();
assert!(!next_salt.is_empty());
// Try duplicate/broken hash chain (prev_hash unchanged but expected next hash in DB)
let res = verify_heartbeat(&conn, &config, &req);
assert!(res.is_err());
assert!(matches!(
res.unwrap_err(),
crate::errors::VerificationError::ChainBroken
));
}
}
+22 -11
View File
@@ -10,17 +10,25 @@ pub struct StoreStats {
pub max_chain_length: u64,
}
pub fn init_db(path: &Path) -> Result<Connection, rusqlite::Error> {
if path == Path::new(":memory:") {
return init_schema(Connection::open_in_memory()?);
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
init_schema(Connection::open(path)?)
pub type DbPool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>;
pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
let manager = if path == Path::new(":memory:") {
r2d2_sqlite::SqliteConnectionManager::memory()
} else {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
r2d2_sqlite::SqliteConnectionManager::file(path)
};
let pool = r2d2::Pool::new(manager)?;
let conn = pool.get()?;
init_schema(&conn)?;
Ok(pool)
}
fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
fn init_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
@@ -33,7 +41,7 @@ fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
expires_at INTEGER NOT NULL
);",
)?;
Ok(conn)
Ok(())
}
pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
@@ -57,5 +65,8 @@ pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
}
pub fn current_time_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
+188 -6
View File
@@ -1,7 +1,14 @@
use crate::config::Config;
use shared::protocol::EntropyData;
pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Error>> {
pub fn validate_mouse(
data: &EntropyData,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
let events = &data.events;
if !config.require_mouse_activity && events.is_empty() {
return Ok(());
}
if events.len() < 3 {
return Err("few events".into());
}
@@ -19,18 +26,193 @@ pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Erro
pauses += 1;
}
}
if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST {
if total_dist < config.min_mouse_total_dist {
return Err("insufficient distance".into());
}
// Speed in px/ms: total distance over elapsed wall-clock time of the event window.
let total_time_ms =
(events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
let total_time_ms = (events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
let avg_speed = total_dist / total_time_ms;
if avg_speed > shared::constants::MAX_MOUSE_AVG_SPEED {
if avg_speed > config.max_mouse_avg_speed {
return Err("speed too high".into());
}
if pauses < shared::constants::MIN_PAUSE_COUNT {
if pauses < config.min_pause_count {
return Err("no pause".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use shared::protocol::MouseEvent;
fn get_default_config() -> Config {
Config {
min_mouse_total_dist: 10.0,
max_mouse_avg_speed: 2.0,
min_pause_count: 1,
require_mouse_activity: true,
..Config::default()
}
}
#[test]
fn test_validate_mouse_success() {
let config = get_default_config();
// Mouse moves from (0,0) to (5,0) then (15,0) with a pause
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
// Pause here (dist = 0, time diff = 100ms > 50ms)
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 15.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
assert!(validate_mouse(&data, &config).is_ok());
}
#[test]
fn test_validate_mouse_insufficient_events() {
let config = get_default_config();
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "few events");
}
#[test]
fn test_validate_mouse_insufficient_distance() {
let config = get_default_config();
// Total distance is only 5.0 < 10.0
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 2.0,
y: 0.0,
timestamp_ms: 200.0,
},
MouseEvent {
x: 2.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "insufficient distance");
}
#[test]
fn test_validate_mouse_too_fast() {
let config = get_default_config();
// Distance is 200.0, time difference is 70ms -> speed 2.85 > 2.0
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 100.0,
y: 0.0,
timestamp_ms: 105.0,
},
MouseEvent {
x: 100.0,
y: 0.0,
timestamp_ms: 165.0,
}, // pause
MouseEvent {
x: 200.0,
y: 0.0,
timestamp_ms: 170.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "speed too high");
}
#[test]
fn test_validate_mouse_no_pauses() {
let config = get_default_config();
// Constant movement without any pause
let events = vec![
MouseEvent {
x: 0.0,
y: 0.0,
timestamp_ms: 100.0,
},
MouseEvent {
x: 5.0,
y: 0.0,
timestamp_ms: 200.0,
},
MouseEvent {
x: 10.0,
y: 0.0,
timestamp_ms: 300.0,
},
MouseEvent {
x: 15.0,
y: 0.0,
timestamp_ms: 400.0,
},
];
let data = EntropyData { events };
let res = validate_mouse(&data, &config);
assert!(res.is_err());
assert_eq!(res.unwrap_err().to_string(), "no pause");
}
#[test]
fn test_validate_mouse_require_activity_toggle() {
let mut config = get_default_config();
config.require_mouse_activity = false;
let data = EntropyData { events: vec![] };
assert!(validate_mouse(&data, &config).is_ok());
config.require_mouse_activity = true;
assert!(validate_mouse(&data, &config).is_err());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
[dependencies]
-9
View File
@@ -1,11 +1,2 @@
pub const SESSION_ID_LEN: usize = 32;
pub const SALT_LEN: usize = 16;
pub const HEARTBEAT_MIN_INTERVAL_MS: u64 = 12_000;
pub const HEARTBEAT_MAX_INTERVAL_MS: u64 = 25_000;
pub const EXPIRATION_MINUTES: i64 = 30;
pub const RATE_LIMIT_COUNT: u32 = 5;
pub const RATE_LIMIT_WINDOW_SECS: u64 = 10;
pub const MAX_TIMESTAMP_DRIFT_MS: i64 = 30_000;
pub const MIN_MOUSE_TOTAL_DIST: f64 = 10.0;
pub const MAX_MOUSE_AVG_SPEED: f64 = 2.0; // px/ms
pub const MIN_PAUSE_COUNT: u32 = 1;
+1 -1
View File
@@ -1,5 +1,5 @@
use blake3::Hasher;
use crate::protocol::{EntropyData, StackState};
use blake3::Hasher;
/// Initial hash for a brand-new session: Blake3(session_id || pub_key || salt)
pub fn initial_hash(session_id: &str, pub_key: &[u8], salt: &[u8]) -> Vec<u8> {
+2
View File
@@ -12,6 +12,8 @@ pub struct InitResponse {
pub opcodes_b64: String,
pub initial_hash: String,
pub expires_at: u64,
pub heartbeat_min_interval_ms: u64,
pub heartbeat_max_interval_ms: u64,
}
#[derive(Deserialize, Serialize)]
+2 -2
View File
@@ -1,10 +1,10 @@
[package]
name = "chronoseal-wasm"
version = "0.2.0"
version = "0.5.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
[dependencies]
shared = { path = "../shared" }
+3 -5
View File
@@ -3,7 +3,7 @@ use std::cell::RefCell;
use wasm_bindgen::prelude::*;
thread_local! {
static KEYPAIR: RefCell<Option<SigningKey>> = RefCell::new(None);
static KEYPAIR: RefCell<Option<SigningKey>> = const { RefCell::new(None) };
}
#[wasm_bindgen]
@@ -51,10 +51,8 @@ pub fn compute_next_hash(
) -> String {
let prev = hex::decode(prev_hash_hex).unwrap_or_default();
let salt = hex::decode(salt_hex).unwrap_or_default();
let entropy =
serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap();
let stack =
serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
let entropy = serde_json::from_str::<shared::protocol::EntropyData>(entropy_data_json).unwrap();
let stack = serde_json::from_str::<shared::protocol::StackState>(stack_state_json).unwrap();
let new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt);
hex::encode(new)
}
+155 -7
View File
@@ -1,10 +1,12 @@
use wasm_bindgen::prelude::*;
use shared::protocol::StackState;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn run_program(program_b64: &str) -> JsValue {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD.decode(program_b64).unwrap();
let bytes = base64::engine::general_purpose::STANDARD
.decode(program_b64)
.unwrap();
let state = execute(&bytes);
serde_wasm_bindgen::to_value(&state).unwrap()
}
@@ -17,13 +19,22 @@ fn execute(program: &[u8]) -> StackState {
ip += 1;
match op {
0x00 => {
if ip + 4 > program.len() { break; }
let val = u32::from_le_bytes([program[ip], program[ip+1], program[ip+2], program[ip+3]]);
if ip + 4 > program.len() {
break;
}
let val = u32::from_le_bytes([
program[ip],
program[ip + 1],
program[ip + 2],
program[ip + 3],
]);
ip += 4;
stack.push(val);
}
0x01..=0x07 => {
if stack.len() < 2 { break; }
if stack.len() < 2 {
break;
}
let b = stack.pop().unwrap();
let a = stack.pop().unwrap();
let r = match op {
@@ -39,7 +50,9 @@ fn execute(program: &[u8]) -> StackState {
stack.push(r);
}
0x08 => {
if stack.is_empty() { break; }
if stack.is_empty() {
break;
}
let a = stack.pop().unwrap();
stack.push(!a);
}
@@ -51,5 +64,140 @@ fn execute(program: &[u8]) -> StackState {
_ => break,
}
}
StackState { stack, ip: ip as u16 }
StackState {
stack,
ip: ip as u16,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_push() {
// PUSH 42, PUSH 100
let program = vec![0x00, 42, 0, 0, 0, 0x00, 100, 0, 0, 0];
let state = execute(&program);
assert_eq!(state.stack, vec![42, 100]);
assert_eq!(state.ip, 10);
}
#[test]
fn test_add() {
// PUSH 5, PUSH 10, ADD
let program = vec![0x00, 5, 0, 0, 0, 0x00, 10, 0, 0, 0, 0x01];
let state = execute(&program);
assert_eq!(state.stack, vec![15]);
}
#[test]
fn test_add_wrapping() {
// PUSH u32::MAX, PUSH 1, ADD
let program = vec![0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 1, 0, 0, 0, 0x01];
let state = execute(&program);
assert_eq!(state.stack, vec![0]);
}
#[test]
fn test_sub() {
// PUSH 20, PUSH 7, SUB
let program = vec![0x00, 20, 0, 0, 0, 0x00, 7, 0, 0, 0, 0x02];
let state = execute(&program);
assert_eq!(state.stack, vec![13]);
}
#[test]
fn test_sub_wrapping() {
// PUSH 0, PUSH 1, SUB
let program = vec![0x00, 0, 0, 0, 0, 0x00, 1, 0, 0, 0, 0x02];
let state = execute(&program);
assert_eq!(state.stack, vec![u32::MAX]);
}
#[test]
fn test_mul() {
// PUSH 6, PUSH 7, MUL
let program = vec![0x00, 6, 0, 0, 0, 0x00, 7, 0, 0, 0, 0x03];
let state = execute(&program);
assert_eq!(state.stack, vec![42]);
}
#[test]
fn test_xor() {
// PUSH 0b1010, PUSH 0b1100, XOR
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x04];
let state = execute(&program);
assert_eq!(state.stack, vec![0b0110]);
}
#[test]
fn test_and() {
// PUSH 0b1010, PUSH 0b1100, AND
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x05];
let state = execute(&program);
assert_eq!(state.stack, vec![0b1000]);
}
#[test]
fn test_or() {
// PUSH 0b1010, PUSH 0b1100, OR
let program = vec![0x00, 0b1010, 0, 0, 0, 0x00, 0b1100, 0, 0, 0, 0x06];
let state = execute(&program);
assert_eq!(state.stack, vec![0b1110]);
}
#[test]
fn test_rot() {
// PUSH 1, PUSH 4, ROT
let program = vec![0x00, 1, 0, 0, 0, 0x00, 4, 0, 0, 0, 0x07];
let state = execute(&program);
assert_eq!(state.stack, vec![16]);
}
#[test]
fn test_not() {
// PUSH 0, NOT
let program = vec![0x00, 0, 0, 0, 0, 0x08];
let state = execute(&program);
assert_eq!(state.stack, vec![u32::MAX]);
}
#[test]
fn test_hash() {
// PUSH 10, PUSH 20, HASH
let program = vec![0x00, 10, 0, 0, 0, 0x00, 20, 0, 0, 0, 0x09];
let state = execute(&program);
assert_eq!(state.stack.len(), 1);
let expected_hash = shared::hashing::hash_stack(&[10, 20]);
assert_eq!(state.stack[0], expected_hash);
}
#[test]
fn test_underflow_binary() {
// PUSH 42, ADD (needs 2 values, only 1 on stack)
let program = vec![0x00, 42, 0, 0, 0, 0x01];
let state = execute(&program);
// ADD breaks when stack.len() < 2, stack has 42 left, ip is at the opcode ADD (6)
assert_eq!(state.stack, vec![42]);
assert_eq!(state.ip, 6);
}
#[test]
fn test_underflow_unary() {
// NOT (needs 1 value, empty stack)
let program = vec![0x08];
let state = execute(&program);
assert_eq!(state.stack, Vec::<u32>::new());
assert_eq!(state.ip, 1);
}
#[test]
fn test_incomplete_push() {
// PUSH opcode, but only 2 bytes instead of 4
let program = vec![0x00, 42, 0];
let state = execute(&program);
assert_eq!(state.stack, Vec::<u32>::new());
assert_eq!(state.ip, 1); // execution stopped at op 0x00 because ip + 4 > program.len()
}
}