diff --git a/.env b/.env deleted file mode 100644 index 3a7d21d..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -JWT_SECRET=123 -CLUSTER_SECRET=please_change -ADMIN_PASSWORD=admin123 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18b994d --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy this file to .env and replace every value below with a strong, random +# secret before starting DynaRust. The server refuses to boot if any of these +# is unset, equal to a known placeholder, or shorter than 16 characters. +# +# Generate strong secrets, e.g.: +# openssl rand -hex 32 + +# Signing key for user / admin JWTs. +JWT_SECRET=replace-me-with-at-least-16-random-bytes + +# Shared secret used to authenticate node-to-node cluster traffic (joins, +# replication, gossip, internal reads/writes). Anyone with this value can +# read or modify every record in the cluster. +CLUSTER_SECRET=replace-me-with-at-least-16-random-bytes + +# Password gating the /admin dashboard. +ADMIN_PASSWORD=replace-me-with-at-least-16-random-bytes + +# Optional: set to "https" to terminate TLS with the certs under ./cert. +# DYNA_MODE=https diff --git a/.gitignore b/.gitignore index 47ac7b3..8a59e99 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ snapshots/* data/* cert/* DynaRust -encryption.key \ No newline at end of file +encryption.key +.env +.env.local \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index a9043b7..ce1da95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,10 +57,43 @@ fn merge_global_store( } } +/// Refuse to boot when a critical secret is missing or set to a value from a +/// known weak / placeholder list. This prevents misconfiguration from turning +/// the cluster auth bypasses (e.g. `X-Internal-Request: default_secret`) into +/// open doors. +fn require_secret(name: &str, forbidden: &[&str]) { + let value = match env::var(name) { + Ok(v) => v, + Err(_) => { + eprintln!( + "fatal: {} environment variable is not set. Refusing to start.", + name + ); + process::exit(1); + } + }; + if forbidden.iter().any(|f| value == *f) { + eprintln!( + "fatal: {} is set to a known-insecure default value. Refusing to start.", + name + ); + process::exit(1); + } + if value.len() < 16 { + eprintln!( + "fatal: {} is too short (minimum 16 characters). Refusing to start.", + name + ); + process::exit(1); + } +} + #[actix_web::main] async fn main() -> std::io::Result<()> { - dotenvy::dotenv().unwrap(); + // dotenv is optional; in production credentials should come from the + // environment / a secret manager rather than a committed .env file. + let _ = dotenvy::dotenv(); let args: Vec = env::args().collect(); if args.len() < 2 { eprintln!("Usage: {} [join_node_address]", args[0]); @@ -69,13 +102,12 @@ async fn main() -> std::io::Result<()> { let current_node = args[1].clone(); // Use the current node address for binding. let bind_addr = current_node.clone(); - match env::var("JWT_SECRET") { - Ok(secret) => secret, - Err(_) => { - eprintln!("JWT_SECRET environment variable not set"); - std::process::exit(1); - } - }; + + // Refuse to boot with missing or known-insecure secrets. Falling back to + // hard-coded defaults silently turns the cluster into an open door. + require_secret("JWT_SECRET", &["", "default_jwt_secret", "123", "changeme", "secret"]); + require_secret("CLUSTER_SECRET", &["", "default_secret", "please_change", "changeme"]); + require_secret("ADMIN_PASSWORD", &["", "admin", "admin123", "password", "changeme"]); // Initialize the local in‑memory multi‑table key–value store. let state = web::Data::new(AppState { diff --git a/src/network/broadcaster.rs b/src/network/broadcaster.rs index 1eb1350..c154e88 100644 --- a/src/network/broadcaster.rs +++ b/src/network/broadcaster.rs @@ -35,7 +35,7 @@ fn get_scheme() -> &'static str { } /// Strip any existing scheme/trailing slash from `node` and build a full URL. -fn build_url(node: &str, endpoint: &str) -> String { +pub fn build_url(node: &str, endpoint: &str) -> String { let scheme = get_scheme(); let host = node .trim_start_matches("http://") diff --git a/src/security/authentication.rs b/src/security/authentication.rs index 6186805..b7dd173 100644 --- a/src/security/authentication.rs +++ b/src/security/authentication.rs @@ -5,10 +5,10 @@ use sha2::{Digest, Sha256}; use hex; use chrono::{Utc, Duration}; use std::collections::HashMap; -use std::env; use jsonwebtoken::{encode, Header, EncodingKey}; -use crate::storage::engine::{get_active_nodes, get_jwt_secret, get_replication_nodes, AppState, ClusterData, VersionedValue}; +use crate::network::broadcaster::build_url; +use crate::storage::engine::{get_active_nodes, get_cluster_secret, get_jwt_secret, get_replication_nodes, is_safe_name, AppState, ClusterData, VersionedValue}; use crate::storage::subscription::{KeyEvent, SubscriptionManager}; /// Incoming JSON payload. @@ -34,11 +34,14 @@ pub async fn access( sub_manager: web::Data, ) -> impl Responder { let username = user.into_inner(); + if !is_safe_name(&username) { + return HttpResponse::BadRequest().json(json!({"error": "Invalid username"})); + } println!("Access request for user: {}", username); - let cluster_secret = env::var("CLUSTER_SECRET").unwrap_or_else(|_| "default_secret".to_string()); + let cluster_secret = get_cluster_secret(); // Internal replication requests - verify secret matching if let Some(internal_req) = req.headers().get("X-Internal-Request") { @@ -57,11 +60,9 @@ pub async fn access( let mut value_map = HashMap::new(); value_map.insert("secret".to_string(), json!(provided_secret)); - let user_header = req.headers().get("User") - .and_then(|h| h.to_str().ok()) - .unwrap_or(&username); - - let new_rec = VersionedValue::new(value_map, String::from(user_header), current_addr.get_ref().clone()); + // Trust the path-derived username as the owner; the User header is + // attacker-controlled and was previously used to forge ownership. + let new_rec = VersionedValue::new(value_map, username.clone(), current_addr.get_ref().clone()); auth_table.insert(username.clone(), new_rec.clone()); println!("Successfully replicated user: {}", username); @@ -81,53 +82,72 @@ pub async fn access( Ok(t) => t.secret, Err(_) => return HttpResponse::BadRequest().finish(), }; - let is_new_user; - let new_value = { + // Hash the provided secret once, salted with the username, so identical + // passwords hash to different values across users (rainbow-table defense). + let mut hasher = Sha256::new(); + hasher.update(username.as_bytes()); + hasher.update(b":"); + hasher.update(provided_secret.as_bytes()); + let provided_hash = hex::encode(hasher.finalize()); + + enum AuthOutcome { + New(VersionedValue), + Existing(VersionedValue), + Mismatch, + Corrupt, + } + + let outcome = { let auth_table = state.store.entry("auth".to_string()).or_default(); + let mut value_map = HashMap::new(); + value_map.insert("secret".to_string(), json!(provided_hash)); + let candidate = VersionedValue::new( + value_map, + username.clone(), + current_addr.get_ref().clone(), + ); + + // Atomically register-or-lookup so two concurrent registrations of the + // same username cannot race past each other. + let mut outcome = AuthOutcome::Corrupt; + auth_table + .entry(username.clone()) + .and_modify(|existing| { + match existing.value.get("secret").and_then(|v| v.as_str()) { + Some(stored_hash) if stored_hash == provided_hash => { + outcome = AuthOutcome::Existing(existing.clone()); + } + Some(_) => { + outcome = AuthOutcome::Mismatch; + } + None => { + outcome = AuthOutcome::Corrupt; + } + } + }) + .or_insert_with(|| { + outcome = AuthOutcome::New(candidate.clone()); + candidate + }); + outcome + }; - let record = auth_table.get(&username); - if record.is_none() { + let (is_new_user, new_value) = match outcome { + AuthOutcome::New(v) => { println!("New user registration: {}", username); - // User registration - let mut hasher = Sha256::new(); - hasher.update(&provided_secret); - let hashed = hex::encode(hasher.finalize()); - - let mut value_map = HashMap::new(); - value_map.insert("secret".to_string(), json!(hashed)); - - let new_rec = VersionedValue::new(value_map, username.clone(), current_addr.get_ref().clone()); - auth_table.insert(username.clone(), new_rec.clone()); - is_new_user = true; - new_rec - } else { + (true, v) + } + AuthOutcome::Existing(v) => { println!("Login attempt for existing user: {}", username); - // Login attempt - let user_record = record.unwrap(); - - let stored_hash = match user_record - .value - .get("secret") - .and_then(|v| v.as_str()) - { - Some(h) => h, - None => { - return HttpResponse::InternalServerError() - .json(json!({"error":"Corrupt user record"})); - } - }; - - let mut hasher = Sha256::new(); - hasher.update(&provided_secret); - let provided_hash = hex::encode(hasher.finalize()); - - if stored_hash != provided_hash { - return HttpResponse::Unauthorized() - .json(json!({"error":"Invalid credentials"})); - } - - is_new_user = false; - user_record.clone() + (false, v) + } + AuthOutcome::Mismatch => { + return HttpResponse::Unauthorized() + .json(json!({"error":"Invalid credentials"})); + } + AuthOutcome::Corrupt => { + return HttpResponse::InternalServerError() + .json(json!({"error":"Corrupt user record"})); } }; @@ -157,24 +177,20 @@ pub async fn access( for target in targets { if target != *current_addr.get_ref() { - // Make sure URL format matches your API route configuration - let url = format!("http://{}/auth/{}", target, username); + let url = build_url(&target, &format!("auth/{}", username)); println!("Preparing replication to: {}", url); let client_clone = client.clone(); - let username_clone = username.clone(); let secret_clone = hashed_secret.to_string(); let fut = async move { println!("Sending replication request to: {}", url); - // Create the payload with the same structure as AccessToken let payload = AccessToken { secret: secret_clone }; - let cluster_secret = env::var("CLUSTER_SECRET").unwrap_or_else(|_| "default_secret".to_string()); + let cluster_secret = get_cluster_secret(); let result = client_clone .post(&url) .header("X-Internal-Request", cluster_secret) - .header("User", &username_clone) .header("Content-Type", "application/octet-stream") .body(bincode::serialize(&payload).unwrap()) .send() diff --git a/src/storage/admin.html b/src/storage/admin.html index b154d68..6611dd4 100644 --- a/src/storage/admin.html +++ b/src/storage/admin.html @@ -500,21 +500,38 @@

Cluster Statistics

jsonEditor.value = JSON.stringify(record.value, null, 4); currentKeyNameDisp.textContent = key; versionBadge.textContent = `Last Updated: ${new Date(record.timestamp).toLocaleString()}`; - metadataView.innerHTML = ` -
Owner: ${record.owner}
- -
- Vector Clock: -
- ${Object.entries(record.vector_clock).map(([nodeId, tick]) => ` - - ${nodeId} - ${tick} - - `).join('')} -
-
- `; + // Build the metadata view via DOM APIs so that user-controlled + // strings (owner, vector_clock keys) cannot be interpreted as + // HTML. Previously this used innerHTML, which let any value + // containing `