diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index ceb1ab5..25d2966 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2619,8 +2619,10 @@ dependencies = [ "anyhow", "axum", "base64", + "bincode", "chrono", "clap", + "ed25519-dalek", "hex", "rand", "serde", diff --git a/warzone/crates/warzone-server/Cargo.toml b/warzone/crates/warzone-server/Cargo.toml index 230197f..c2257f7 100644 --- a/warzone/crates/warzone-server/Cargo.toml +++ b/warzone/crates/warzone-server/Cargo.toml @@ -22,3 +22,5 @@ chrono.workspace = true hex.workspace = true base64.workspace = true rand.workspace = true +ed25519-dalek.workspace = true +bincode.workspace = true diff --git a/warzone/crates/warzone-server/src/db.rs b/warzone/crates/warzone-server/src/db.rs index 425162d..369eb26 100644 --- a/warzone/crates/warzone-server/src/db.rs +++ b/warzone/crates/warzone-server/src/db.rs @@ -5,6 +5,7 @@ pub struct Database { pub messages: sled::Tree, pub groups: sled::Tree, pub aliases: sled::Tree, + pub tokens: sled::Tree, _db: sled::Db, } @@ -15,11 +16,13 @@ impl Database { let messages = db.open_tree("messages")?; let groups = db.open_tree("groups")?; let aliases = db.open_tree("aliases")?; + let tokens = db.open_tree("tokens")?; Ok(Database { keys, messages, groups, aliases, + tokens, _db: db, }) } diff --git a/warzone/crates/warzone-server/src/routes/auth.rs b/warzone/crates/warzone-server/src/routes/auth.rs new file mode 100644 index 0000000..299967f --- /dev/null +++ b/warzone/crates/warzone-server/src/routes/auth.rs @@ -0,0 +1,184 @@ +//! Challenge-response authentication. +//! +//! Flow: +//! 1. Client: POST /v1/auth/challenge { fingerprint } +//! 2. Server: returns { challenge: random_hex, expires_at } +//! 3. Client: POST /v1/auth/verify { fingerprint, challenge, signature } +//! (signature = Ed25519 sign the challenge bytes with identity key) +//! 4. Server: verifies signature against stored public key, returns { token } +//! 5. Client: includes `Authorization: Bearer ` on subsequent requests +//! +//! Token is valid for 7 days. Server renews on activity. + +use std::collections::HashMap; +use std::sync::Mutex; + +use axum::{ + extract::State, + routing::post, + Json, Router, +}; +use serde::Deserialize; + +use crate::errors::AppResult; +use crate::state::AppState; + +/// Token validity: 7 days. +const TOKEN_TTL_SECS: i64 = 7 * 24 * 3600; +/// Challenge validity: 60 seconds. +const CHALLENGE_TTL_SECS: i64 = 60; + +pub fn routes() -> Router { + Router::new() + .route("/auth/challenge", post(create_challenge)) + .route("/auth/verify", post(verify_challenge)) +} + +fn now_ts() -> i64 { + chrono::Utc::now().timestamp() +} + +fn normalize_fp(fp: &str) -> String { + fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() +} + +fn random_hex(len: usize) -> String { + use rand::RngCore; + let mut bytes = vec![0u8; len]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Pending challenges (fingerprint → (challenge_hex, expires_at)). +/// In production this would be in the DB, but for Phase 1 in-memory is fine. +static CHALLENGES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Deserialize)] +struct ChallengeRequest { + fingerprint: String, +} + +async fn create_challenge( + Json(req): Json, +) -> AppResult> { + let fp = normalize_fp(&req.fingerprint); + let challenge = random_hex(32); + let expires_at = now_ts() + CHALLENGE_TTL_SECS; + + CHALLENGES.lock().unwrap().insert(fp.clone(), (challenge.clone(), expires_at)); + + tracing::info!("Challenge issued for {}", fp); + Ok(Json(serde_json::json!({ + "challenge": challenge, + "expires_at": expires_at, + }))) +} + +#[derive(Deserialize)] +struct VerifyRequest { + fingerprint: String, + challenge: String, + signature: String, // hex-encoded Ed25519 signature +} + +async fn verify_challenge( + State(state): State, + Json(req): Json, +) -> AppResult> { + let fp = normalize_fp(&req.fingerprint); + + // Check challenge exists and hasn't expired + let stored = { + let mut challenges = CHALLENGES.lock().unwrap(); + challenges.remove(&fp) + }; + + let (expected_challenge, expires_at) = match stored { + Some(c) => c, + None => return Ok(Json(serde_json::json!({ "error": "no pending challenge" }))), + }; + + if now_ts() > expires_at { + return Ok(Json(serde_json::json!({ "error": "challenge expired" }))); + } + + if req.challenge != expected_challenge { + return Ok(Json(serde_json::json!({ "error": "challenge mismatch" }))); + } + + // Get stored public key bundle to extract Ed25519 verifying key + let bundle_bytes = match state.db.keys.get(fp.as_bytes())? { + Some(b) => b.to_vec(), + None => return Ok(Json(serde_json::json!({ "error": "fingerprint not registered" }))), + }; + + // Try to deserialize as bincode PreKeyBundle (CLI client) + let identity_key = if let Ok(bundle) = bincode::deserialize::(&bundle_bytes) { + bundle.identity_key + } else { + // Web client stores JSON — can't do Ed25519 verify. Accept for now. + // Phase 2: web client uses WASM for proper Ed25519. + let token = random_hex(32); + let token_expires = now_ts() + TOKEN_TTL_SECS; + state.db.tokens.insert( + token.as_bytes(), + serde_json::to_vec(&serde_json::json!({ + "fingerprint": fp, + "expires_at": token_expires, + }))?.as_slice(), + )?; + tracing::info!("Token issued for {} (web client, no sig verify)", fp); + return Ok(Json(serde_json::json!({ + "token": token, + "expires_at": token_expires, + }))); + }; + + // Verify Ed25519 signature + let sig_bytes = hex::decode(&req.signature) + .map_err(|_| anyhow::anyhow!("invalid signature hex"))?; + + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&identity_key) + .map_err(|_| anyhow::anyhow!("invalid identity key"))?; + + let signature = ed25519_dalek::Signature::from_slice(&sig_bytes) + .map_err(|_| anyhow::anyhow!("invalid signature format"))?; + + let challenge_bytes = hex::decode(&req.challenge) + .map_err(|_| anyhow::anyhow!("invalid challenge hex"))?; + + use ed25519_dalek::Verifier; + verifying_key + .verify(&challenge_bytes, &signature) + .map_err(|_| anyhow::anyhow!("signature verification failed"))?; + + // Issue token + let token = random_hex(32); + let token_expires = now_ts() + TOKEN_TTL_SECS; + state.db.tokens.insert( + token.as_bytes(), + serde_json::to_vec(&serde_json::json!({ + "fingerprint": fp, + "expires_at": token_expires, + }))?.as_slice(), + )?; + + tracing::info!("Token issued for {} (Ed25519 verified)", fp); + Ok(Json(serde_json::json!({ + "token": token, + "expires_at": token_expires, + }))) +} + +/// Validate a bearer token. Returns the fingerprint if valid. +pub fn validate_token(db: &sled::Tree, token: &str) -> Option { + let data = db.get(token.as_bytes()).ok()??; + let val: serde_json::Value = serde_json::from_slice(&data).ok()?; + let expires = val.get("expires_at")?.as_i64()?; + if now_ts() > expires { + let _ = db.remove(token.as_bytes()); + return None; + } + val.get("fingerprint")?.as_str().map(String::from) +} diff --git a/warzone/crates/warzone-server/src/routes/keys.rs b/warzone/crates/warzone-server/src/routes/keys.rs index 5ca1c0f..4bbca98 100644 --- a/warzone/crates/warzone-server/src/routes/keys.rs +++ b/warzone/crates/warzone-server/src/routes/keys.rs @@ -10,8 +10,10 @@ use crate::state::AppState; pub fn routes() -> Router { Router::new() .route("/keys/register", post(register_keys)) + .route("/keys/replenish", post(replenish_otpks)) .route("/keys/list", get(list_keys)) .route("/keys/:fingerprint", get(get_bundle)) + .route("/keys/:fingerprint/otpk-count", get(otpk_count)) } /// Debug endpoint: list all registered fingerprints. @@ -89,3 +91,48 @@ async fn get_bundle( } } } + +/// Check how many one-time pre-keys remain for a fingerprint. +async fn otpk_count( + State(state): State, + Path(fingerprint): Path, +) -> Json { + let fp = normalize_fp(&fingerprint); + let prefix = format!("otpk:{}:", fp); + let count = state.db.keys.scan_prefix(prefix.as_bytes()).count(); + Json(serde_json::json!({ "fingerprint": fp, "otpk_count": count })) +} + +#[derive(Deserialize)] +struct ReplenishRequest { + fingerprint: String, + /// One-time pre-keys: list of {id, public_key_hex} + otpks: Vec, +} + +#[derive(Deserialize)] +struct OtpkEntry { + id: u32, + public_key: String, // hex-encoded 32-byte X25519 public key +} + +/// Upload additional one-time pre-keys. +async fn replenish_otpks( + State(state): State, + Json(req): Json, +) -> Json { + let fp = normalize_fp(&req.fingerprint); + let mut stored = 0; + + for otpk in &req.otpks { + let key = format!("otpk:{}:{}", fp, otpk.id); + let _ = state.db.keys.insert(key.as_bytes(), otpk.public_key.as_bytes()); + stored += 1; + } + + let prefix = format!("otpk:{}:", fp); + let total = state.db.keys.scan_prefix(prefix.as_bytes()).count(); + + tracing::info!("Replenished {} OTPKs for {} (total: {})", stored, fp, total); + Json(serde_json::json!({ "ok": true, "stored": stored, "total": total })) +} diff --git a/warzone/crates/warzone-server/src/routes/mod.rs b/warzone/crates/warzone-server/src/routes/mod.rs index cab6779..37f3cf2 100644 --- a/warzone/crates/warzone-server/src/routes/mod.rs +++ b/warzone/crates/warzone-server/src/routes/mod.rs @@ -1,4 +1,5 @@ mod aliases; +pub mod auth; mod groups; mod health; mod keys; @@ -16,6 +17,7 @@ pub fn router() -> Router { .merge(messages::routes()) .merge(groups::routes()) .merge(aliases::routes()) + .merge(auth::routes()) } /// Web UI router (served at root, outside /v1)