//! 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. /// Used by protected endpoints (will be wired in when auth middleware is added). #[allow(dead_code)] 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) }