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
+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,