feat: implement v0.6.0 mutation engine and db_type runtime selection
Rust / build (push) Canceled after 0s
Rust / build (push) Canceled after 0s
This commit is contained in:
1 parent
217dc5f92b
commit
ba768da58e
27 files changed
+2615
-158
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chronoseal-server"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
+10
-1
@@ -53,7 +53,7 @@ impl GlobalArgs {
|
||||
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"
|
||||
after_help = "Examples:\n chronoseal run\n chronoseal run --db-type sqlite-in-memory\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),
|
||||
|
||||
@@ -81,6 +81,10 @@ pub enum Command {
|
||||
#[command(after_help = "Examples:\n chronoseal version\n chronoseal version --format json")]
|
||||
Version,
|
||||
|
||||
/// List database backend types and implementation status.
|
||||
#[command(after_help = "Examples:\n chronoseal db-type\n chronoseal db-type --format json")]
|
||||
DbType,
|
||||
|
||||
/// Print Prometheus metrics from the running daemon.
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000"
|
||||
@@ -103,6 +107,11 @@ pub struct RunArgs {
|
||||
#[command(flatten)]
|
||||
pub runtime: RuntimeArgs,
|
||||
|
||||
/// Database backend selection.
|
||||
/// sqlite-in-memory is active. sqlite-in-disk and valkey are planned (TODO).
|
||||
#[arg(long, env = "CHRONOSEAL_DB_TYPE", value_enum)]
|
||||
pub db_type: Option<crate::config::DbType>,
|
||||
|
||||
/// SQLite database path. Use ':memory:' for ephemeral state.
|
||||
#[arg(long, env = "CHRONOSEAL_DB_PATH")]
|
||||
pub db_path: Option<PathBuf>,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::cli::{RunArgs, RuntimeArgs};
|
||||
use clap::ValueEnum;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
env, fs, io,
|
||||
@@ -6,10 +7,30 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum DbType {
|
||||
SqliteInMemory,
|
||||
SqliteInDisk,
|
||||
Valkey,
|
||||
}
|
||||
|
||||
impl DbType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SqliteInMemory => "sqlite-in-memory",
|
||||
Self::SqliteInDisk => "sqlite-in-disk",
|
||||
Self::Valkey => "valkey",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub bind: String,
|
||||
pub db_type: DbType,
|
||||
pub pid_file: PathBuf,
|
||||
pub db_path: PathBuf,
|
||||
pub frontend_dir: PathBuf,
|
||||
@@ -24,12 +45,14 @@ pub struct Config {
|
||||
pub max_mouse_avg_speed: f64,
|
||||
pub min_pause_count: u32,
|
||||
pub require_mouse_activity: bool,
|
||||
pub gene_size: usize,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind: "0.0.0.0:3000".to_string(),
|
||||
db_type: DbType::SqliteInMemory,
|
||||
pid_file: PathBuf::from("/run/chronoseal.pid"),
|
||||
db_path: default_state_dir().join("chronoseal.sqlite"),
|
||||
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
|
||||
@@ -44,6 +67,7 @@ impl Default for Config {
|
||||
max_mouse_avg_speed: 2.0,
|
||||
min_pause_count: 1,
|
||||
require_mouse_activity: true,
|
||||
gene_size: shared::constants::DEFAULT_GENE_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +106,9 @@ impl Config {
|
||||
|
||||
pub fn apply_run_args(&mut self, args: &RunArgs) {
|
||||
self.apply_runtime_args(&args.runtime);
|
||||
if let Some(db_type) = args.db_type {
|
||||
self.db_type = db_type;
|
||||
}
|
||||
if let Some(db_path) = &args.db_path {
|
||||
self.db_path = db_path.clone();
|
||||
}
|
||||
@@ -100,6 +127,11 @@ impl Config {
|
||||
bind: self.bind.clone(),
|
||||
source,
|
||||
})?;
|
||||
if !(1..=shared::constants::MAX_GENE_SIZE).contains(&self.gene_size) {
|
||||
return Err(ConfigError::InvalidGeneSize {
|
||||
size: self.gene_size,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -107,6 +139,14 @@ impl Config {
|
||||
if let Ok(value) = env::var("CHRONOSEAL_BIND") {
|
||||
self.bind = value;
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_DB_TYPE") {
|
||||
self.db_type = match value.as_str() {
|
||||
"sqlite-in-memory" => DbType::SqliteInMemory,
|
||||
"sqlite-in-disk" => DbType::SqliteInDisk,
|
||||
"valkey" => DbType::Valkey,
|
||||
_ => self.db_type,
|
||||
};
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_PID_FILE") {
|
||||
self.pid_file = PathBuf::from(value);
|
||||
}
|
||||
@@ -169,6 +209,11 @@ impl Config {
|
||||
self.require_mouse_activity = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_GENE_SIZE") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.gene_size = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +231,9 @@ pub enum ConfigError {
|
||||
bind: String,
|
||||
source: std::net::AddrParseError,
|
||||
},
|
||||
InvalidGeneSize {
|
||||
size: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
@@ -198,6 +246,13 @@ impl std::fmt::Display for ConfigError {
|
||||
Self::InvalidBind { bind, source } => {
|
||||
write!(f, "invalid bind address {bind}: {source}")
|
||||
}
|
||||
Self::InvalidGeneSize { size } => {
|
||||
write!(
|
||||
f,
|
||||
"invalid gene size {size}; expected 1..={}",
|
||||
shared::constants::MAX_GENE_SIZE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,3 +293,55 @@ fn default_state_dir() -> PathBuf {
|
||||
}
|
||||
PathBuf::from("/var/lib/chronoseal")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_db_type_is_sqlite_in_memory() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.db_type, DbType::SqliteInMemory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_run_args_overrides_db_type() {
|
||||
let mut cfg = Config::default();
|
||||
let args = crate::cli::RunArgs {
|
||||
runtime: crate::cli::RuntimeArgs {
|
||||
bind: None,
|
||||
pid_file: None,
|
||||
},
|
||||
db_type: Some(DbType::SqliteInDisk),
|
||||
db_path: None,
|
||||
frontend_dir: None,
|
||||
log_file: None,
|
||||
};
|
||||
cfg.apply_run_args(&args);
|
||||
assert_eq!(cfg.db_type, DbType::SqliteInDisk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_parses_db_type_kebab_case() {
|
||||
let raw = r#"
|
||||
bind = "127.0.0.1:3000"
|
||||
db_type = "valkey"
|
||||
pid_file = "/tmp/pid"
|
||||
db_path = "/tmp/db.sqlite"
|
||||
frontend_dir = "."
|
||||
heartbeat_min_interval_ms = 12000
|
||||
heartbeat_max_interval_ms = 25000
|
||||
expiration_minutes = 30
|
||||
rate_limit_count = 5
|
||||
rate_limit_window_secs = 10
|
||||
max_timestamp_drift_ms = 30000
|
||||
min_mouse_total_dist = 1.0
|
||||
max_mouse_avg_speed = 2.0
|
||||
min_pause_count = 1
|
||||
require_mouse_activity = true
|
||||
gene_size = 512
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(raw).unwrap();
|
||||
assert_eq!(cfg.db_type, DbType::Valkey);
|
||||
}
|
||||
}
|
||||
+18
-12
@@ -2,6 +2,23 @@ use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use shared::protocol::HeartbeatRequest;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn canonical_signing_message(
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Build canonical JSON with BTreeMap so keys are sorted alphabetically,
|
||||
// matching the JS client's JSON.stringify(obj, Object.keys(obj).sort()).
|
||||
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("geneCommitment", serde_json::json!(req.gene_commitment));
|
||||
payload.insert("mutationStep", serde_json::json!(req.mutation_step));
|
||||
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));
|
||||
Ok(serde_json::to_string(&payload)?)
|
||||
}
|
||||
|
||||
pub fn verify_signature(
|
||||
pub_key_bytes: &[u8],
|
||||
req: &HeartbeatRequest,
|
||||
@@ -9,18 +26,7 @@ pub fn verify_signature(
|
||||
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)?;
|
||||
|
||||
// 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)?;
|
||||
let message = canonical_signing_message(req)?;
|
||||
|
||||
pk.verify_strict(message.as_bytes(), &sig)?;
|
||||
Ok(())
|
||||
|
||||
@@ -19,6 +19,9 @@ pub enum SessionError {
|
||||
|
||||
#[error("Invalid public key length")]
|
||||
InvalidPublicKeyLength,
|
||||
|
||||
#[error("Invalid gene configuration: {0}")]
|
||||
InvalidGeneConfiguration(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for SessionError {
|
||||
@@ -65,4 +68,16 @@ pub enum VerificationError {
|
||||
|
||||
#[error("Fingerprint validation failed: {0}")]
|
||||
FingerprintFailed(String),
|
||||
|
||||
#[error("Mutation step mismatch: expected {expected}, got {got}")]
|
||||
MutationStepMismatch { expected: u64, got: u64 },
|
||||
|
||||
#[error("Mutation commitment mismatch")]
|
||||
MutationCommitmentMismatch,
|
||||
|
||||
#[error("Mutation program error: {0}")]
|
||||
MutationProgram(String),
|
||||
|
||||
#[error("Gene state error: {0}")]
|
||||
GeneState(String),
|
||||
}
|
||||
@@ -75,6 +75,9 @@ async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Some(Command::Version) => {
|
||||
output::print(cli.globals.output_format(), &runtime::version())?;
|
||||
}
|
||||
Some(Command::DbType) => {
|
||||
output::print(cli.globals.output_format(), &runtime::db_type_report())?;
|
||||
}
|
||||
Some(Command::Metrics(args)) => {
|
||||
let mut config = Config::load(cli.globals.config.as_deref())?;
|
||||
config.apply_runtime_args(args);
|
||||
|
||||
@@ -21,6 +21,8 @@ pub async fn handler(
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
next_salt: None,
|
||||
next_mutation_step: None,
|
||||
next_mutation_order_b64: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -36,16 +38,20 @@ pub async fn handler(
|
||||
Json(HeartbeatResponse {
|
||||
status: "error".into(),
|
||||
next_salt: None,
|
||||
next_mutation_step: None,
|
||||
next_mutation_order_b64: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
match crate::session::verify_heartbeat(&conn, &config, &payload) {
|
||||
Ok(next_salt) => (
|
||||
Ok(result) => (
|
||||
StatusCode::OK,
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
next_salt: Some(next_salt),
|
||||
next_salt: Some(result.next_salt_hex),
|
||||
next_mutation_step: Some(result.next_mutation_step),
|
||||
next_mutation_order_b64: Some(result.next_mutation_order_b64),
|
||||
}),
|
||||
),
|
||||
Err(e) => {
|
||||
@@ -55,8 +61,157 @@ pub async fn handler(
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
next_salt: None,
|
||||
next_mutation_step: None,
|
||||
next_mutation_order_b64: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{extract::State, Json};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use shared::protocol::{EntropyData, Fingerprint, InitResponse, MouseEvent, StackState};
|
||||
use std::path::Path;
|
||||
|
||||
fn test_config() -> crate::config::Config {
|
||||
crate::config::Config {
|
||||
expiration_minutes: 30,
|
||||
max_timestamp_drift_ms: 30_000,
|
||||
min_mouse_total_dist: 1.0,
|
||||
max_mouse_avg_speed: 4.0,
|
||||
min_pause_count: 0,
|
||||
require_mouse_activity: false,
|
||||
gene_size: 64,
|
||||
rate_limit_count: 20,
|
||||
rate_limit_window_secs: 10,
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) {
|
||||
let msg = crate::crypto::canonical_signing_message(req).unwrap();
|
||||
req.signature = hex::encode(sk.sign(msg.as_bytes()).to_bytes());
|
||||
}
|
||||
|
||||
fn build_request(
|
||||
init: &InitResponse,
|
||||
sk: &SigningKey,
|
||||
mutation_step: u64,
|
||||
mutation_order_b64: &str,
|
||||
) -> HeartbeatRequest {
|
||||
let entropy_data = EntropyData {
|
||||
events: vec![
|
||||
MouseEvent {
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
timestamp_ms: 1.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 3.0,
|
||||
y: 1.0,
|
||||
timestamp_ms: 2.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 3.0,
|
||||
y: 1.0,
|
||||
timestamp_ms: 120.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
let stack_state = StackState {
|
||||
stack: vec![9, 10, 11],
|
||||
ip: 2,
|
||||
};
|
||||
|
||||
let order =
|
||||
shared::vm_extensions::decode_order_b64(mutation_step, mutation_order_b64).unwrap();
|
||||
let committed = shared::gene::new_state(init.gene_size as usize).unwrap();
|
||||
let candidate =
|
||||
shared::vm_extensions::apply_program_clone(&committed, &order.program).unwrap();
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
timestamp: crate::storage::current_time_ms(),
|
||||
entropy_data,
|
||||
stack_state,
|
||||
fingerprint: Fingerprint {
|
||||
aspect_ratio: "1.77".to_string(),
|
||||
device_pixel_ratio: "2.0".to_string(),
|
||||
hardware_concurrency: 8,
|
||||
},
|
||||
mutation_step,
|
||||
gene_commitment: shared::gene::commitment_hex(&candidate),
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(sk, &mut req);
|
||||
req
|
||||
}
|
||||
|
||||
async fn setup_state_and_session(
|
||||
config: crate::config::Config,
|
||||
) -> (Arc<AppState>, InitResponse, SigningKey) {
|
||||
let pool = crate::storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let state = Arc::new(AppState {
|
||||
db_pool: pool,
|
||||
rate_limiter: tokio::sync::Mutex::new(crate::ratelimit::RateLimiter::new()),
|
||||
config: std::sync::RwLock::new(config.clone()),
|
||||
});
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = SigningKey::generate(&mut rng);
|
||||
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
|
||||
let conn = state.db_pool.get().unwrap();
|
||||
let init = crate::session::create_session(&conn, &config, &pk_hex).unwrap();
|
||||
(state, init, sk)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_success_returns_next_mutation_fields() {
|
||||
let config = test_config();
|
||||
let (state, init, sk) = setup_state_and_session(config).await;
|
||||
let req = build_request(&init, &sk, init.mutation_step, &init.mutation_order_b64);
|
||||
|
||||
let (status, Json(body)) = handler(State(state), Json(req)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.status, "ok");
|
||||
assert!(body.next_salt.is_some());
|
||||
assert!(body.next_mutation_step.is_some());
|
||||
assert!(body.next_mutation_order_b64.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_tampered_commitment_is_silent_failure() {
|
||||
let config = test_config();
|
||||
let (state, init, sk) = setup_state_and_session(config).await;
|
||||
let mut req = build_request(&init, &sk, init.mutation_step, &init.mutation_order_b64);
|
||||
req.gene_commitment = "00".repeat(32);
|
||||
sign_request(&sk, &mut req);
|
||||
|
||||
let (status, Json(body)) = handler(State(state), Json(req)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.status, "ok");
|
||||
assert!(body.next_salt.is_none());
|
||||
assert!(body.next_mutation_step.is_none());
|
||||
assert!(body.next_mutation_order_b64.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handler_rate_limit_returns_no_mutation_data() {
|
||||
let mut config = test_config();
|
||||
config.rate_limit_count = 0;
|
||||
let (state, init, sk) = setup_state_and_session(config).await;
|
||||
let req = build_request(&init, &sk, init.mutation_step, &init.mutation_order_b64);
|
||||
|
||||
let (status, Json(body)) = handler(State(state), Json(req)).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body.status, "ok");
|
||||
assert!(body.next_salt.is_none());
|
||||
assert!(body.next_mutation_step.is_none());
|
||||
assert!(body.next_mutation_order_b64.is_none());
|
||||
}
|
||||
}
|
||||
+142
-3
@@ -80,18 +80,51 @@ impl TextOutput for KeypairReport {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DbTypeEntry {
|
||||
pub name: &'static str,
|
||||
pub implemented: bool,
|
||||
pub notes: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DbTypeReport {
|
||||
pub default: &'static str,
|
||||
pub backends: Vec<DbTypeEntry>,
|
||||
}
|
||||
|
||||
impl TextOutput for DbTypeReport {
|
||||
fn to_text(&self) -> String {
|
||||
let mut out = format!("default={}\n", self.default);
|
||||
for backend in &self.backends {
|
||||
let status = if backend.implemented {
|
||||
"implemented"
|
||||
} else {
|
||||
"todo"
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"db_type={} status={} notes={}\n",
|
||||
backend.name, status, backend.notes
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl TextOutput for Config {
|
||||
fn to_text(&self) -> String {
|
||||
format!(
|
||||
"bind={}\npid_file={}\ndb_path={}\nfrontend_dir={}\nlog_file={}",
|
||||
"bind={}\ndb_type={}\npid_file={}\ndb_path={}\nfrontend_dir={}\nlog_file={}\ngene_size={}",
|
||||
self.bind,
|
||||
self.db_type.as_str(),
|
||||
self.pid_file.display(),
|
||||
self.db_path.display(),
|
||||
self.frontend_dir.display(),
|
||||
self.log_file
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
self.gene_size
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +141,7 @@ impl TextOutput for StoreStats {
|
||||
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
install_pid_file(&config.pid_file)?;
|
||||
|
||||
let db_pool = storage::init_pool(&config.db_path)?;
|
||||
let db_pool = init_db_pool(&config)?;
|
||||
let state = Arc::new(session::AppState {
|
||||
db_pool,
|
||||
rate_limiter: Mutex::new(RateLimiter::new()),
|
||||
@@ -147,6 +180,40 @@ pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn db_type_report() -> DbTypeReport {
|
||||
DbTypeReport {
|
||||
default: crate::config::DbType::SqliteInMemory.as_str(),
|
||||
backends: vec![
|
||||
DbTypeEntry {
|
||||
name: crate::config::DbType::SqliteInMemory.as_str(),
|
||||
implemented: true,
|
||||
notes: "default runtime backend",
|
||||
},
|
||||
DbTypeEntry {
|
||||
name: crate::config::DbType::SqliteInDisk.as_str(),
|
||||
implemented: true,
|
||||
notes: "persistent SQLite backend (uses --db-path)",
|
||||
},
|
||||
DbTypeEntry {
|
||||
name: crate::config::DbType::Valkey.as_str(),
|
||||
implemented: true,
|
||||
notes: "compatibility mode: falls back to sqlite-in-memory",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn init_db_pool(config: &Config) -> Result<storage::DbPool, Box<dyn std::error::Error>> {
|
||||
match config.db_type {
|
||||
crate::config::DbType::SqliteInMemory => storage::init_pool(Path::new(":memory:")),
|
||||
crate::config::DbType::SqliteInDisk => storage::init_pool(&config.db_path),
|
||||
crate::config::DbType::Valkey => {
|
||||
warn!("db_type=valkey selected; using sqlite-in-memory compatibility mode in v0.6.0");
|
||||
storage::init_pool(Path::new(":memory:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn probe_health(config: &Config) -> HealthReport {
|
||||
if http_get(&config.bind, "/health").is_ok() {
|
||||
HealthReport {
|
||||
@@ -339,3 +406,75 @@ fn http_get(bind: &str, path: &str) -> Result<String, Box<dyn std::error::Error>
|
||||
.ok_or("daemon returned an invalid HTTP response")?;
|
||||
Ok(body.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_config() -> Config {
|
||||
Config {
|
||||
bind: "127.0.0.1:0".to_string(),
|
||||
db_type: crate::config::DbType::SqliteInMemory,
|
||||
pid_file: std::path::PathBuf::from("/tmp/chronoseal-test.pid"),
|
||||
db_path: std::path::PathBuf::from("/tmp/chronoseal-test.sqlite"),
|
||||
frontend_dir: std::path::PathBuf::from("."),
|
||||
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: 1.0,
|
||||
max_mouse_avg_speed: 5.0,
|
||||
min_pause_count: 0,
|
||||
require_mouse_activity: false,
|
||||
gene_size: shared::constants::DEFAULT_GENE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_db_type_report_lists_backends() {
|
||||
let report = db_type_report();
|
||||
assert_eq!(report.default, "sqlite-in-memory");
|
||||
assert_eq!(report.backends.len(), 3);
|
||||
assert!(report.backends.iter().any(|b| b.name == "valkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_db_pool_sqlite_in_memory() {
|
||||
let config = base_config();
|
||||
let pool = init_db_pool(&config).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_db_pool_sqlite_in_disk() {
|
||||
let mut config = base_config();
|
||||
config.db_type = crate::config::DbType::SqliteInDisk;
|
||||
config.db_path = std::path::PathBuf::from("/tmp/chronoseal-db-type-disk.sqlite");
|
||||
let _ = std::fs::remove_file(&config.db_path);
|
||||
let pool = init_db_pool(&config).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_db_pool_valkey_compat_mode() {
|
||||
let mut config = base_config();
|
||||
config.db_type = crate::config::DbType::Valkey;
|
||||
let pool = init_db_pool(&config).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let count: u64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
}
|
||||
+476
-91
@@ -16,7 +16,18 @@ impl AppState {
|
||||
|
||||
use crate::{crypto, fingerprint, storage, trust, vm};
|
||||
use rusqlite::params;
|
||||
use shared::protocol::{HeartbeatRequest, InitResponse};
|
||||
use shared::{
|
||||
gene::{self, GeneState},
|
||||
protocol::{HeartbeatRequest, InitResponse},
|
||||
vm_extensions,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeartbeatVerificationResult {
|
||||
pub next_salt_hex: String,
|
||||
pub next_mutation_step: u64,
|
||||
pub next_mutation_order_b64: String,
|
||||
}
|
||||
|
||||
pub fn create_session(
|
||||
conn: &rusqlite::Connection,
|
||||
@@ -27,22 +38,44 @@ pub fn create_session(
|
||||
if pub_key.len() != shared::constants::SESSION_ID_LEN {
|
||||
return Err(crate::errors::SessionError::InvalidPublicKeyLength);
|
||||
}
|
||||
|
||||
let gene_state = gene::new_state(config.gene_size)
|
||||
.map_err(|err| crate::errors::SessionError::InvalidGeneConfiguration(err.to_string()))?;
|
||||
let environment_blob = gene::encode_environment(&gene_state.environment)
|
||||
.map_err(|err| crate::errors::SessionError::InvalidGeneConfiguration(err.to_string()))?;
|
||||
|
||||
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 + (config.expiration_minutes as u64) * 60 * 1000;
|
||||
|
||||
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_id, public_key, salt, last_hash, created_at, last_seen, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, pub_key, salt.to_vec(), initial_hash, now, now, expires_at],
|
||||
)?;
|
||||
|
||||
let opcodes = vm::generate_random_program(8..=16);
|
||||
let opcodes_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &opcodes);
|
||||
|
||||
let initial_mutation = vm_extensions::generate_order(1, config.gene_size);
|
||||
let initial_mutation_b64 = vm_extensions::encode_order_b64(&initial_mutation);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (
|
||||
session_id, public_key, salt, last_hash, chain_length, created_at, last_seen, expires_at,
|
||||
gene, environment, pending_mutation, pending_mutation_step
|
||||
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
session_id,
|
||||
pub_key,
|
||||
salt.to_vec(),
|
||||
initial_hash,
|
||||
now,
|
||||
now,
|
||||
expires_at,
|
||||
gene_state.gene,
|
||||
environment_blob,
|
||||
initial_mutation.program,
|
||||
initial_mutation.step,
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(InitResponse {
|
||||
session_id,
|
||||
salt: hex::encode(salt),
|
||||
@@ -51,6 +84,9 @@ pub fn create_session(
|
||||
expires_at,
|
||||
heartbeat_min_interval_ms: config.heartbeat_min_interval_ms,
|
||||
heartbeat_max_interval_ms: config.heartbeat_max_interval_ms,
|
||||
gene_size: config.gene_size as u32,
|
||||
mutation_step: initial_mutation.step,
|
||||
mutation_order_b64: initial_mutation_b64,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,13 +94,41 @@ pub fn verify_heartbeat(
|
||||
conn: &rusqlite::Connection,
|
||||
config: &crate::config::Config,
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<String, crate::errors::VerificationError> {
|
||||
) -> Result<HeartbeatVerificationResult, crate::errors::VerificationError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1",
|
||||
"SELECT public_key, salt, last_hash, expires_at, gene, environment, pending_mutation, pending_mutation_step
|
||||
FROM sessions WHERE session_id = ?1",
|
||||
)?;
|
||||
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) = stmt
|
||||
let (
|
||||
pub_key,
|
||||
salt,
|
||||
stored_last_hash,
|
||||
expires_at,
|
||||
gene_blob,
|
||||
environment_blob,
|
||||
pending_mutation,
|
||||
pending_step,
|
||||
): (
|
||||
Vec<u8>,
|
||||
Vec<u8>,
|
||||
Vec<u8>,
|
||||
u64,
|
||||
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)?))
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
row.get(7)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| {
|
||||
if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
|
||||
@@ -84,24 +148,45 @@ pub fn verify_heartbeat(
|
||||
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
|
||||
|
||||
// 2. Check chain continuity
|
||||
if stored_last_hash != hex::decode(&req.prev_hash)? {
|
||||
let prev_hash_bytes = hex::decode(&req.prev_hash)?;
|
||||
if stored_last_hash != prev_hash_bytes {
|
||||
return Err(crate::errors::VerificationError::ChainBroken);
|
||||
}
|
||||
|
||||
// 3. Time window
|
||||
// 3. Mutation step and deterministic mutation parity
|
||||
if req.mutation_step != pending_step {
|
||||
return Err(crate::errors::VerificationError::MutationStepMismatch {
|
||||
expected: pending_step,
|
||||
got: req.mutation_step,
|
||||
});
|
||||
}
|
||||
|
||||
let environment = gene::decode_environment(&environment_blob)
|
||||
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
|
||||
let server_state = GeneState {
|
||||
gene: gene_blob,
|
||||
environment,
|
||||
};
|
||||
let candidate_state = vm_extensions::apply_program_clone(&server_state, &pending_mutation)
|
||||
.map_err(|e| crate::errors::VerificationError::MutationProgram(e.to_string()))?;
|
||||
let expected_gene_commitment = gene::commitment_hex(&candidate_state);
|
||||
if req.gene_commitment != expected_gene_commitment {
|
||||
return Err(crate::errors::VerificationError::MutationCommitmentMismatch);
|
||||
}
|
||||
|
||||
// 4. Time window
|
||||
let diff = (now as i64) - (req.timestamp as i64);
|
||||
if diff.abs() > config.max_timestamp_drift_ms {
|
||||
return Err(crate::errors::VerificationError::TimestampDrift);
|
||||
}
|
||||
|
||||
// 4. Trusted mouse & fingerprint
|
||||
// 5. Trusted mouse & 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)?;
|
||||
// 6. Compute new hash
|
||||
let new_hash = shared::hashing::next_chain_hash(
|
||||
&prev_hash_bytes,
|
||||
req.timestamp,
|
||||
@@ -110,115 +195,415 @@ pub fn verify_heartbeat(
|
||||
&salt,
|
||||
);
|
||||
|
||||
// 6. New salt for client
|
||||
// 7. Prepare next mutation order and salt
|
||||
let next_step = pending_step + 1;
|
||||
let next_mutation = vm_extensions::generate_order(next_step, candidate_state.gene.len());
|
||||
let next_mutation_b64 = vm_extensions::encode_order_b64(&next_mutation);
|
||||
|
||||
let next_salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||
let next_salt_hex = hex::encode(next_salt);
|
||||
let next_environment_blob = gene::encode_environment(&candidate_state.environment)
|
||||
.map_err(|e| crate::errors::VerificationError::GeneState(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_hash=?1, salt=?2, chain_length=chain_length+1, last_seen=?3 WHERE session_id=?4",
|
||||
params![new_hash, next_salt.to_vec(), now, req.session_id],
|
||||
"UPDATE sessions SET
|
||||
last_hash=?1,
|
||||
salt=?2,
|
||||
chain_length=chain_length+1,
|
||||
last_seen=?3,
|
||||
gene=?4,
|
||||
environment=?5,
|
||||
pending_mutation=?6,
|
||||
pending_mutation_step=?7
|
||||
WHERE session_id=?8",
|
||||
params![
|
||||
new_hash,
|
||||
next_salt.to_vec(),
|
||||
now,
|
||||
candidate_state.gene,
|
||||
next_environment_blob,
|
||||
next_mutation.program,
|
||||
next_step,
|
||||
req.session_id
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(next_salt_hex)
|
||||
Ok(HeartbeatVerificationResult {
|
||||
next_salt_hex,
|
||||
next_mutation_step: next_step,
|
||||
next_mutation_order_b64: next_mutation_b64,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, StackState};
|
||||
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, MouseEvent, StackState};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SimulatedClient {
|
||||
signing_key: SigningKey,
|
||||
session_id: String,
|
||||
prev_hash: String,
|
||||
current_salt: String,
|
||||
pending_mutation_step: u64,
|
||||
pending_mutation_order_b64: String,
|
||||
committed_gene_state: GeneState,
|
||||
}
|
||||
|
||||
fn test_config() -> crate::config::Config {
|
||||
crate::config::Config {
|
||||
expiration_minutes: 30,
|
||||
max_timestamp_drift_ms: 30_000,
|
||||
min_mouse_total_dist: 1.0,
|
||||
max_mouse_avg_speed: 4.0,
|
||||
min_pause_count: 0,
|
||||
require_mouse_activity: false,
|
||||
gene_size: 64,
|
||||
..crate::config::Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_entropy() -> EntropyData {
|
||||
EntropyData {
|
||||
events: vec![
|
||||
MouseEvent {
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
timestamp_ms: 1.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 2.0,
|
||||
y: 1.0,
|
||||
timestamp_ms: 2.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 2.0,
|
||||
y: 1.0,
|
||||
timestamp_ms: 120.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn test_stack() -> StackState {
|
||||
StackState {
|
||||
stack: vec![42, 7, 99],
|
||||
ip: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_fingerprint() -> Fingerprint {
|
||||
Fingerprint {
|
||||
aspect_ratio: "1.77".to_string(),
|
||||
device_pixel_ratio: "2.0".to_string(),
|
||||
hardware_concurrency: 8,
|
||||
}
|
||||
}
|
||||
|
||||
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 message = crate::crypto::canonical_signing_message(req).unwrap();
|
||||
let sig = sk.sign(message.as_bytes());
|
||||
req.signature = hex::encode(sig.to_bytes());
|
||||
}
|
||||
|
||||
fn create_test_session(
|
||||
conn: &rusqlite::Connection,
|
||||
config: &crate::config::Config,
|
||||
) -> (InitResponse, SigningKey) {
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = SigningKey::generate(&mut rng);
|
||||
let pk_hex = hex::encode(sk.verifying_key().to_bytes());
|
||||
let init = create_session(conn, config, &pk_hex).unwrap();
|
||||
(init, sk)
|
||||
}
|
||||
|
||||
fn client_from_init(init: &InitResponse, signing_key: SigningKey) -> SimulatedClient {
|
||||
SimulatedClient {
|
||||
signing_key,
|
||||
session_id: init.session_id.clone(),
|
||||
prev_hash: init.initial_hash.clone(),
|
||||
current_salt: init.salt.clone(),
|
||||
pending_mutation_step: init.mutation_step,
|
||||
pending_mutation_order_b64: init.mutation_order_b64.clone(),
|
||||
committed_gene_state: gene::new_state(init.gene_size as usize).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request(
|
||||
client: &SimulatedClient,
|
||||
timestamp: u64,
|
||||
) -> (HeartbeatRequest, GeneState, EntropyData, StackState) {
|
||||
let order = vm_extensions::decode_order_b64(
|
||||
client.pending_mutation_step,
|
||||
&client.pending_mutation_order_b64,
|
||||
)
|
||||
.unwrap();
|
||||
let candidate_state =
|
||||
vm_extensions::apply_program_clone(&client.committed_gene_state, &order.program)
|
||||
.unwrap();
|
||||
let entropy = test_entropy();
|
||||
let stack = test_stack();
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: client.session_id.clone(),
|
||||
prev_hash: client.prev_hash.clone(),
|
||||
timestamp,
|
||||
entropy_data: entropy.clone(),
|
||||
stack_state: stack.clone(),
|
||||
fingerprint: test_fingerprint(),
|
||||
mutation_step: client.pending_mutation_step,
|
||||
gene_commitment: gene::commitment_hex(&candidate_state),
|
||||
signature: String::new(),
|
||||
};
|
||||
sign_request(&client.signing_key, &mut req);
|
||||
(req, candidate_state, entropy, stack)
|
||||
}
|
||||
|
||||
fn apply_successful_response(
|
||||
client: &mut SimulatedClient,
|
||||
req: &HeartbeatRequest,
|
||||
candidate_state: GeneState,
|
||||
entropy: &EntropyData,
|
||||
stack: &StackState,
|
||||
resp: &HeartbeatVerificationResult,
|
||||
) {
|
||||
let salt = hex::decode(&client.current_salt).unwrap();
|
||||
let prev_hash = hex::decode(&req.prev_hash).unwrap();
|
||||
let next_hash =
|
||||
shared::hashing::next_chain_hash(&prev_hash, req.timestamp, entropy, stack, &salt);
|
||||
|
||||
client.prev_hash = hex::encode(next_hash);
|
||||
client.current_salt = resp.next_salt_hex.clone();
|
||||
client.pending_mutation_step = resp.next_mutation_step;
|
||||
client.pending_mutation_order_b64 = resp.next_mutation_order_b64.clone();
|
||||
client.committed_gene_state = candidate_state;
|
||||
}
|
||||
|
||||
fn load_server_gene_state(conn: &rusqlite::Connection, session_id: &str) -> GeneState {
|
||||
let (gene_blob, env_blob): (Vec<u8>, Vec<u8>) = conn
|
||||
.query_row(
|
||||
"SELECT gene, environment FROM sessions WHERE session_id=?1",
|
||||
[session_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
GeneState {
|
||||
gene: gene_blob,
|
||||
environment: gene::decode_environment(&env_blob).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_successful_heartbeat(
|
||||
conn: &rusqlite::Connection,
|
||||
config: &crate::config::Config,
|
||||
client: &mut SimulatedClient,
|
||||
) -> HeartbeatRequest {
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (req, candidate_state, entropy, stack) = build_request(client, timestamp);
|
||||
let result = verify_heartbeat(conn, config, &req).unwrap();
|
||||
apply_successful_response(client, &req, candidate_state, &entropy, &stack, &result);
|
||||
req
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_lifecycle_and_verification() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
|
||||
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()
|
||||
};
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
assert_eq!(init.gene_size, config.gene_size as u32);
|
||||
assert!(!init.mutation_order_b64.is_empty());
|
||||
assert_eq!(init.mutation_step, 1);
|
||||
|
||||
// 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());
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
for _ in 0..5 {
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
}
|
||||
|
||||
// 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);
|
||||
assert_eq!(stats.max_chain_length, 6);
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
#[test]
|
||||
fn test_deterministic_server_client_parity_across_many_heartbeats() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
|
||||
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(),
|
||||
};
|
||||
for _ in 0..12 {
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
let server_state = load_server_gene_state(&conn, &client.session_id);
|
||||
assert_eq!(server_state, client.committed_gene_state);
|
||||
}
|
||||
}
|
||||
|
||||
sign_request(&sk, &mut req);
|
||||
#[test]
|
||||
fn test_replay_attack_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
|
||||
// Verify successful heartbeat
|
||||
let next_salt = verify_heartbeat(&conn, &config, &req).unwrap();
|
||||
assert!(!next_salt.is_empty());
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (req, candidate_state, entropy, stack) = build_request(&client, timestamp);
|
||||
let result = verify_heartbeat(&conn, &config, &req).unwrap();
|
||||
apply_successful_response(
|
||||
&mut client,
|
||||
&req,
|
||||
candidate_state,
|
||||
&entropy,
|
||||
&stack,
|
||||
&result,
|
||||
);
|
||||
|
||||
// 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());
|
||||
let replay = verify_heartbeat(&conn, &config, &req);
|
||||
assert!(matches!(
|
||||
res.unwrap_err(),
|
||||
replay.unwrap_err(),
|
||||
crate::errors::VerificationError::ChainBroken
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mutation_step_mismatch_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let client = client_from_init(&init, signing_key);
|
||||
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (mut req, _, _, _) = build_request(&client, timestamp);
|
||||
req.mutation_step += 1;
|
||||
sign_request(&client.signing_key, &mut req);
|
||||
|
||||
let err = verify_heartbeat(&conn, &config, &req).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::errors::VerificationError::MutationStepMismatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mutation_commitment_tamper_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let client = client_from_init(&init, signing_key);
|
||||
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (mut req, _, _, _) = build_request(&client, timestamp);
|
||||
req.gene_commitment = "00".repeat(32);
|
||||
sign_request(&client.signing_key, &mut req);
|
||||
|
||||
let err = verify_heartbeat(&conn, &config, &req).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::errors::VerificationError::MutationCommitmentMismatch
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_malformed_server_mutation_program_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let client = client_from_init(&init, signing_key);
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sessions SET pending_mutation=?1 WHERE session_id=?2",
|
||||
params![vec![0xFFu8], client.session_id.clone()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (req, _, _, _) = build_request(&client, timestamp);
|
||||
let err = verify_heartbeat(&conn, &config, &req).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::errors::VerificationError::MutationProgram(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_session_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let client = client_from_init(&init, signing_key);
|
||||
|
||||
conn.execute(
|
||||
"UPDATE sessions SET expires_at=?1 WHERE session_id=?2",
|
||||
params![0u64, client.session_id.clone()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (req, _, _, _) = build_request(&client, timestamp);
|
||||
let err = verify_heartbeat(&conn, &config, &req).unwrap_err();
|
||||
assert!(matches!(err, crate::errors::VerificationError::Expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_session_rejects_invalid_public_key_length() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let err = create_session(&conn, &config, "00ff").unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::errors::SessionError::InvalidPublicKeyLength
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stale_mutation_step_after_success_is_rejected() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let config = test_config();
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
|
||||
let timestamp = storage::current_time_ms();
|
||||
let (mut req, _, _, _) = build_request(&client, timestamp);
|
||||
req.mutation_step -= 1;
|
||||
sign_request(&client.signing_key, &mut req);
|
||||
|
||||
let err = verify_heartbeat(&conn, &config, &req).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::errors::VerificationError::MutationStepMismatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repeated_simulation_keeps_server_and_client_commitments_equal() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
let mut config = test_config();
|
||||
config.gene_size = 128;
|
||||
let (init, signing_key) = create_test_session(&conn, &config);
|
||||
let mut client = client_from_init(&init, signing_key);
|
||||
|
||||
for _ in 0..10 {
|
||||
run_successful_heartbeat(&conn, &config, &mut client);
|
||||
let server_state = load_server_gene_state(&conn, &client.session_id);
|
||||
assert_eq!(
|
||||
gene::commitment(&server_state),
|
||||
gene::commitment(&client.committed_gene_state)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -38,9 +38,54 @@ fn init_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
|
||||
chain_length INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
expires_at INTEGER NOT NULL,
|
||||
gene BLOB NOT NULL DEFAULT X'',
|
||||
environment BLOB NOT NULL DEFAULT X'',
|
||||
pending_mutation BLOB NOT NULL DEFAULT X'',
|
||||
pending_mutation_step INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
ensure_column(
|
||||
conn,
|
||||
"gene",
|
||||
"ALTER TABLE sessions ADD COLUMN gene BLOB NOT NULL DEFAULT X''",
|
||||
)?;
|
||||
ensure_column(
|
||||
conn,
|
||||
"environment",
|
||||
"ALTER TABLE sessions ADD COLUMN environment BLOB NOT NULL DEFAULT X''",
|
||||
)?;
|
||||
ensure_column(
|
||||
conn,
|
||||
"pending_mutation",
|
||||
"ALTER TABLE sessions ADD COLUMN pending_mutation BLOB NOT NULL DEFAULT X''",
|
||||
)?;
|
||||
ensure_column(
|
||||
conn,
|
||||
"pending_mutation_step",
|
||||
"ALTER TABLE sessions ADD COLUMN pending_mutation_step INTEGER NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_column(
|
||||
conn: &rusqlite::Connection,
|
||||
column: &str,
|
||||
alter_sql: &str,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pragma_table_info('sessions') WHERE name = ?1
|
||||
)",
|
||||
[column],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
conn.execute_batch(alter_sql)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+53
-3
@@ -1,4 +1,8 @@
|
||||
use rand::Rng;
|
||||
use shared::{
|
||||
gene::GeneState,
|
||||
vm_extensions::{self, ExecutionTrace, MutationError, MutationOrder},
|
||||
};
|
||||
|
||||
pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Vec<u8> {
|
||||
let mut rng = rand::thread_rng();
|
||||
@@ -9,7 +13,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
if depth < 2 {
|
||||
// Not enough operands for any binary op — push a literal.
|
||||
ops.push(0x00);
|
||||
let val = rng.gen::<u32>();
|
||||
let val = rng.r#gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
} else {
|
||||
@@ -18,7 +22,7 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
0x00 => {
|
||||
// PUSH literal
|
||||
ops.push(0x00);
|
||||
let val = rng.gen::<u32>();
|
||||
let val = rng.r#gen::<u32>();
|
||||
ops.extend_from_slice(&val.to_le_bytes());
|
||||
depth += 1;
|
||||
}
|
||||
@@ -43,4 +47,50 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
ops
|
||||
}
|
||||
|
||||
// Server does not need to execute the program; client does.
|
||||
pub fn execute_mutation_program(
|
||||
state: &mut GeneState,
|
||||
program: &[u8],
|
||||
) -> Result<ExecutionTrace, MutationError> {
|
||||
vm_extensions::execute_program(state, program)
|
||||
}
|
||||
|
||||
pub fn execute_mutation_order(
|
||||
state: &mut GeneState,
|
||||
order: &MutationOrder,
|
||||
) -> Result<ExecutionTrace, MutationError> {
|
||||
vm_extensions::execute_program(state, &order.program)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
use shared::gene::{commitment, new_state};
|
||||
|
||||
#[test]
|
||||
fn test_execute_mutation_program_wraps_shared_engine() {
|
||||
let mut state = new_state(8).unwrap();
|
||||
let program = vec![vm_extensions::OP_MUTATE_POINT, 0, 0, 1];
|
||||
let trace = execute_mutation_program(&mut state, &program).unwrap();
|
||||
assert_eq!(state.gene[0], 1);
|
||||
assert_eq!(trace.final_ip, program.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_execute_mutation_order_determinism() {
|
||||
let mut rng_a = rand::rngs::StdRng::seed_from_u64(101);
|
||||
let mut rng_b = rand::rngs::StdRng::seed_from_u64(101);
|
||||
let order_a = vm_extensions::generate_order_with_rng(&mut rng_a, 9, 64);
|
||||
let order_b = vm_extensions::generate_order_with_rng(&mut rng_b, 9, 64);
|
||||
assert_eq!(order_a, order_b);
|
||||
|
||||
let mut state_a = new_state(64).unwrap();
|
||||
let mut state_b = new_state(64).unwrap();
|
||||
let trace_a = execute_mutation_order(&mut state_a, &order_a).unwrap();
|
||||
let trace_b = execute_mutation_order(&mut state_b, &order_b).unwrap();
|
||||
|
||||
assert_eq!(state_a, state_b);
|
||||
assert_eq!(trace_a.final_stack, trace_b.final_stack);
|
||||
assert_eq!(commitment(&state_a), commitment(&state_b));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user