From bf67566b0c6e1f289a9178190b267bde65ba8fce Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Fri, 27 Mar 2026 07:18:10 +0400 Subject: [PATCH] Alias TTL, recovery keys, and reclamation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aliases now have a lifecycle: - 365-day TTL from last activity (send/receive/renew) - 30-day grace period after expiry (only recovery key can reclaim) - After grace: anyone can register the alias - Recovery key generated on first registration, rotated on recovery - Auto-renew on activity via POST /v1/alias/renew New endpoints: - POST /v1/alias/recover {alias, recovery_key, new_fingerprint} Reclaim alias with recovery key, even if expired. Works across identity changes (new seed → new fingerprint, same alias). Recovery key is rotated on each recovery. - POST /v1/alias/renew {fingerprint} Heartbeat — resets TTL. Returns days until expiry. Resolve now returns expiry info: - GET /v1/alias/resolve/:name → includes expires_in_days, expired flag - GET /v1/alias/list → includes expiry status per alias Phase 2: DNS automation — separate DNS authority manages parent zone, servers update delegated records via API. Recovery key maps to DNS record ownership for out-of-band reclamation. Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 1 + warzone/crates/warzone-server/Cargo.toml | 1 + .../warzone-server/src/routes/aliases.rs | 278 +++++++++++++++--- 3 files changed, 247 insertions(+), 33 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index d27f6be..37d1ae0 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2621,6 +2621,7 @@ dependencies = [ "chrono", "clap", "hex", + "rand", "serde", "serde_json", "sled", diff --git a/warzone/crates/warzone-server/Cargo.toml b/warzone/crates/warzone-server/Cargo.toml index f282b18..230197f 100644 --- a/warzone/crates/warzone-server/Cargo.toml +++ b/warzone/crates/warzone-server/Cargo.toml @@ -21,3 +21,4 @@ uuid.workspace = true chrono.workspace = true hex.workspace = true base64.workspace = true +rand.workspace = true diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs index 618d060..37deb4d 100644 --- a/warzone/crates/warzone-server/src/routes/aliases.rs +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -3,14 +3,21 @@ use axum::{ routing::{get, post}, Json, Router, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::errors::AppResult; use crate::state::AppState; +/// Alias expires after 365 days of inactivity. +const ALIAS_TTL_SECS: i64 = 365 * 24 * 3600; +/// Grace period after expiry: 30 days before someone else can claim. +const GRACE_PERIOD_SECS: i64 = 30 * 24 * 3600; + pub fn routes() -> Router { Router::new() .route("/alias/register", post(register_alias)) + .route("/alias/recover", post(recover_alias)) + .route("/alias/renew", post(renew_alias)) .route("/alias/resolve/:name", get(resolve_alias)) .route("/alias/list", get(list_aliases)) .route("/alias/whois/:fingerprint", get(reverse_lookup)) @@ -31,13 +38,77 @@ fn normalize_alias(name: &str) -> String { .collect() } +fn now_ts() -> i64 { + chrono::Utc::now().timestamp() +} + +fn gen_recovery_key() -> String { + use rand::RngCore; + let mut bytes = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Stored record for an alias. +#[derive(Serialize, Deserialize, Clone)] +struct AliasRecord { + alias: String, + fingerprint: String, + recovery_key: String, + registered_at: i64, + last_active: i64, +} + +impl AliasRecord { + fn is_expired(&self) -> bool { + now_ts() - self.last_active > ALIAS_TTL_SECS + } + + fn is_past_grace(&self) -> bool { + now_ts() - self.last_active > ALIAS_TTL_SECS + GRACE_PERIOD_SECS + } + + fn expires_in_days(&self) -> i64 { + let remaining = (self.last_active + ALIAS_TTL_SECS) - now_ts(); + remaining / 86400 + } +} + +fn load_alias_record(db: &sled::Tree, alias: &str) -> Option { + db.get(format!("rec:{}", alias).as_bytes()) + .ok() + .flatten() + .and_then(|data| serde_json::from_slice(&data).ok()) +} + +fn save_alias_record(db: &sled::Tree, record: &AliasRecord) -> anyhow::Result<()> { + let data = serde_json::to_vec(record)?; + db.insert(format!("rec:{}", record.alias).as_bytes(), data)?; + // Forward + reverse index + db.insert(format!("a:{}", record.alias).as_bytes(), record.fingerprint.as_bytes())?; + db.insert(format!("fp:{}", record.fingerprint).as_bytes(), record.alias.as_bytes())?; + db.flush()?; + Ok(()) +} + +fn delete_alias_record(db: &sled::Tree, record: &AliasRecord) -> anyhow::Result<()> { + db.remove(format!("rec:{}", record.alias).as_bytes())?; + db.remove(format!("a:{}", record.alias).as_bytes())?; + db.remove(format!("fp:{}", record.fingerprint).as_bytes())?; + db.flush()?; + Ok(()) +} + #[derive(Deserialize)] struct RegisterRequest { alias: String, fingerprint: String, } -/// Register an alias. One alias per fingerprint, one fingerprint per alias. +/// Register an alias. Returns a recovery key on first registration. +/// - One alias per fingerprint +/// - Expired aliases (past grace period) can be reclaimed by anyone +/// - Expired aliases (within grace period) can only be reclaimed by recovery key async fn register_alias( State(state): State, Json(req): Json, @@ -49,29 +120,147 @@ async fn register_alias( return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" }))); } - // Check if alias is taken by someone else - if let Some(existing) = state.db.aliases.get(format!("a:{}", alias).as_bytes())? { - let existing_fp = String::from_utf8_lossy(&existing).to_string(); - if existing_fp != fp { + // Check existing record for this alias + if let Some(existing) = load_alias_record(&state.db.aliases, &alias) { + if existing.fingerprint == fp { + // Same person — renew + let mut updated = existing; + updated.last_active = now_ts(); + save_alias_record(&state.db.aliases, &updated)?; + return Ok(Json(serde_json::json!({ + "ok": true, "alias": alias, "fingerprint": fp, + "renewed": true, "expires_in_days": updated.expires_in_days() + }))); + } + + if !existing.is_past_grace() { + // Still active or in grace period — can't take it + if existing.is_expired() { + return Ok(Json(serde_json::json!({ + "error": "alias expired but in grace period — use recovery key or wait", + "grace_ends_in_days": (existing.last_active + ALIAS_TTL_SECS + GRACE_PERIOD_SECS - now_ts()) / 86400 + }))); + } return Ok(Json(serde_json::json!({ "error": "alias already taken" }))); } - // Same person re-registering — ok + + // Past grace period — clean up old record + tracing::info!("Alias '{}' expired past grace, releasing from {}", alias, existing.fingerprint); + delete_alias_record(&state.db.aliases, &existing)?; } // Remove old alias for this fingerprint (one alias per person) - if let Some(old_alias) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { - let old = String::from_utf8_lossy(&old_alias).to_string(); - state.db.aliases.remove(format!("a:{}", old).as_bytes())?; - tracing::info!("Removed old alias '{}' for {}", old, fp); + if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { + let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string(); + if let Some(old_record) = load_alias_record(&state.db.aliases, &old_alias) { + delete_alias_record(&state.db.aliases, &old_record)?; + tracing::info!("Removed old alias '{}' for {}", old_alias, fp); + } } - // Store both directions: alias→fp and fp→alias - state.db.aliases.insert(format!("a:{}", alias).as_bytes(), fp.as_bytes())?; - state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes())?; - state.db.aliases.flush()?; + let recovery_key = gen_recovery_key(); + let record = AliasRecord { + alias: alias.clone(), + fingerprint: fp.clone(), + recovery_key: recovery_key.clone(), + registered_at: now_ts(), + last_active: now_ts(), + }; + save_alias_record(&state.db.aliases, &record)?; tracing::info!("Alias '{}' registered for {}", alias, fp); - Ok(Json(serde_json::json!({ "ok": true, "alias": alias, "fingerprint": fp }))) + Ok(Json(serde_json::json!({ + "ok": true, + "alias": alias, + "fingerprint": fp, + "recovery_key": recovery_key, + "expires_in_days": record.expires_in_days(), + "IMPORTANT": "Save your recovery key! It's the only way to reclaim this alias if you lose access." + }))) +} + +#[derive(Deserialize)] +struct RecoverRequest { + alias: String, + recovery_key: String, + new_fingerprint: String, +} + +/// Recover an alias using the recovery key. Works even if expired (within or past grace). +async fn recover_alias( + State(state): State, + Json(req): Json, +) -> AppResult> { + let alias = normalize_alias(&req.alias); + let new_fp = normalize_fp(&req.new_fingerprint); + + let record = match load_alias_record(&state.db.aliases, &alias) { + Some(r) => r, + None => return Ok(Json(serde_json::json!({ "error": "alias not found" }))), + }; + + if record.recovery_key != req.recovery_key { + tracing::warn!("Failed recovery attempt for alias '{}'", alias); + return Ok(Json(serde_json::json!({ "error": "invalid recovery key" }))); + } + + // Delete old mappings + delete_alias_record(&state.db.aliases, &record)?; + + // Remove any existing alias for the new fingerprint + if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", new_fp).as_bytes())? { + let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string(); + if let Some(old_record) = load_alias_record(&state.db.aliases, &old_alias) { + delete_alias_record(&state.db.aliases, &old_record)?; + } + } + + let new_recovery_key = gen_recovery_key(); + let new_record = AliasRecord { + alias: alias.clone(), + fingerprint: new_fp.clone(), + recovery_key: new_recovery_key.clone(), + registered_at: now_ts(), + last_active: now_ts(), + }; + save_alias_record(&state.db.aliases, &new_record)?; + + tracing::info!("Alias '{}' recovered and transferred to {}", alias, new_fp); + Ok(Json(serde_json::json!({ + "ok": true, + "alias": alias, + "fingerprint": new_fp, + "new_recovery_key": new_recovery_key, + "IMPORTANT": "Your recovery key has been rotated. Save the new one!" + }))) +} + +#[derive(Deserialize)] +struct RenewRequest { + fingerprint: String, +} + +/// Renew/heartbeat — resets the TTL. Called automatically on activity. +async fn renew_alias( + State(state): State, + Json(req): Json, +) -> AppResult> { + let fp = normalize_fp(&req.fingerprint); + + let alias = match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { + Some(data) => String::from_utf8_lossy(&data).to_string(), + None => return Ok(Json(serde_json::json!({ "alias": null }))), + }; + + if let Some(mut record) = load_alias_record(&state.db.aliases, &alias) { + record.last_active = now_ts(); + save_alias_record(&state.db.aliases, &record)?; + return Ok(Json(serde_json::json!({ + "ok": true, "alias": alias, "expires_in_days": record.expires_in_days() + }))); + } + + Ok(Json(serde_json::json!({ "alias": null }))) } /// Resolve an alias to a fingerprint. @@ -81,10 +270,22 @@ async fn resolve_alias( ) -> AppResult> { let alias = normalize_alias(&name); - match state.db.aliases.get(format!("a:{}", alias).as_bytes())? { - Some(data) => { - let fp = String::from_utf8_lossy(&data).to_string(); - Ok(Json(serde_json::json!({ "alias": alias, "fingerprint": fp }))) + match load_alias_record(&state.db.aliases, &alias) { + Some(record) => { + if record.is_expired() { + Ok(Json(serde_json::json!({ + "alias": alias, + "fingerprint": record.fingerprint, + "expired": true, + "warning": "this alias is expired and may be reclaimed" + }))) + } else { + Ok(Json(serde_json::json!({ + "alias": alias, + "fingerprint": record.fingerprint, + "expires_in_days": record.expires_in_days() + }))) + } } None => Ok(Json(serde_json::json!({ "error": "alias not found" }))), } @@ -100,7 +301,16 @@ async fn reverse_lookup( match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { Some(data) => { let alias = String::from_utf8_lossy(&data).to_string(); - Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias }))) + if let Some(record) = load_alias_record(&state.db.aliases, &alias) { + Ok(Json(serde_json::json!({ + "fingerprint": fp, + "alias": alias, + "expired": record.is_expired(), + "expires_in_days": record.expires_in_days() + }))) + } else { + Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias }))) + } } None => Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": null }))), } @@ -110,18 +320,20 @@ async fn reverse_lookup( async fn list_aliases( State(state): State, ) -> AppResult> { - let aliases: Vec = state - .db - .aliases - .scan_prefix(b"a:") - .filter_map(|item| { - item.ok().map(|(k, v)| { - let alias = String::from_utf8_lossy(&k[2..]).to_string(); - let fp = String::from_utf8_lossy(&v).to_string(); - serde_json::json!({ "alias": alias, "fingerprint": fp }) - }) - }) - .collect(); + let mut aliases: Vec = Vec::new(); + + for item in state.db.aliases.scan_prefix(b"rec:") { + if let Ok((_, data)) = item { + if let Ok(record) = serde_json::from_slice::(&data) { + aliases.push(serde_json::json!({ + "alias": record.alias, + "fingerprint": record.fingerprint, + "expired": record.is_expired(), + "expires_in_days": record.expires_in_days(), + })); + } + } + } Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() }))) }