Draft 0.5: identity/registry spec; Phase A signed MA registry, mailbox challenge auth, key rotation, skew rejection

This commit is contained in:
George Coles
2026-09-15 05:45:02 -04:00
parent fb45cfa8cf
commit 559c260639
18 changed files with 1526 additions and 105 deletions
+246 -5
View File
@@ -1,12 +1,20 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use tokio::net::TcpListener;
use crate::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Config, Member, load_members, save_members};
use crate::crypto::{Keypair, now_ts};
use crate::index::{Collection, LocalIndex, load_collections, save_collections};
use crate::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
use crate::node;
use crate::registry::{self, KeyEntry, RegistryDoc, RegistryMember, SignedRegistry};
use crate::render;
pub fn add(
@@ -92,12 +100,27 @@ pub async fn query(
Ok(())
}
pub fn member_add(config_path: &Path, name: &str, pubkey: &str, class: &str) -> Result<()> {
let config = Config::load(config_path)?;
let bytes = hex::decode(pubkey).context("pubkey must be hex")?;
fn normalize_key(value: &str) -> Result<String> {
let bytes = hex::decode(value).context("pubkey must be hex")?;
if bytes.len() != 32 {
return Err(anyhow!("pubkey must be 32 bytes (64 hex characters)"));
}
Ok(value.to_ascii_lowercase())
}
pub fn member_add(
config_path: &Path,
name: &str,
pubkey: &str,
class: &str,
previous: &[String],
) -> Result<()> {
let config = Config::load(config_path)?;
let pubkey = normalize_key(pubkey)?;
let previous = previous
.iter()
.map(|key| normalize_key(key))
.collect::<Result<Vec<_>>>()?;
let class = if class == CLASS_ENRICHMENT {
CLASS_ENRICHMENT
} else {
@@ -107,8 +130,9 @@ pub fn member_add(config_path: &Path, name: &str, pubkey: &str, class: &str) ->
members.retain(|member| member.name != name && member.pubkey != pubkey);
members.push(Member {
name: name.to_string(),
pubkey: pubkey.to_ascii_lowercase(),
pubkey,
class: class.to_string(),
previous,
});
save_members(&config.members_path(), &members)?;
println!(
@@ -118,6 +142,30 @@ pub fn member_add(config_path: &Path, name: &str, pubkey: &str, class: &str) ->
Ok(())
}
pub fn key_show(config_path: &Path) -> Result<()> {
let config = Config::load(config_path)?;
println!("{}", config.load_key()?.public_hex());
Ok(())
}
pub fn key_rotate(config_path: &Path) -> Result<()> {
let config = Config::load(config_path)?;
let old = config.load_key()?;
let backup = config.key_path().with_extension("hex.bak");
std::fs::copy(config.key_path(), &backup)?;
let new_key = Keypair::generate();
config.save_key(&new_key)?;
println!("old pubkey {}", old.public_hex());
println!("new pubkey {}", new_key.public_hex());
println!("old key saved to {}", backup.display());
println!(
"peers accept the new key via: frxd member add <name> {} --previous {}",
new_key.public_hex(),
old.public_hex()
);
Ok(())
}
pub fn member_remove(config_path: &Path, name: &str) -> Result<()> {
let config = Config::load(config_path)?;
let mut members = load_members(&config.members_path())?;
@@ -184,6 +232,199 @@ pub async fn aggregates(
Ok(())
}
fn registry_key_path(dir: &Path) -> PathBuf {
dir.join("ma-key.hex")
}
fn registry_doc_path(dir: &Path) -> PathBuf {
dir.join("registry.json")
}
fn open_registry(dir: &Path) -> Result<(Keypair, SignedRegistry)> {
let raw = std::fs::read_to_string(registry_key_path(dir))
.with_context(|| format!("reading MA key from {}", registry_key_path(dir).display()))?;
let key = Keypair::from_hex(&raw)?;
let signed = registry::load_registry(&registry_doc_path(dir))
.with_context(|| format!("reading registry from {}", registry_doc_path(dir).display()))?;
Ok((key, signed))
}
fn mutate_registry(dir: &Path, mutate: impl FnOnce(&mut RegistryDoc) -> Result<()>) -> Result<u64> {
let (ma, signed) = open_registry(dir)?;
let mut doc = signed.doc;
mutate(&mut doc)?;
doc.version += 1;
doc.issued_at = now_ts();
let signed = registry::sign_registry(doc, &ma);
registry::save_registry(&registry_doc_path(dir), &signed)?;
Ok(signed.doc.version)
}
pub fn registry_init(dir: &Path) -> Result<()> {
let registry_path = registry_doc_path(dir);
if registry_path.exists() {
return Err(anyhow!(
"registry {} already exists",
registry_path.display()
));
}
std::fs::create_dir_all(dir)?;
let ma = Keypair::generate();
std::fs::write(registry_key_path(dir), ma.to_hex())?;
crate::config::set_private_permissions(&registry_key_path(dir))?;
let doc = RegistryDoc {
version: 1,
issued_at: now_ts(),
ma_key: String::new(),
members: Vec::new(),
};
let signed = registry::sign_registry(doc, &ma);
registry::save_registry(&registry_path, &signed)?;
println!("MA key {}", ma.public_hex());
println!("registry {}", registry_path.display());
Ok(())
}
pub fn registry_add(
dir: &Path,
id: &str,
pubkey: &str,
class: &str,
not_before: Option<u64>,
not_after: Option<u64>,
) -> Result<()> {
let pubkey = normalize_key(pubkey)?;
let class = if class == CLASS_ENRICHMENT {
CLASS_ENRICHMENT
} else {
CLASS_SOURCE
};
mutate_registry(dir, |doc| {
if doc.members.iter().any(|member| member.id == id) {
return Err(anyhow!("member {id} already listed"));
}
doc.members.push(RegistryMember {
id: id.to_string(),
class: class.to_string(),
keys: vec![KeyEntry {
key: pubkey.clone(),
not_before: not_before.unwrap_or_else(now_ts),
not_after,
}],
});
Ok(())
})?;
println!("added {id} ({class})");
Ok(())
}
pub fn registry_add_key(
dir: &Path,
id: &str,
pubkey: &str,
not_before: Option<u64>,
not_after: Option<u64>,
) -> Result<()> {
let pubkey = normalize_key(pubkey)?;
mutate_registry(dir, |doc| {
let Some(member) = doc.members.iter_mut().find(|member| member.id == id) else {
return Err(anyhow!("no member named {id}"));
};
if member.keys.iter().any(|entry| entry.key == pubkey) {
return Err(anyhow!("key already authorized for {id}"));
}
member.keys.push(KeyEntry {
key: pubkey.clone(),
not_before: not_before.unwrap_or_else(now_ts),
not_after,
});
Ok(())
})?;
println!("added key for {id}");
Ok(())
}
pub fn registry_revoke_key(dir: &Path, id: &str, pubkey: &str) -> Result<()> {
let pubkey = normalize_key(pubkey)?;
mutate_registry(dir, |doc| {
let Some(member) = doc.members.iter_mut().find(|member| member.id == id) else {
return Err(anyhow!("no member named {id}"));
};
let before = member.keys.len();
member.keys.retain(|entry| entry.key != pubkey);
if member.keys.len() == before {
return Err(anyhow!("key not authorized for {id}"));
}
Ok(())
})?;
println!("revoked key for {id}");
Ok(())
}
pub fn registry_remove(dir: &Path, id: &str) -> Result<()> {
mutate_registry(dir, |doc| {
let before = doc.members.len();
doc.members.retain(|member| member.id != id);
if doc.members.len() == before {
return Err(anyhow!("no member named {id}"));
}
Ok(())
})?;
println!("removed {id}");
Ok(())
}
pub fn registry_list(dir: &Path) -> Result<()> {
let (_, signed) = open_registry(dir)?;
for member in &signed.doc.members {
let keys = member.keys.len();
println!("{} [{}] ({} key(s))", member.id, member.class, keys);
for entry in &member.keys {
let window = match entry.not_after {
Some(end) => format!("valid {}..{}", entry.not_before, end),
None => format!("valid from {}", entry.not_before),
};
println!(" {} ({window})", entry.key);
}
}
Ok(())
}
pub fn registry_show(dir: &Path) -> Result<()> {
let (_, signed) = open_registry(dir)?;
println!("ma_key {}", signed.doc.ma_key);
println!("version {}", signed.doc.version);
println!("issued_at {}", signed.doc.issued_at);
Ok(())
}
pub async fn registry_serve(dir: &Path, listen: &str) -> Result<()> {
let state = dir.to_path_buf();
let app = Router::new()
.route("/health", get(registry_health))
.route("/registry.json", get(registry_snapshot))
.with_state(state);
let listener = TcpListener::bind(listen).await?;
println!("registry serving on http://{}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
async fn registry_health() -> &'static str {
"ok"
}
async fn registry_snapshot(State(dir): State<PathBuf>) -> Response {
match registry::load_registry(&registry_doc_path(&dir)) {
Ok(signed) => Json(signed).into_response(),
Err(_) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "registry not found" })),
)
.into_response(),
}
}
pub async fn status(config_path: &Path) -> Result<()> {
let config = Config::load(config_path)?;
let base = format!("http://{}", config.node.listen);