Server auth (challenge-response) + OTP key replenishment
Authentication:
- POST /v1/auth/challenge {fingerprint} → {challenge, expires_at}
- POST /v1/auth/verify {fingerprint, challenge, signature} → {token}
- Client signs challenge with Ed25519 identity key
- Server verifies against stored public key
- Returns bearer token valid for 7 days
- Web clients get token without sig verify (Phase 2: WASM)
- validate_token() helper for protecting endpoints
OTP Key Replenishment:
- GET /v1/keys/:fp/otpk-count → {otpk_count}
- POST /v1/keys/replenish {fingerprint, otpks: [{id, public_key}]}
- OTPKs stored individually: otpk:<fp>:<id> → public_key
- Returns total count after replenishment
Phase 1 complete:
- [x] Seed-based identity + BIP39
- [x] X3DH + Double Ratchet (forward secrecy)
- [x] Pre-key bundles
- [x] Server (keys, messages, groups, aliases, auth)
- [x] CLI TUI + Web client
- [x] Aliases with TTL + recovery
- [x] Seed encryption (Argon2id + ChaCha20)
- [x] Server auth (challenge-response + tokens)
- [x] OTP key replenishment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
warzone/Cargo.lock
generated
2
warzone/Cargo.lock
generated
@@ -2619,8 +2619,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"bincode",
|
||||
"chrono",
|
||||
"clap",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"rand",
|
||||
"serde",
|
||||
|
||||
@@ -22,3 +22,5 @@ chrono.workspace = true
|
||||
hex.workspace = true
|
||||
base64.workspace = true
|
||||
rand.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
bincode.workspace = true
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
184
warzone/crates/warzone-server/src/routes/auth.rs
Normal file
184
warzone/crates/warzone-server/src/routes/auth.rs
Normal file
@@ -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 <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.
|
||||
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)
|
||||
}
|
||||
@@ -10,8 +10,10 @@ use crate::state::AppState;
|
||||
pub fn routes() -> Router<AppState> {
|
||||
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<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> Json<serde_json::Value> {
|
||||
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<OtpkEntry>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
Json(req): Json<ReplenishRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod aliases;
|
||||
pub mod auth;
|
||||
mod groups;
|
||||
mod health;
|
||||
mod keys;
|
||||
@@ -16,6 +17,7 @@ pub fn router() -> Router<AppState> {
|
||||
.merge(messages::routes())
|
||||
.merge(groups::routes())
|
||||
.merge(aliases::routes())
|
||||
.merge(auth::routes())
|
||||
}
|
||||
|
||||
/// Web UI router (served at root, outside /v1)
|
||||
|
||||
Reference in New Issue
Block a user