Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .env

This file was deleted.

20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ snapshots/*
data/*
cert/*
DynaRust
encryption.key
encryption.key
.env
.env.local
48 changes: 40 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <current_node_address> [join_node_address]", args[0]);
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/network/broadcaster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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://")
Expand Down
130 changes: 73 additions & 57 deletions src/security/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -34,11 +34,14 @@ pub async fn access(
sub_manager: web::Data<SubscriptionManager>,
) -> 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") {
Expand All @@ -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);
Expand All @@ -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"}));
}
};

Expand Down Expand Up @@ -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()
Expand Down
47 changes: 32 additions & 15 deletions src/storage/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -500,21 +500,38 @@ <h3 style="margin-top: 0;">Cluster Statistics</h3>
jsonEditor.value = JSON.stringify(record.value, null, 4);
currentKeyNameDisp.textContent = key;
versionBadge.textContent = `Last Updated: ${new Date(record.timestamp).toLocaleString()}`;
metadataView.innerHTML = `
<div>Owner: ${record.owner}</div>

<div class="vector-clock-wrapper">
<span class="vc-label">Vector Clock:</span>
<div class="vc-badges">
${Object.entries(record.vector_clock).map(([nodeId, tick]) => `
<span class="vc-badge">
<span class="vc-node">${nodeId}</span>
<span class="vc-tick">${tick}</span>
</span>
`).join('')}
</div>
</div>
`;
// 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 `<script>` etc. execute in the admin's browser.
metadataView.replaceChildren();
const ownerDiv = document.createElement('div');
ownerDiv.textContent = `Owner: ${record.owner}`;
metadataView.appendChild(ownerDiv);

const vcWrap = document.createElement('div');
vcWrap.className = 'vector-clock-wrapper';
const vcLabel = document.createElement('span');
vcLabel.className = 'vc-label';
vcLabel.textContent = 'Vector Clock:';
vcWrap.appendChild(vcLabel);
const vcBadges = document.createElement('div');
vcBadges.className = 'vc-badges';
Object.entries(record.vector_clock || {}).forEach(([nodeId, tick]) => {
const badge = document.createElement('span');
badge.className = 'vc-badge';
const nodeSpan = document.createElement('span');
nodeSpan.className = 'vc-node';
nodeSpan.textContent = nodeId;
const tickSpan = document.createElement('span');
tickSpan.className = 'vc-tick';
tickSpan.textContent = String(tick);
badge.appendChild(nodeSpan);
badge.appendChild(tickSpan);
vcBadges.appendChild(badge);
});
vcWrap.appendChild(vcBadges);
metadataView.appendChild(vcWrap);
noRecordSelected.classList.add('hidden');
editorContainer.classList.remove('hidden');
} catch (e) { console.error(e); }
Expand Down
Loading