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);
|
||||
|
||||
+19
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(¤t_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
@@ -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(®istry_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, ®istry_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(®istry, &ma.public_hex()).unwrap();
|
||||
|
||||
let mut forged = registry.clone();
|
||||
forged.doc.members[0].id = "mallory".to_string();
|
||||
forged.sig = ma.sign(®istry_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(®istry, &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(®istry, now);
|
||||
assert!(map.is_empty(), "future or expired keys must not authorize");
|
||||
}
|
||||
}
|
||||
+73
-2
@@ -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(¶ms.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(¶ms.member).is_err() {
|
||||
if !valid_key(¶ms.member) {
|
||||
return bad_request("valid member key required");
|
||||
}
|
||||
if verify_signature(
|
||||
¶ms.member,
|
||||
&poll_signing_bytes(¶ms.member, ¶ms.nonce),
|
||||
¶ms.sig,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return unauthorized("invalid poll signature");
|
||||
}
|
||||
{
|
||||
let mut inner = relay.inner.lock().expect("relay lock");
|
||||
match inner.challenges.remove(¶ms.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,
|
||||
|
||||
Reference in New Issue
Block a user