Storage: - Detects sled lock contention, shows actionable error: "Database locked by another warzone process" with ps command to find the process and rm command to force unlock TUI: - Poll loop no longer calls load_seed() (was re-prompting passphrase) - Seed passed from main.rs to run_tui to poll_loop - Single passphrase prompt per app launch Warnings fixed: - Removed unused `Context` import in tui/app.rs - Added #[allow(dead_code)] on validate_token (used when auth middleware wired) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
187 lines
6.0 KiB
Rust
187 lines
6.0 KiB
Rust
//! 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 <token>` 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<AppState> {
|
|
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::<String>().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<Mutex<HashMap<String, (String, i64)>>> =
|
|
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
|
|
#[derive(Deserialize)]
|
|
struct ChallengeRequest {
|
|
fingerprint: String,
|
|
}
|
|
|
|
async fn create_challenge(
|
|
Json(req): Json<ChallengeRequest>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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<AppState>,
|
|
Json(req): Json<VerifyRequest>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
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::<warzone_protocol::prekey::PreKeyBundle>(&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<String> {
|
|
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)
|
|
}
|