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:
Siavash Sameni
2026-03-27 07:55:02 +04:00
parent 3ffac0c751
commit cfb227a93d
6 changed files with 240 additions and 0 deletions

View File

@@ -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,
})
}

View 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)
}

View File

@@ -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 }))
}

View File

@@ -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)