Alias TTL, recovery keys, and reclamation
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) <noreply@anthropic.com>
This commit is contained in:
1
warzone/Cargo.lock
generated
1
warzone/Cargo.lock
generated
@@ -2621,6 +2621,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"hex",
|
"hex",
|
||||||
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sled",
|
"sled",
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ uuid.workspace = true
|
|||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
|||||||
@@ -3,14 +3,21 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::errors::AppResult;
|
use crate::errors::AppResult;
|
||||||
use crate::state::AppState;
|
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<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/alias/register", post(register_alias))
|
.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/resolve/:name", get(resolve_alias))
|
||||||
.route("/alias/list", get(list_aliases))
|
.route("/alias/list", get(list_aliases))
|
||||||
.route("/alias/whois/:fingerprint", get(reverse_lookup))
|
.route("/alias/whois/:fingerprint", get(reverse_lookup))
|
||||||
@@ -31,13 +38,77 @@ fn normalize_alias(name: &str) -> String {
|
|||||||
.collect()
|
.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<AliasRecord> {
|
||||||
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct RegisterRequest {
|
struct RegisterRequest {
|
||||||
alias: String,
|
alias: String,
|
||||||
fingerprint: 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(
|
async fn register_alias(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
@@ -49,29 +120,147 @@ async fn register_alias(
|
|||||||
return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" })));
|
return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if alias is taken by someone else
|
// Check existing record for this alias
|
||||||
if let Some(existing) = state.db.aliases.get(format!("a:{}", alias).as_bytes())? {
|
if let Some(existing) = load_alias_record(&state.db.aliases, &alias) {
|
||||||
let existing_fp = String::from_utf8_lossy(&existing).to_string();
|
if existing.fingerprint == fp {
|
||||||
if existing_fp != 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" })));
|
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)
|
// Remove old alias for this fingerprint (one alias per person)
|
||||||
if let Some(old_alias) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||||
let old = String::from_utf8_lossy(&old_alias).to_string();
|
let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string();
|
||||||
state.db.aliases.remove(format!("a:{}", old).as_bytes())?;
|
if let Some(old_record) = load_alias_record(&state.db.aliases, &old_alias) {
|
||||||
tracing::info!("Removed old alias '{}' for {}", old, fp);
|
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
|
let recovery_key = gen_recovery_key();
|
||||||
state.db.aliases.insert(format!("a:{}", alias).as_bytes(), fp.as_bytes())?;
|
let record = AliasRecord {
|
||||||
state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes())?;
|
alias: alias.clone(),
|
||||||
state.db.aliases.flush()?;
|
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);
|
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<AppState>,
|
||||||
|
Json(req): Json<RecoverRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
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<AppState>,
|
||||||
|
Json(req): Json<RenewRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
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.
|
/// Resolve an alias to a fingerprint.
|
||||||
@@ -81,10 +270,22 @@ async fn resolve_alias(
|
|||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
let alias = normalize_alias(&name);
|
let alias = normalize_alias(&name);
|
||||||
|
|
||||||
match state.db.aliases.get(format!("a:{}", alias).as_bytes())? {
|
match load_alias_record(&state.db.aliases, &alias) {
|
||||||
Some(data) => {
|
Some(record) => {
|
||||||
let fp = String::from_utf8_lossy(&data).to_string();
|
if record.is_expired() {
|
||||||
Ok(Json(serde_json::json!({ "alias": alias, "fingerprint": fp })))
|
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" }))),
|
None => Ok(Json(serde_json::json!({ "error": "alias not found" }))),
|
||||||
}
|
}
|
||||||
@@ -100,8 +301,17 @@ async fn reverse_lookup(
|
|||||||
match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||||
Some(data) => {
|
Some(data) => {
|
||||||
let alias = String::from_utf8_lossy(&data).to_string();
|
let alias = String::from_utf8_lossy(&data).to_string();
|
||||||
|
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 })))
|
Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias })))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
None => Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": null }))),
|
None => Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": null }))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,18 +320,20 @@ async fn reverse_lookup(
|
|||||||
async fn list_aliases(
|
async fn list_aliases(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
let aliases: Vec<serde_json::Value> = state
|
let mut aliases: Vec<serde_json::Value> = Vec::new();
|
||||||
.db
|
|
||||||
.aliases
|
for item in state.db.aliases.scan_prefix(b"rec:") {
|
||||||
.scan_prefix(b"a:")
|
if let Ok((_, data)) = item {
|
||||||
.filter_map(|item| {
|
if let Ok(record) = serde_json::from_slice::<AliasRecord>(&data) {
|
||||||
item.ok().map(|(k, v)| {
|
aliases.push(serde_json::json!({
|
||||||
let alias = String::from_utf8_lossy(&k[2..]).to_string();
|
"alias": record.alias,
|
||||||
let fp = String::from_utf8_lossy(&v).to_string();
|
"fingerprint": record.fingerprint,
|
||||||
serde_json::json!({ "alias": alias, "fingerprint": fp })
|
"expired": record.is_expired(),
|
||||||
})
|
"expires_in_days": record.expires_in_days(),
|
||||||
})
|
}));
|
||||||
.collect();
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() })))
|
Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() })))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user