diff --git a/Dockerfile b/Dockerfile index 5d58123..4ab899f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.88-bookworm AS builder +FROM rust:1.87-bookworm AS builder WORKDIR /app diff --git a/frontend/heartbeat.js b/frontend/heartbeat.js index c591ee2..a8430d4 100644 --- a/frontend/heartbeat.js +++ b/frontend/heartbeat.js @@ -47,7 +47,10 @@ async function sendHeartbeat() { }; const msg = JSON.stringify(signable, Object.keys(signable).sort()); const sig = sign_message(msg); - + if (!sig) { + console.error('Keypair not initialised — skipping heartbeat'); + return; + } const resp = await sendRequest('/hb', 'POST', { session_id: session, prev_hash: prevHash, @@ -59,8 +62,12 @@ async function sendHeartbeat() { }); if (resp.next_salt) { + // IMPORTANT: capture the salt that was active when this heartbeat was sent. + // The server computes new_hash = H(prev, ts, entropy, stack, OLD_salt) and stores it, + // then rotates to next_salt. We must mirror that using the same old salt, then rotate. + const sentSalt = currentSalt; currentSalt = resp.next_salt; - prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, currentSalt); + prevHash = compute_next_hash(prevHash, timestamp, entropyJson, stackState, sentSalt); } else { console.warn('Heartbeat rejected'); } diff --git a/server/src/cleanup.rs b/server/src/cleanup.rs index da8943e..fd372d6 100644 --- a/server/src/cleanup.rs +++ b/server/src/cleanup.rs @@ -4,11 +4,21 @@ use crate::session::AppState; pub async fn cleanup_loop(state: Arc) { loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; - let db = state.db.lock().await; // this is infallible - let now = crate::storage::current_time_ms(); - let _ = db.execute( - "DELETE FROM sessions WHERE expires_at < ?1", - rusqlite::params![now], - ); + + // 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], + ); + } + + // Evict stale rate-limiter entries to prevent unbounded HashMap growth. + { + let mut rl = state.rate_limiter.lock().await; + rl.evict_stale(); + } } } \ No newline at end of file diff --git a/server/src/crypto.rs b/server/src/crypto.rs index 3c6e4f9..9d7c741 100644 --- a/server/src/crypto.rs +++ b/server/src/crypto.rs @@ -1,5 +1,6 @@ -use ed25519_dalek::{VerifyingKey, Signature}; +use ed25519_dalek::{Signature, VerifyingKey}; use shared::protocol::HeartbeatRequest; +use std::collections::BTreeMap; pub fn verify_signature( pub_key_bytes: &[u8], @@ -11,15 +12,16 @@ pub fn verify_signature( let sig_bytes = hex::decode(&req.signature)?; let sig = Signature::from_slice(&sig_bytes)?; - // Build canonical JSON exactly as client signed (sorted keys, no extra spaces) - let payload = serde_json::json!({ - "sessionId": req.session_id, - "prevHash": req.prev_hash, - "timestamp": req.timestamp, - "entropyData": req.entropy_data, - "stackState": req.stack_state, - "fingerprint": req.fingerprint, - }); + // Build canonical JSON with BTreeMap so keys are sorted alphabetically, + // matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()). + // Sorted order: entropyData, fingerprint, prevHash, sessionId, stackState, timestamp + let mut payload: BTreeMap<&str, serde_json::Value> = BTreeMap::new(); + payload.insert("entropyData", serde_json::to_value(&req.entropy_data)?); + payload.insert("fingerprint", serde_json::to_value(&req.fingerprint)?); + 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)?); + payload.insert("timestamp", serde_json::json!(req.timestamp)); let message = serde_json::to_string(&payload)?; pk.verify_strict(message.as_bytes(), &sig)?; diff --git a/server/src/ratelimit.rs b/server/src/ratelimit.rs index 852ac6c..eea9c3e 100644 --- a/server/src/ratelimit.rs +++ b/server/src/ratelimit.rs @@ -11,6 +11,7 @@ impl RateLimiter { pub fn new(limit: u32, window_secs: u64) -> Self { Self { buckets: HashMap::new(), limit, window_secs } } + pub fn check(&mut self, key: &str) -> bool { let now = Instant::now(); let entry = self.buckets.entry(key.to_string()).or_insert((0, now)); @@ -24,4 +25,13 @@ impl RateLimiter { true } } + + /// 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; + let now = Instant::now(); + self.buckets + .retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window); + } } \ No newline at end of file diff --git a/server/src/trust.rs b/server/src/trust.rs index d85c665..9c31504 100644 --- a/server/src/trust.rs +++ b/server/src/trust.rs @@ -5,22 +5,27 @@ pub fn validate_mouse(data: &EntropyData) -> Result<(), Box 50.0 { pauses += 1; } + if dist < 0.2 && dt > 50.0 { + pauses += 1; + } } if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST { return Err("insufficient distance".into()); } - let avg_speed = total_dist / events.len() as f64; + // 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 avg_speed = total_dist / total_time_ms; if avg_speed > shared::constants::MAX_MOUSE_AVG_SPEED { return Err("speed too high".into()); } diff --git a/server/src/vm.rs b/server/src/vm.rs index 451126d..b586d87 100644 --- a/server/src/vm.rs +++ b/server/src/vm.rs @@ -1,28 +1,41 @@ use rand::Rng; pub fn generate_random_program(len_range: std::ops::RangeInclusive) -> Vec { - // Same logic as earlier, using shared::hashing for HASH if needed let mut rng = rand::thread_rng(); let count = rng.gen_range(len_range); let mut ops = Vec::new(); let mut depth: i32 = 0; for _ in 0..count { if depth < 2 { - ops.push(0x00); // PUSH + // Not enough operands for any binary op — push a literal. + ops.push(0x00); let val = rng.gen::(); ops.extend_from_slice(&val.to_le_bytes()); depth += 1; } else { - let op = rng.gen_range(0..10); + let op = rng.gen_range(0u8..10); match op { 0x00 => { + // PUSH literal ops.push(0x00); let val = rng.gen::(); ops.extend_from_slice(&val.to_le_bytes()); depth += 1; } - 0x01..=0x08 => { ops.push(op as u8); depth -= 1; } - 0x09 => { ops.push(0x09); depth = 1; } + 0x01..=0x07 => { + // Binary ops (ADD, SUB, MUL, XOR, AND, OR, ROT): pops 2, pushes 1 → net −1 + ops.push(op); + depth -= 1; + } + 0x08 => { + // Unary NOT: pops 1, pushes 1 → net 0; depth unchanged + ops.push(0x08); + } + 0x09 => { + // HASH: collapses entire stack to one u32 → depth becomes 1 + ops.push(0x09); + depth = 1; + } _ => unreachable!(), } } diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 390f21c..c9c15f0 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" # <- add this line +serde_json = "1" blake3 = "1" hex = "0.4" base64 = "0.22" diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index c464c5b..31a9a28 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "antibot-wasm" +name = "chronoseal-wasm" version = "0.2.0" edition = "2021" @@ -12,9 +12,9 @@ wasm-bindgen = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" ed25519-dalek = { version = "2", features = ["rand_core"] } -rand = "0.8" # <-- add this +rand = "0.8" blake3 = "1" getrandom = { version = "0.2", features = ["js"] } hex = "0.4" base64 = "0.22" -serde-wasm-bindgen = "0.6" \ No newline at end of file +serde-wasm-bindgen = "0.6" diff --git a/wasm/src/crypto.rs b/wasm/src/crypto.rs index e759817..6428fc5 100644 --- a/wasm/src/crypto.rs +++ b/wasm/src/crypto.rs @@ -1,4 +1,4 @@ -use ed25519_dalek::{SigningKey, Signer}; +use ed25519_dalek::{Signer, SigningKey}; use std::cell::RefCell; use wasm_bindgen::prelude::*; @@ -16,17 +16,28 @@ pub fn generate_keypair() -> String { hex_pub } +/// Returns the hex-encoded public key, or an empty string if the keypair has not +/// been generated yet. Callers must check for an empty return value. #[wasm_bindgen] pub fn get_public_key() -> String { - KEYPAIR.with(|kp| hex::encode(kp.borrow().as_ref().unwrap().verifying_key().as_bytes())) + KEYPAIR.with(|kp| { + kp.borrow() + .as_ref() + .map(|sk| hex::encode(sk.verifying_key().as_bytes())) + .unwrap_or_default() + }) } +/// Signs `message_json` and returns a hex-encoded signature, or an empty string +/// if the keypair has not been initialised. Callers must check for an empty +/// return value before sending a heartbeat. #[wasm_bindgen] pub fn sign_message(message_json: &str) -> String { KEYPAIR.with(|kp| { - let sk = kp.borrow(); - let sig = sk.as_ref().unwrap().sign(message_json.as_bytes()); - hex::encode(sig.to_bytes()) + kp.borrow() + .as_ref() + .map(|sk| hex::encode(sk.sign(message_json.as_bytes()).to_bytes())) + .unwrap_or_default() }) } @@ -38,10 +49,12 @@ pub fn compute_next_hash( stack_state_json: &str, salt_hex: &str, ) -> String { - let prev = hex::decode(prev_hash_hex).unwrap(); - let salt = hex::decode(salt_hex).unwrap(); - let entropy = serde_json::from_str::(entropy_data_json).unwrap(); - let stack = serde_json::from_str::(stack_state_json).unwrap(); + 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 new = shared::hashing::next_chain_hash(&prev, timestamp, &entropy, &stack, &salt); - hex::encode(&new) + hex::encode(new) } \ No newline at end of file