Draft 0.5: identity/registry spec; Phase A signed MA registry, mailbox challenge auth, key rotation, skew rejection
This commit is contained in:
+246
-5
@@ -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(®istry_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(®istry_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(®istry_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(®istry_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(®istry_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);
|
||||
|
||||
Reference in New Issue
Block a user