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);
+19 -1
View File
@@ -18,8 +18,16 @@ pub struct Config {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeSection {
pub name: String,
#[serde(default)]
pub id: Option<String>,
pub listen: String,
pub relays: Vec<String>,
#[serde(default)]
pub registry: Option<String>,
#[serde(default)]
pub ma_key: Option<String>,
#[serde(default)]
pub dev_bootstrap: bool,
#[serde(default = "default_true")]
pub responder: bool,
}
@@ -33,6 +41,8 @@ pub struct Member {
pub pubkey: String,
#[serde(default = "default_class")]
pub class: String,
#[serde(default)]
pub previous: Vec<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
@@ -117,8 +127,12 @@ impl Config {
Self {
node: NodeSection {
name: name.to_string(),
id: None,
listen: listen.to_string(),
relays: vec![relay.to_string()],
registry: None,
ma_key: None,
dev_bootstrap: false,
responder: true,
},
query: QuerySection::default(),
@@ -164,6 +178,10 @@ impl Config {
self.data_dir().join("members.toml")
}
pub fn registry_cache_path(&self) -> PathBuf {
self.data_dir().join("registry-cache.json")
}
pub fn load_key(&self) -> Result<Keypair> {
let raw = fs::read_to_string(self.key_path())
.with_context(|| format!("reading key {}", self.key_path().display()))?;
@@ -179,7 +197,7 @@ impl Config {
}
}
fn set_private_permissions(path: &Path) -> Result<()> {
pub fn set_private_permissions(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
+24 -16
View File
@@ -97,6 +97,29 @@ fn write_canonical(value: &Value, out: &mut String) {
}
}
pub fn poll_signing_bytes(member: &str, nonce: &str) -> Vec<u8> {
format!("{}\npoll\n{}\n{}", PROTOCOL, member, nonce).into_bytes()
}
pub fn verify_signature(public_hex: &str, message: &[u8], sig_hex: &str) -> Result<()> {
let key_bytes = hex::decode(public_hex).context("public key is not hex")?;
let key_bytes: [u8; 32] = key_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("public key must be 32 bytes"))?;
let verifying_key =
VerifyingKey::from_bytes(&key_bytes).map_err(|e| anyhow!("bad public key: {e}"))?;
let sig_bytes = hex::decode(sig_hex).context("signature is not hex")?;
let sig_bytes: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("signature must be 64 bytes"))?;
let signature = Signature::from_bytes(&sig_bytes);
verifying_key
.verify_strict(message, &signature)
.map_err(|_| anyhow!("signature verification failed"))
}
pub fn signing_bytes(envelope: &Envelope) -> Vec<u8> {
format!(
"{}\n{}\n{}\n{}\n{}\n{}",
@@ -111,22 +134,7 @@ pub fn signing_bytes(envelope: &Envelope) -> Vec<u8> {
}
pub fn verify_envelope(envelope: &Envelope) -> Result<()> {
let key_bytes = hex::decode(&envelope.from).context("from is not hex")?;
let key_bytes: [u8; 32] = key_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("from must be a 32-byte ed25519 public key"))?;
let verifying_key =
VerifyingKey::from_bytes(&key_bytes).map_err(|e| anyhow!("bad public key: {e}"))?;
let sig_bytes = hex::decode(&envelope.sig).context("sig is not hex")?;
let sig_bytes: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("sig must be 64 bytes"))?;
let signature = Signature::from_bytes(&sig_bytes);
verifying_key
.verify_strict(&signing_bytes(envelope), &signature)
.map_err(|_| anyhow!("signature verification failed"))
verify_signature(&envelope.from, &signing_bytes(envelope), &envelope.sig)
}
pub fn now_ts() -> u64 {
+1
View File
@@ -5,6 +5,7 @@ pub mod extract;
pub mod index;
pub mod message;
pub mod node;
pub mod registry;
pub mod relay;
pub mod render;
+105 -2
View File
@@ -33,6 +33,12 @@ enum Command {
data_dir: String,
#[arg(long)]
force: bool,
#[arg(long)]
id: Option<String>,
#[arg(long)]
registry: Option<String>,
#[arg(long)]
ma_key: Option<String>,
},
Add {
path: PathBuf,
@@ -70,6 +76,16 @@ enum Command {
#[command(subcommand)]
command: MemberCommand,
},
Key {
#[command(subcommand)]
command: KeyCommand,
},
Registry {
#[arg(long, default_value = "./frx-registry")]
dir: PathBuf,
#[command(subcommand)]
command: RegistryCommand,
},
Aggregates {
#[arg(long)]
from: Option<String>,
@@ -87,6 +103,8 @@ enum MemberCommand {
pubkey: String,
#[arg(long, default_value = "source")]
class: String,
#[arg(long = "previous")]
previous: Vec<String>,
},
Remove {
name: String,
@@ -94,6 +112,48 @@ enum MemberCommand {
List,
}
#[derive(Subcommand)]
enum KeyCommand {
Show,
Rotate,
}
#[derive(Subcommand)]
enum RegistryCommand {
Init,
Add {
id: String,
pubkey: String,
#[arg(long, default_value = "source")]
class: String,
#[arg(long)]
not_before: Option<u64>,
#[arg(long)]
not_after: Option<u64>,
},
AddKey {
id: String,
pubkey: String,
#[arg(long)]
not_before: Option<u64>,
#[arg(long)]
not_after: Option<u64>,
},
RevokeKey {
id: String,
pubkey: String,
},
Remove {
id: String,
},
List,
Show,
Serve {
#[arg(long, default_value = "127.0.0.1:7800")]
listen: String,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum Exposure {
Metadata,
@@ -110,6 +170,9 @@ async fn main() -> Result<()> {
relay,
data_dir,
force,
id,
registry,
ma_key,
} => {
if cli.config.exists() && !force {
bail!(
@@ -117,7 +180,19 @@ async fn main() -> Result<()> {
cli.config.display()
);
}
let config = Config::new(&name, &listen, &relay, &data_dir);
if registry.is_some() && ma_key.is_none() {
bail!("--ma-key is required with --registry");
}
let mut config = Config::new(&name, &listen, &relay, &data_dir);
config.node.id = id;
config.node.registry = registry;
config.node.ma_key = ma_key;
if config.node.registry.is_none() {
config.node.dev_bootstrap = true;
println!(
"warning: no registry configured; development open bootstrap (any valid key is accepted; do not deploy)"
);
}
let key = Keypair::generate();
config.save_key(&key)?;
config.save(&cli.config)?;
@@ -167,10 +242,38 @@ async fn main() -> Result<()> {
name,
pubkey,
class,
} => commands::member_add(&cli.config, &name, &pubkey, &class)?,
previous,
} => commands::member_add(&cli.config, &name, &pubkey, &class, &previous)?,
MemberCommand::Remove { name } => commands::member_remove(&cli.config, &name)?,
MemberCommand::List => commands::member_list(&cli.config)?,
},
Command::Key { command } => match command {
KeyCommand::Show => commands::key_show(&cli.config)?,
KeyCommand::Rotate => commands::key_rotate(&cli.config)?,
},
Command::Registry { dir, command } => match command {
RegistryCommand::Init => commands::registry_init(&dir)?,
RegistryCommand::Add {
id,
pubkey,
class,
not_before,
not_after,
} => commands::registry_add(&dir, &id, &pubkey, &class, not_before, not_after)?,
RegistryCommand::AddKey {
id,
pubkey,
not_before,
not_after,
} => commands::registry_add_key(&dir, &id, &pubkey, not_before, not_after)?,
RegistryCommand::RevokeKey { id, pubkey } => {
commands::registry_revoke_key(&dir, &id, &pubkey)?
}
RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?,
RegistryCommand::List => commands::registry_list(&dir)?,
RegistryCommand::Show => commands::registry_show(&dir)?,
RegistryCommand::Serve { listen } => commands::registry_serve(&dir, &listen).await?,
},
Command::Aggregates {
from,
period,
+6
View File
@@ -11,6 +11,12 @@ pub const TYPE_AGGREGATE: &str = "aggregate";
pub const EXPOSURE_METADATA: &str = "metadata";
pub const EXPOSURE_FULL: &str = "full";
pub const MAX_CLOCK_SKEW_SECS: u64 = 300;
pub fn timestamp_is_fresh(ts: u64, now: u64) -> bool {
now.abs_diff(ts) <= MAX_CLOCK_SKEW_SECS
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope {
#[serde(rename = "type")]
+166 -10
View File
@@ -17,12 +17,13 @@ use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use crate::config::{CLASS_ENRICHMENT, Config, Member, load_members};
use crate::crypto::Keypair;
use crate::crypto::{Keypair, now_ts, poll_signing_bytes};
use crate::index::{LocalIndex, SearchHit, response_items};
use crate::message::{
AggregateBody, Envelope, QueryBody, ResponseBody, ResponseItem, TYPE_AGGREGATE, TYPE_QUERY,
TYPE_RESPONSE, build_response,
TYPE_RESPONSE, build_response, timestamp_is_fresh,
};
use crate::registry::{SignedRegistry, authorized_keys, load_registry, verify_registry};
#[derive(Default)]
struct Aggregates {
@@ -36,6 +37,8 @@ pub struct Node {
index: LocalIndex,
members: RwLock<Vec<Member>>,
members_mtime: Mutex<Option<SystemTime>>,
registry: Mutex<Option<SignedRegistry>>,
registry_mtime: Mutex<Option<SystemTime>>,
aggregates: Mutex<Aggregates>,
seen: Mutex<HashSet<String>>,
pending: Mutex<HashMap<String, Vec<(String, ResponseBody)>>>,
@@ -119,12 +122,14 @@ impl Node {
.timeout(Duration::from_secs(15))
.build()
.context("building http client")?;
Ok(Arc::new(Self {
let node = Arc::new(Self {
config,
key,
index,
members: RwLock::new(members),
members_mtime: Mutex::new(members_mtime),
registry: Mutex::new(None),
registry_mtime: Mutex::new(None),
aggregates: Mutex::new(Aggregates::default()),
seen: Mutex::new(HashSet::new()),
pending: Mutex::new(HashMap::new()),
@@ -132,7 +137,90 @@ impl Node {
client,
sent: AtomicU64::new(0),
received: AtomicU64::new(0),
}))
});
node.load_initial_registry();
Ok(node)
}
fn load_initial_registry(&self) {
let Some(source) = &self.config.node.registry else {
return;
};
let result = if source.starts_with("http://") || source.starts_with("https://") {
load_registry(&self.config.registry_cache_path())
} else {
let path = std::path::PathBuf::from(source);
let loaded = load_registry(&path);
if loaded.is_ok() {
let mtime = fs::metadata(&path)
.and_then(|metadata| metadata.modified())
.ok();
*self.registry_mtime.lock().expect("registry mtime lock") = mtime;
}
loaded
};
if let Ok(signed) = result {
if let Err(error) = self.verify_and_apply_registry(signed) {
eprintln!("cached registry rejected: {error}");
}
}
}
fn verify_and_apply_registry(&self, signed: SignedRegistry) -> Result<()> {
let ma_key = self
.config
.node
.ma_key
.as_deref()
.ok_or_else(|| anyhow!("registry configured without ma_key"))?;
verify_registry(&signed, ma_key)?;
{
let current = self.registry.lock().expect("registry lock");
if let Some(existing) = current.as_ref() {
if signed.doc.version <= existing.doc.version {
return Err(anyhow!("registry version rollback rejected"));
}
}
}
*self.registry.lock().expect("registry lock") = Some(signed);
Ok(())
}
fn refresh_registry(&self) {
let Some(source) = &self.config.node.registry else {
return;
};
if source.starts_with("http://") || source.starts_with("https://") {
return;
}
let path = std::path::PathBuf::from(source);
let mtime = fs::metadata(&path)
.and_then(|metadata| metadata.modified())
.ok();
{
let last = self.registry_mtime.lock().expect("registry mtime lock");
if *last == mtime {
return;
}
}
let Ok(signed) = load_registry(&path) else {
return;
};
*self.registry_mtime.lock().expect("registry mtime lock") = mtime;
if let Err(error) = self.verify_and_apply_registry(signed) {
eprintln!("registry update rejected: {error}");
}
}
async fn fetch_registry(&self) -> Result<()> {
let Some(url) = &self.config.node.registry else {
return Ok(());
};
let response = self.client.get(url).send().await?;
let signed: SignedRegistry = response.json().await?;
self.verify_and_apply_registry(signed.clone())?;
crate::registry::save_registry(&self.config.registry_cache_path(), &signed)?;
Ok(())
}
fn refresh_members(&self) {
@@ -156,7 +244,9 @@ impl Node {
fn member_class(&self, members: &[Member], pubkey: &str) -> Option<String> {
members
.iter()
.find(|member| member.pubkey == pubkey)
.find(|member| {
member.pubkey == pubkey || member.previous.iter().any(|key| key == pubkey)
})
.map(|member| member.class.clone())
}
@@ -213,6 +303,22 @@ impl Node {
.with_context(|| format!("binding {}", node.config.node.listen))?;
let addr = listener.local_addr()?;
let mut tasks = Vec::new();
if let Some(source) = &node.config.node.registry {
if source.starts_with("http://") || source.starts_with("https://") {
if let Err(error) = node.fetch_registry().await {
eprintln!("initial registry fetch failed: {error}");
}
let node_for_registry = node.clone();
tasks.push(tokio::spawn(async move {
loop {
if let Err(error) = node_for_registry.fetch_registry().await {
eprintln!("registry fetch failed: {error}");
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}));
}
}
for relay in node.config.node.relays.clone() {
tasks.push(tokio::spawn(poll_relay(node.clone(), relay)));
}
@@ -333,11 +439,31 @@ impl Node {
if envelope.verify().is_err() {
return;
}
if !timestamp_is_fresh(envelope.ts, crate::crypto::now_ts()) {
return;
}
self.refresh_members();
let (class, listed) = {
self.refresh_registry();
let (class, listed) = if self.config.node.registry.is_some() {
let registry = self.registry.lock().expect("registry lock");
let authorized = registry
.as_ref()
.map(|signed| authorized_keys(signed, now_ts()));
match authorized {
Some(map) => {
let entry = map.get(&envelope.from);
(entry.map(|(_, class)| class.clone()), entry.is_some())
}
None => (None, false),
}
} else {
let members = self.members.read().expect("members lock");
let class = self.member_class(&members, &envelope.from);
let listed = members.is_empty() || class.is_some();
let listed = if members.is_empty() {
self.config.node.dev_bootstrap
} else {
class.is_some()
};
(class, listed)
};
if !listed {
@@ -504,13 +630,35 @@ impl Node {
}
}
async fn fetch_challenge(node: &Node, base: &str, member: &str) -> Option<String> {
let response = node
.client
.get(format!("{base}/v1/challenge?member={member}"))
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let payload: Value = response.json().await.ok()?;
payload
.get("nonce")
.and_then(Value::as_str)
.map(str::to_string)
}
async fn poll_relay(node: Arc<Node>, relay: String) {
let base = relay.trim_end_matches('/').to_string();
let member = node.key.public_hex();
loop {
let Some(nonce) = fetch_challenge(&node, &base, &member).await else {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
};
let signature = node.key.sign(&poll_signing_bytes(&member, &nonce));
let url = format!(
"{}/v1/poll?member={}&timeout_ms=20000",
base,
node.key.public_hex()
"{}/v1/poll?member={}&nonce={}&sig={}&timeout_ms=20000",
base, member, nonce, signature
);
match node.client.get(&url).send().await {
Ok(response) if response.status() == reqwest::StatusCode::NO_CONTENT => continue,
@@ -582,8 +730,16 @@ async fn local_query(
async fn local_status(State(node): State<Arc<Node>>) -> Response {
let aggregates = node.aggregate_for(&current_period(), None);
let registry_version = node
.registry
.lock()
.expect("registry lock")
.as_ref()
.map(|signed| signed.doc.version);
Json(json!({
"name": node.config.node.name,
"id": node.config.node.id,
"registry_version": registry_version,
"pubkey": node.key.public_hex(),
"listen": node.config.node.listen,
"relays": node.config.node.relays,
+176
View File
@@ -0,0 +1,176 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use crate::PROTOCOL;
#[cfg(test)]
use crate::crypto::now_ts;
use crate::crypto::{Keypair, canonical_json, verify_signature};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyEntry {
pub key: String,
#[serde(default)]
pub not_before: u64,
#[serde(default)]
pub not_after: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryMember {
pub id: String,
#[serde(default)]
pub class: String,
#[serde(default)]
pub keys: Vec<KeyEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryDoc {
pub version: u64,
pub issued_at: u64,
pub ma_key: String,
#[serde(default)]
pub members: Vec<RegistryMember>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedRegistry {
#[serde(flatten)]
pub doc: RegistryDoc,
pub sig: String,
}
pub fn registry_signing_bytes(doc: &RegistryDoc) -> Vec<u8> {
let value = serde_json::to_value(doc).expect("registry doc serializes");
format!("{}\nregistry\n{}", PROTOCOL, canonical_json(&value)).into_bytes()
}
pub fn sign_registry(mut doc: RegistryDoc, key: &Keypair) -> SignedRegistry {
doc.ma_key = key.public_hex();
let sig = key.sign(&registry_signing_bytes(&doc));
SignedRegistry { doc, sig }
}
pub fn verify_registry(signed: &SignedRegistry, ma_key: &str) -> Result<()> {
if signed.doc.ma_key != ma_key {
return Err(anyhow!("registry signed by unexpected MA key"));
}
verify_signature(ma_key, &registry_signing_bytes(&signed.doc), &signed.sig)
}
pub fn load_registry(path: &Path) -> Result<SignedRegistry> {
let raw = fs::read_to_string(path).context("reading registry")?;
serde_json::from_str(&raw).context("parsing registry")
}
pub fn save_registry(path: &Path, signed: &SignedRegistry) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(signed)?)?;
Ok(())
}
pub fn authorized_keys(signed: &SignedRegistry, now: u64) -> HashMap<String, (String, String)> {
let mut authorized = HashMap::new();
for member in &signed.doc.members {
for key in &member.keys {
let valid = now >= key.not_before && key.not_after.map(|end| now < end).unwrap_or(true);
if valid {
authorized
.entry(key.key.clone())
.or_insert((member.id.clone(), member.class.clone()));
}
}
}
authorized
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::Keypair;
fn make_registry(
key: &Keypair,
entries: Vec<(&str, &str, u64, Option<u64>)>,
) -> SignedRegistry {
let members = entries
.into_iter()
.map(|(id, key_hex, not_before, not_after)| RegistryMember {
id: id.to_string(),
class: "source".to_string(),
keys: vec![KeyEntry {
key: key_hex.to_string(),
not_before,
not_after,
}],
})
.collect();
sign_registry(
RegistryDoc {
version: 1,
issued_at: now_ts(),
ma_key: String::new(),
members,
},
key,
)
}
#[test]
fn signed_snapshot_verifies_and_forgery_fails() {
let ma = Keypair::generate();
let member = Keypair::generate();
let registry = make_registry(&ma, vec![("alice", &member.public_hex(), 0, None)]);
verify_registry(&registry, &ma.public_hex()).unwrap();
let mut forged = registry.clone();
forged.doc.members[0].id = "mallory".to_string();
forged.sig = ma.sign(&registry_signing_bytes(&forged.doc));
assert!(verify_registry(&forged, &ma.public_hex()).is_ok());
assert!(
verify_registry(&forged, &Keypair::generate().public_hex()).is_err(),
"wrong MA key must be rejected"
);
}
#[test]
fn tampered_snapshot_fails_verification() {
let ma = Keypair::generate();
let member = Keypair::generate();
let mut registry = make_registry(&ma, vec![("alice", &member.public_hex(), 0, None)]);
registry.doc.version = 99;
assert!(verify_registry(&registry, &ma.public_hex()).is_err());
}
#[test]
fn validity_windows_gate_authorization() {
let ma = Keypair::generate();
let member = Keypair::generate();
let now = now_ts();
let mut registry = make_registry(
&ma,
vec![("future", &member.public_hex(), now + 3600, None)],
);
let mut expired = registry.doc.members.clone();
expired.push(RegistryMember {
id: "expired".to_string(),
class: "source".to_string(),
keys: vec![KeyEntry {
key: Keypair::generate().public_hex(),
not_before: 0,
not_after: Some(now.saturating_sub(1)),
}],
});
registry.doc.members = expired;
let registry = sign_registry(registry.doc, &ma);
let map = authorized_keys(&registry, now);
assert!(map.is_empty(), "future or expired keys must not authorize");
}
}
+73 -2
View File
@@ -13,17 +13,25 @@ use serde::Deserialize;
use serde_json::json;
use tokio::net::TcpListener;
use crate::message::{Envelope, TYPE_AGGREGATE, TYPE_QUERY, TYPE_RESPONSE};
use crate::crypto::{now_ts, poll_signing_bytes, random_nonce, verify_signature};
use crate::message::{Envelope, TYPE_AGGREGATE, TYPE_QUERY, TYPE_RESPONSE, timestamp_is_fresh};
pub const DEFAULT_CAPACITY: usize = 256;
const CHALLENGE_TTL: Duration = Duration::from_secs(120);
#[derive(Default)]
struct MemberQueue {
items: VecDeque<(u64, Envelope)>,
}
struct Challenge {
member: String,
created: Instant,
}
struct Inner {
members: HashMap<String, MemberQueue>,
challenges: HashMap<String, Challenge>,
seq: u64,
}
@@ -37,6 +45,7 @@ impl Relay {
Arc::new(Self {
inner: Mutex::new(Inner {
members: HashMap::new(),
challenges: HashMap::new(),
seq: 0,
}),
capacity,
@@ -79,6 +88,7 @@ impl Relay {
pub fn router(relay: Arc<Relay>) -> Router {
Router::new()
.route("/health", get(health))
.route("/v1/challenge", get(challenge))
.route("/v1/publish", post(publish))
.route("/v1/unicast", post(unicast))
.route("/v1/poll", get(poll))
@@ -97,14 +107,48 @@ pub async fn bind(addr: &str) -> Result<(TcpListener, SocketAddr)> {
Ok((listener, local))
}
fn valid_key(member: &str) -> bool {
matches!(hex::decode(member), Ok(bytes) if bytes.len() == 32)
}
async fn health() -> &'static str {
"ok"
}
#[derive(Deserialize)]
struct ChallengeParams {
member: String,
}
async fn challenge(
State(relay): State<Arc<Relay>>,
Query(params): Query<ChallengeParams>,
) -> Response {
if !valid_key(&params.member) {
return bad_request("valid member key required");
}
let nonce = random_nonce();
let mut inner = relay.inner.lock().expect("relay lock");
inner
.challenges
.retain(|_, challenge| challenge.created.elapsed() < CHALLENGE_TTL);
inner.challenges.insert(
nonce.clone(),
Challenge {
member: params.member,
created: Instant::now(),
},
);
(StatusCode::OK, Json(json!({ "nonce": nonce }))).into_response()
}
async fn publish(State(relay): State<Arc<Relay>>, Json(envelope): Json<Envelope>) -> Response {
if envelope.verify().is_err() {
return bad_request("invalid signature");
}
if !timestamp_is_fresh(envelope.ts, now_ts()) {
return bad_request("stale timestamp");
}
if envelope.msg_type != TYPE_QUERY {
return bad_request("relay carries broadcast queries only");
}
@@ -131,6 +175,9 @@ async fn unicast(
if envelope.verify().is_err() {
return bad_request("invalid signature");
}
if !timestamp_is_fresh(envelope.ts, now_ts()) {
return bad_request("stale timestamp");
}
if envelope.msg_type != TYPE_RESPONSE && envelope.msg_type != TYPE_AGGREGATE {
return bad_request("unicast carries responses and aggregates only");
}
@@ -148,13 +195,33 @@ async fn unicast(
#[derive(Deserialize)]
struct PollParams {
member: String,
nonce: String,
sig: String,
timeout_ms: Option<u64>,
}
async fn poll(State(relay): State<Arc<Relay>>, Query(params): Query<PollParams>) -> Response {
if params.member.is_empty() || hex::decode(&params.member).is_err() {
if !valid_key(&params.member) {
return bad_request("valid member key required");
}
if verify_signature(
&params.member,
&poll_signing_bytes(&params.member, &params.nonce),
&params.sig,
)
.is_err()
{
return unauthorized("invalid poll signature");
}
{
let mut inner = relay.inner.lock().expect("relay lock");
match inner.challenges.remove(&params.nonce) {
Some(challenge)
if challenge.member == params.member
&& challenge.created.elapsed() < CHALLENGE_TTL => {}
_ => return unauthorized("unknown, expired, or reused challenge"),
}
}
let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(25_000).min(60_000));
let deadline = Instant::now() + timeout;
loop {
@@ -181,6 +248,10 @@ fn bad_request(message: &str) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({ "error": message }))).into_response()
}
fn unauthorized(message: &str) -> Response {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": message }))).into_response()
}
fn backpressure(status: StatusCode) -> Response {
(
status,