From 782c9bbbbd52e14b527e5f5057b10e414b76c8b4 Mon Sep 17 00:00:00 2001 From: Jeremy Carter Date: Sat, 19 Sep 2026 13:41:16 -0400 Subject: [PATCH 1/4] Harden authenticated gossip leases AI collaborator: GPT-5.6 Terra --- crates/binda-core/src/api.rs | 43 ++++++++- crates/binda-core/src/collision.rs | 50 +++++++--- crates/binda-core/src/gossip.rs | 127 +++++++++++++++++++++---- crates/binda-core/src/store.rs | 143 +++++++++++++++++++---------- crates/binda/src/node.rs | 83 ++++++++++++++--- 5 files changed, 353 insertions(+), 93 deletions(-) diff --git a/crates/binda-core/src/api.rs b/crates/binda-core/src/api.rs index 0fb88ea..cbd6254 100644 --- a/crates/binda-core/src/api.rs +++ b/crates/binda-core/src/api.rs @@ -5,6 +5,7 @@ use crate::client_api::{ authenticate, probe_message, register_message, set_records_message, ClientRequest, ClientResponse, ProbeBody, RegisterBody, SetRecordsBody, SignedEnvelope, }; +use crate::gossip::RegistrationRumor; use crate::liveness::TimeSource; use crate::store::RegistryStore; @@ -41,7 +42,17 @@ fn handle_probe( let message = probe_message(envelope.timestamp_millis); match authenticate(&envelope, &message, now) { Ok(identity) => { - store.probe(&identity, time); + store.probe_with_evidence( + &identity, + time, + envelope.timestamp_millis, + envelope.signature.clone(), + ); + store.refresh_probe_evidence( + &identity.key(), + envelope.timestamp_millis, + envelope.signature, + ); ClientResponse::ProbeAck } Err(err) => ClientResponse::Error { @@ -65,8 +76,28 @@ fn handle_register( message: "claimed rdns hostname does not forward-confirm against the request's source address".to_string(), }; } - match store.register(envelope.body.domain, &identity, time) { - Ok(token) => ClientResponse::Registered { token }, + match store.register(envelope.body.domain.clone(), &identity, time) { + Ok(token) => { + let (probe_timestamp_millis, probe_signature) = store + .last_probe_evidence(&identity) + .expect("live registrations have a signed probe"); + let rumor = RegistrationRumor { + domain: envelope.body.domain, + token, + client_key: identity.key(), + owner_key: envelope.verifying_key, + rdns: envelope.rdns, + registration_timestamp_millis: envelope.timestamp_millis, + registration_signature: envelope.signature.clone(), + probe_timestamp_millis, + probe_signature, + records: Vec::new(), + records_timestamp_millis: None, + records_signature: None, + }; + let _ = store.attach_rumor(rumor); + ClientResponse::Registered { token } + } Err(err) => ClientResponse::Error { message: err.to_string(), }, @@ -91,6 +122,12 @@ fn handle_set_records( match authenticate(&envelope, &message, now) { Ok(identity) => { if store.set_records(&envelope.body.domain, &identity, envelope.body.records) { + store.attach_record_evidence( + &envelope.body.domain, + &identity.key(), + envelope.timestamp_millis, + envelope.signature, + ); ClientResponse::RecordsSet } else { ClientResponse::Error { diff --git a/crates/binda-core/src/collision.rs b/crates/binda-core/src/collision.rs index c2ad449..e2937c5 100644 --- a/crates/binda-core/src/collision.rs +++ b/crates/binda-core/src/collision.rs @@ -97,20 +97,31 @@ pub fn resolve( } } -/// Run a full simulated negotiation locally (both sides in-process), for -/// testing and for single-node simulation of the network protocol. -pub fn negotiate_locally(a: RegistrationToken, b: RegistrationToken) -> RegistrationToken { - let mut side_a = Negotiation::new(); - let mut side_b = Negotiation::new(); - loop { - let pa = side_a.propose(); - let pb = side_b.propose(); - if let Some(condition) = Negotiation::check_agreement(pa, pb) { - return resolve(condition, a, b); - } +/// Derive the mutually-random collision condition from *both* claims. +/// +/// Each registration token contributes an unpredictable nonce, while this +/// symmetric reduction means every replica computes the same outcome from +/// the same pair. This replaces the old local simulation of two remote +/// coin flips, which could make different gossip recipients retain +/// different winners. +pub fn mutually_derived_condition(a: RegistrationToken, b: RegistrationToken) -> WinCondition { + let parity = a.nonce.iter().chain(b.nonce.iter()).fold( + (a.issued_at_millis ^ b.issued_at_millis) as u8, + |acc, byte| acc ^ byte, + ); + if parity & 1 == 0 { + WinCondition::HigherWins + } else { + WinCondition::LowerWins } } +/// Resolve a collision in a way every node can reproduce from the two +/// registration tokens alone. +pub fn negotiate_locally(a: RegistrationToken, b: RegistrationToken) -> RegistrationToken { + resolve(mutually_derived_condition(a, b), a, b) +} + #[cfg(test)] mod tests { use super::*; @@ -123,6 +134,23 @@ mod tests { assert!(winner == a || winner == b); } + #[test] + fn mutually_derived_outcome_is_symmetric_and_convergent() { + let a = RegistrationToken { + issued_at_millis: 1, + nonce: [1; 8], + }; + let b = RegistrationToken { + issued_at_millis: 2, + nonce: [2; 8], + }; + assert_eq!( + mutually_derived_condition(a, b), + mutually_derived_condition(b, a) + ); + assert_eq!(negotiate_locally(a, b), negotiate_locally(b, a)); + } + #[test] fn resolve_higher_wins_picks_greater_key() { let a = RegistrationToken { diff --git a/crates/binda-core/src/gossip.rs b/crates/binda-core/src/gossip.rs index ffd013c..4c1d76b 100644 --- a/crates/binda-core/src/gossip.rs +++ b/crates/binda-core/src/gossip.rs @@ -27,9 +27,12 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; +use crate::client::ClientIdentity; +use crate::client_api::{probe_message, register_message, set_records_message}; use crate::collision::{resolve, WinCondition}; use crate::domain::DomainName; use crate::token::RegistrationToken; +use crate::zone::Record; /// A single fact a node can gossip about: a domain's current registration /// state, as of a token. @@ -38,6 +41,75 @@ pub struct RegistrationRumor { pub domain: DomainName, pub token: RegistrationToken, pub client_key: String, + /// The original owner-signed registration and most recent signed probe. + /// A rumor is an independently verifiable lease, never a peer assertion. + pub owner_key: Vec, + pub rdns: String, + pub registration_timestamp_millis: u64, + pub registration_signature: Vec, + pub probe_timestamp_millis: u64, + pub probe_signature: Vec, + pub records: Vec, + pub records_timestamp_millis: Option, + pub records_signature: Option>, +} + +impl RegistrationRumor { + /// Validate all owner authorizations carried by this replicated lease. + /// The admission node remains responsible for FCrDNS at issuance time; + /// replicas validate the cryptographic fact and expiry without trusting + /// the gossip sender. + pub fn is_authorized(&self) -> bool { + let Ok(bytes) = <[u8; 32]>::try_from(self.owner_key.as_slice()) else { + return false; + }; + let Ok(key) = ed25519_dalek::VerifyingKey::from_bytes(&bytes) else { + return false; + }; + let owner = ClientIdentity::new(key, self.rdns.clone()); + if owner.key() != self.client_key { + return false; + } + let Ok(register_signature) = + ed25519_dalek::Signature::from_slice(&self.registration_signature) + else { + return false; + }; + let Ok(probe_signature) = ed25519_dalek::Signature::from_slice(&self.probe_signature) + else { + return false; + }; + if owner + .verify( + ®ister_message(&self.domain, self.registration_timestamp_millis), + ®ister_signature, + ) + .is_err() + || owner + .verify( + &probe_message(self.probe_timestamp_millis), + &probe_signature, + ) + .is_err() + { + return false; + } + match (self.records_timestamp_millis, &self.records_signature) { + (None, None) => self.records.is_empty(), + (Some(timestamp), Some(signature)) => { + let Ok(signature) = ed25519_dalek::Signature::from_slice(signature) else { + return false; + }; + owner + .verify( + &set_records_message(&self.domain, &self.records, timestamp), + &signature, + ) + .is_ok() + } + _ => false, + } + } } /// A behavioural test bundled into a [`GossipMessage::Request`]: answering @@ -154,6 +226,27 @@ pub const MAX_REQUEST_ENTRIES: usize = 4096; mod tests { use super::*; + fn rumor( + domain: DomainName, + token: RegistrationToken, + client_key: impl Into, + ) -> RegistrationRumor { + RegistrationRumor { + domain, + token, + client_key: client_key.into(), + owner_key: vec![], + rdns: String::new(), + registration_timestamp_millis: 0, + registration_signature: vec![], + probe_timestamp_millis: 0, + probe_signature: vec![], + records: vec![], + records_timestamp_millis: None, + records_signature: None, + } + } + #[test] fn oversized_digest_is_rejected() { let rumors = (0..MAX_DIGEST_ENTRIES + 1) @@ -201,10 +294,12 @@ mod tests { #[test] fn oversized_rumors_is_rejected() { let rumors = (0..MAX_DIGEST_ENTRIES + 1) - .map(|i| RegistrationRumor { - domain: DomainName::new(format!("d{i}.binda")).unwrap(), - token: RegistrationToken::issue(0), - client_key: "someone".to_string(), + .map(|i| { + rumor( + DomainName::new(format!("d{i}.binda")).unwrap(), + RegistrationToken::issue(0), + "someone", + ) }) .collect(); let msg = GossipMessage::Rumors { @@ -217,11 +312,11 @@ mod tests { #[test] fn normal_rumors_is_accepted() { let msg = GossipMessage::Rumors { - rumors: vec![RegistrationRumor { - domain: DomainName::new("example.binda").unwrap(), - token: RegistrationToken::issue(0), - client_key: "someone".to_string(), - }], + rumors: vec![rumor( + DomainName::new("example.binda").unwrap(), + RegistrationToken::issue(0), + "someone", + )], challenge_answer: RegistrationToken::issue(0), }; assert!(is_well_formed(&msg)); @@ -249,18 +344,14 @@ mod tests { fn rumor_response_must_be_request_scoped_and_unique() { let requested = vec![DomainName::new("requested.binda").unwrap()]; let token = RegistrationToken::issue(1); - let valid = vec![RegistrationRumor { - domain: requested[0].clone(), - token, - client_key: "client".into(), - }]; + let valid = vec![rumor(requested[0].clone(), token, "client")]; assert!(is_valid_rumor_response(&requested, &valid)); - let unrequested = vec![RegistrationRumor { - domain: DomainName::new("unrequested.binda").unwrap(), + let unrequested = vec![rumor( + DomainName::new("unrequested.binda").unwrap(), token, - client_key: "client".into(), - }]; + "client", + )]; assert!(!is_valid_rumor_response(&requested, &unrequested)); let duplicate = vec![valid[0].clone(), valid[0].clone()]; diff --git a/crates/binda-core/src/store.rs b/crates/binda-core/src/store.rs index e5d8c62..5142c51 100644 --- a/crates/binda-core/src/store.rs +++ b/crates/binda-core/src/store.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use crate::client::ClientIdentity; use crate::collision::negotiate_locally; use crate::domain::DomainName; +use crate::gossip::RegistrationRumor; use crate::liveness::{LivenessTracker, TimeSource}; use crate::token::RegistrationToken; use crate::zone::Record; @@ -18,6 +19,7 @@ pub struct Registration { pub client_key: String, pub token: RegistrationToken, pub records: Vec, + pub rumor: Option, } /// Reasons a registration attempt can be refused. @@ -39,6 +41,7 @@ pub enum RegistrationError { pub struct RegistryStore { registrations: HashMap, liveness: LivenessTracker, + probe_evidence: HashMap)>, } impl RegistryStore { @@ -51,6 +54,22 @@ impl RegistryStore { self.liveness.record_probe(client, time); } + pub fn probe_with_evidence( + &mut self, + client: &ClientIdentity, + time: &dyn TimeSource, + timestamp: u64, + signature: Vec, + ) { + self.probe(client, time); + self.probe_evidence + .insert(client.key(), (timestamp, signature)); + } + + pub fn last_probe_evidence(&self, client: &ClientIdentity) -> Option<(u64, Vec)> { + self.probe_evidence.get(&client.key()).cloned() + } + /// Attempt to register `domain` on behalf of `client`. /// /// If the domain is free, the client is registered outright (subject @@ -90,6 +109,7 @@ impl RegistryStore { client_key: client.key(), token, records: Vec::new(), + rumor: None, }, ); self.liveness.increment_registration(client); @@ -114,6 +134,7 @@ impl RegistryStore { client_key, token, records: Vec::new(), + rumor: None, }, ); } @@ -135,18 +156,68 @@ impl RegistryStore { } } + /// Persist the owner-signed lease created by the API layer. Only an + /// exact locally-issued claim may gain gossip authority. + pub fn attach_rumor(&mut self, rumor: RegistrationRumor) -> bool { + match self.registrations.get_mut(&rumor.domain) { + Some(reg) if reg.client_key == rumor.client_key && reg.token == rumor.token => { + reg.records = rumor.records.clone(); + reg.rumor = Some(rumor); + true + } + _ => false, + } + } + + /// Refresh every locally held lease for an owner after its authenticated + /// probe. This makes expiry globally reproducible on the next gossip. + pub fn refresh_probe_evidence(&mut self, client_key: &str, timestamp: u64, signature: Vec) { + for registration in self.registrations.values_mut() { + if registration.client_key == client_key { + if let Some(rumor) = &mut registration.rumor { + rumor.probe_timestamp_millis = timestamp; + rumor.probe_signature = signature.clone(); + } + } + } + } + + pub fn attach_record_evidence( + &mut self, + domain: &DomainName, + client_key: &str, + timestamp: u64, + signature: Vec, + ) { + if let Some(registration) = self.registrations.get_mut(domain) { + if registration.client_key == client_key { + if let Some(rumor) = &mut registration.rumor { + rumor.records = registration.records.clone(); + rumor.records_timestamp_millis = Some(timestamp); + rumor.records_signature = Some(signature); + } + } + } + } + /// Absorb a fact learned from gossip: another node claims `domain` is /// held by `client_key` as of `token`. Never subject to this node's /// own liveness/cap rules — those only gate registrations *this* node /// issues locally. If the domain is already held here under a /// different claim, the two claims are arbitrated via the same mutual /// coin-negotiation protocol used for a live collision. - pub fn adopt_rumor( - &mut self, - domain: DomainName, - client_key: String, - token: RegistrationToken, - ) { + pub fn adopt_rumor(&mut self, rumor: RegistrationRumor, time: &dyn TimeSource) -> bool { + if !rumor.is_authorized() + || time + .now_millis() + .saturating_sub(rumor.probe_timestamp_millis) + > crate::liveness::LIVENESS_WINDOW.as_millis() as u64 + { + return false; + } + let domain = rumor.domain.clone(); + let client_key = rumor.client_key.clone(); + let token = rumor.token; match self.registrations.get(&domain) { None => { self.registrations.insert( @@ -154,7 +225,8 @@ impl RegistryStore { Registration { client_key, token, - records: Vec::new(), + records: rumor.records.clone(), + rumor: Some(rumor), }, ); } @@ -162,13 +234,24 @@ impl RegistryStore { // Already known; nothing to do. } Some(existing) => { - self.resolve_collision( - domain, - (existing.client_key.clone(), existing.token), - (client_key, token), - ); + // `negotiate_locally` is a symmetric derivation from both + // tokens, so every replica makes the same choice. Retain + // the winning lease itself; retaining only its key/token + // would make the next anti-entropy round unauthenticated. + if negotiate_locally(existing.token, token) == token { + self.registrations.insert( + domain, + Registration { + client_key, + token, + records: rumor.records.clone(), + rumor: Some(rumor), + }, + ); + } } } + true } /// Every `(domain, client_key, token)` this node currently holds, for @@ -283,42 +366,6 @@ mod tests { assert!(store.register(domain, &c, &time).is_ok()); } - #[test] - fn adopt_rumor_inserts_when_domain_unknown() { - let mut store = RegistryStore::new(); - let domain = DomainName::new("example.binda").unwrap(); - let token = RegistrationToken::issue(1000); - store.adopt_rumor(domain.clone(), "someone".to_string(), token); - let reg = store.lookup(&domain).unwrap(); - assert_eq!(reg.client_key, "someone"); - assert_eq!(reg.token, token); - } - - #[test] - fn adopt_rumor_is_a_no_op_when_already_known() { - let mut store = RegistryStore::new(); - let domain = DomainName::new("example.binda").unwrap(); - let token = RegistrationToken::issue(1000); - store.adopt_rumor(domain.clone(), "someone".to_string(), token); - // Same client_key and token again: nothing should change. - store.adopt_rumor(domain.clone(), "someone".to_string(), token); - let reg = store.lookup(&domain).unwrap(); - assert_eq!(reg.client_key, "someone"); - assert_eq!(reg.token, token); - } - - #[test] - fn adopt_rumor_resolves_collision_when_claims_differ() { - let mut store = RegistryStore::new(); - let domain = DomainName::new("example.binda").unwrap(); - let token_a = RegistrationToken::issue(1000); - let token_b = RegistrationToken::issue(2000); - store.adopt_rumor(domain.clone(), "a".to_string(), token_a); - store.adopt_rumor(domain.clone(), "b".to_string(), token_b); - let reg = store.lookup(&domain).unwrap(); - assert!(reg.client_key == "a" || reg.client_key == "b"); - } - #[test] fn set_records_fails_for_non_owner() { let time = MockTimeSource::new(0); diff --git a/crates/binda/src/node.rs b/crates/binda/src/node.rs index 791e07a..50dbc81 100644 --- a/crates/binda/src/node.rs +++ b/crates/binda/src/node.rs @@ -277,11 +277,14 @@ impl Node { domains .into_iter() .filter_map(|domain| { - store.lookup(&domain).map(|reg| RegistrationRumor { - domain, - token: reg.token, - client_key: reg.client_key.clone(), - }) + store + .lookup(&domain) + .and_then(|reg| reg.rumor.as_ref()) + .map(|rumor| { + let mut rumor = rumor.clone(); + rumor.domain = domain; + rumor + }) }) .collect() }; @@ -331,7 +334,7 @@ impl Node { let mut store = self.store.lock().await; for rumor in rumors { - store.adopt_rumor(rumor.domain, rumor.client_key, rumor.token); + store.adopt_rumor(rumor, self.time.as_ref()); } } } @@ -471,7 +474,7 @@ mod tests { use binda_core::domain::DomainName; use binda_core::liveness::SystemTimeSource; use binda_core::token::RegistrationToken; - use ed25519_dalek::SigningKey; + use ed25519_dalek::{Signer, SigningKey}; use rand::rngs::OsRng; use std::time::Duration as StdDuration; @@ -585,9 +588,36 @@ mod tests { store .register(domain.clone(), &client, node.time.as_ref()) .unwrap(); + let token = store.lookup(domain).unwrap().token; + let timestamp = node.time.now_millis(); + let probe_signature = signing_key.sign(&binda_core::client_api::probe_message(timestamp)); + let registration_signature = + signing_key.sign(&binda_core::client_api::register_message(domain, timestamp)); if !records.is_empty() { - assert!(store.set_records(domain, &client, records)); + assert!(store.set_records(domain, &client, records.clone())); } + let records_signature = (!records.is_empty()).then(|| { + signing_key + .sign(&binda_core::client_api::set_records_message( + domain, &records, timestamp, + )) + .to_bytes() + .to_vec() + }); + assert!(store.attach_rumor(RegistrationRumor { + domain: domain.clone(), + token, + client_key: client.key(), + owner_key: signing_key.verifying_key().to_bytes().to_vec(), + rdns: client.rdns.clone(), + registration_timestamp_millis: timestamp, + registration_signature: registration_signature.to_bytes().to_vec(), + probe_timestamp_millis: timestamp, + probe_signature: probe_signature.to_bytes().to_vec(), + records: records.clone(), + records_timestamp_millis: records_signature.as_ref().map(|_| timestamp), + records_signature, + })); } #[tokio::test(flavor = "multi_thread")] @@ -675,6 +705,15 @@ mod tests { domain: evil_domain.clone(), token: binda_core::token::RegistrationToken::issue(0), client_key: "rogue".to_string(), + owner_key: vec![], + rdns: String::new(), + registration_timestamp_millis: 0, + registration_signature: vec![], + probe_timestamp_millis: 0, + probe_signature: vec![], + records: vec![], + records_timestamp_millis: None, + records_signature: None, }], challenge_answer: wrong, }; @@ -725,6 +764,15 @@ mod tests { domain: domain.clone(), token, client_key: "honest".to_string(), + owner_key: vec![], + rdns: String::new(), + registration_timestamp_millis: 0, + registration_signature: vec![], + probe_timestamp_millis: 0, + probe_signature: vec![], + records: vec![], + records_timestamp_millis: None, + records_signature: None, }], challenge_answer: challenge.expected_answer(), }; @@ -736,11 +784,11 @@ mod tests { tokio::time::sleep(StdDuration::from_millis(500)).await; let store = victim.store.lock().await; - let reg = store - .lookup(&domain) - .expect("a correctly-answered rumor should be adopted"); - assert_eq!(reg.client_key, "honest"); - assert_eq!(reg.token, token); + let reg = store.lookup(&domain); + assert!( + reg.is_none(), + "a conformance response without an owner-signed lease must not be adopted" + ); } #[tokio::test(flavor = "multi_thread")] @@ -774,6 +822,15 @@ mod tests { domain: unrequested.clone(), token: binda_core::token::RegistrationToken::issue(0), client_key: "rogue".into(), + owner_key: vec![], + rdns: String::new(), + registration_timestamp_millis: 0, + registration_signature: vec![], + probe_timestamp_millis: 0, + probe_signature: vec![], + records: vec![], + records_timestamp_millis: None, + records_signature: None, }], challenge_answer: challenge.expected_answer(), }; From f268e7deeb175c50b8611e2a0b29322bbd143f03 Mon Sep 17 00:00:00 2001 From: Jeremy Carter Date: Sat, 19 Sep 2026 13:54:14 -0400 Subject: [PATCH 2/4] Bind registration quota to verified host AI collaborator: GPT-5.6 Terra --- README.md | 7 ++--- crates/binda-core/src/client.rs | 23 ++++++++++++++++ crates/binda-core/src/liveness.rs | 29 +++++++++++--------- crates/binda-core/src/store.rs | 45 ++++++++++++++++++++++++++----- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 58f2073..0f68275 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ within a 1-minute window. respond to a liveness probe within a minute; miss the window and it falls back into the pool. (Originally a stricter 3 seconds; relaxed to give room for ordinary reconfiguration downtime.) -- **Squatting resistance** — a maximum of 5 live registrations per client, - where a client is identified by an Ed25519 signature combined with its - reverse-DNS hostname. That hostname isn't just asserted: every +- **Squatting resistance** — a maximum of 5 live registrations per + forward-confirmed host allocation, regardless of how many Ed25519 owner + keys it rotates through. Owner keys authenticate updates; they do not + create capacity. That hostname isn't just asserted: every registration is checked with a real, forward-confirmed reverse DNS (FCrDNS) lookup against the request's actual source IP — the claimed hostname's PTR record must name it, and its A/AAAA record must resolve diff --git a/crates/binda-core/src/client.rs b/crates/binda-core/src/client.rs index a179abf..6365600 100644 --- a/crates/binda-core/src/client.rs +++ b/crates/binda-core/src/client.rs @@ -45,6 +45,17 @@ impl ClientIdentity { ) } + /// The scarce, verified-host subject that gates registration capacity. + /// + /// Owner keys are intentionally cheap to rotate. They authenticate + /// updates, but must never create fresh domain capacity. The daemon + /// forward-confirms this hostname against the request's observed source + /// address before allowing a registration; canonicalising it here makes + /// case and a trailing DNS dot unable to split one host's quota. + pub fn quota_key(&self) -> String { + self.rdns.trim().trim_end_matches('.').to_ascii_lowercase() + } + /// Verify that `signature` over `message` was produced by this client's /// private key. pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<(), ClientAuthError> { @@ -83,4 +94,16 @@ mod tests { Err(ClientAuthError::InvalidSignature) ); } + + #[test] + fn quota_key_is_host_scoped_and_canonical() { + let signing_key = SigningKey::generate(&mut OsRng); + let a = ClientIdentity::new(signing_key.verifying_key(), "Host.Example.Net."); + let b = ClientIdentity::new( + SigningKey::generate(&mut OsRng).verifying_key(), + "host.example.net", + ); + assert_eq!(a.quota_key(), b.quota_key()); + assert_ne!(a.key(), b.key()); + } } diff --git a/crates/binda-core/src/liveness.rs b/crates/binda-core/src/liveness.rs index da0156c..9aa524a 100644 --- a/crates/binda-core/src/liveness.rs +++ b/crates/binda-core/src/liveness.rs @@ -16,8 +16,8 @@ use crate::client::ClientIdentity; /// some real slack before losing its name. pub const LIVENESS_WINDOW: Duration = Duration::from_secs(60); -/// The maximum number of live domain registrations a single client -/// identity may hold at once, enforced while it remains within +/// The maximum number of live domain registrations a single verified host +/// allocation may hold at once, enforced while it remains within /// [`LIVENESS_WINDOW`]. pub const MAX_REGISTRATIONS_PER_CLIENT: usize = 5; @@ -76,8 +76,9 @@ impl TimeSource for MockTimeSource { } } -/// Tracks the last-seen timestamp of every client currently holding -/// registrations, and how many domains each one holds. +/// Tracks the last-seen timestamp and domain count for each verified-host +/// quota subject. A new owner key on the same host deliberately shares the +/// same entry rather than minting fresh capacity. #[derive(Debug, Default)] pub struct LivenessTracker { last_seen_millis: HashMap, @@ -92,13 +93,13 @@ impl LivenessTracker { /// Record a liveness probe response from `client` at the current time. pub fn record_probe(&mut self, client: &ClientIdentity, time: &dyn TimeSource) { self.last_seen_millis - .insert(client.key(), time.now_millis()); + .insert(client.quota_key(), time.now_millis()); } /// Whether `client` has probed within [`LIVENESS_WINDOW`] of `time`'s /// current reading. A client never probed is not considered live. pub fn is_live(&self, client: &ClientIdentity, time: &dyn TimeSource) -> bool { - match self.last_seen_millis.get(&client.key()) { + match self.last_seen_millis.get(&client.quota_key()) { Some(&last) => { let now = time.now_millis(); now.saturating_sub(last) <= LIVENESS_WINDOW.as_millis() as u64 @@ -110,7 +111,7 @@ impl LivenessTracker { /// Current number of registrations attributed to `client`. pub fn registration_count(&self, client: &ClientIdentity) -> usize { self.registration_counts - .get(&client.key()) + .get(&client.quota_key()) .copied() .unwrap_or(0) } @@ -124,13 +125,16 @@ impl LivenessTracker { /// Attribute one more registration to `client`. Callers must have /// already checked [`Self::can_register`]. pub fn increment_registration(&mut self, client: &ClientIdentity) { - *self.registration_counts.entry(client.key()).or_insert(0) += 1; + *self + .registration_counts + .entry(client.quota_key()) + .or_insert(0) += 1; } /// Release all of `client`'s registration slots, e.g. after it drops /// out of the live set and its domains are reclaimed. pub fn clear_registrations(&mut self, client: &ClientIdentity) { - self.registration_counts.remove(&client.key()); + self.registration_counts.remove(&client.quota_key()); } /// Fully forget a client identified by its raw key (as returned by @@ -236,7 +240,7 @@ mod tests { tracker.record_probe(&c, &time); tracker.increment_registration(&c); - tracker.forget_client_by_key(&c.key()); + tracker.forget_client_by_key(&c.quota_key()); assert_eq!(tracker.registration_count(&c), 0); assert!(!tracker.is_live(&c, &time)); @@ -247,7 +251,8 @@ mod tests { let time = MockTimeSource::new(0); let mut tracker = LivenessTracker::new(); let stale = client(); - let fresh = client(); + let fresh_key = SigningKey::generate(&mut OsRng); + let fresh = ClientIdentity::new(fresh_key.verifying_key(), "fresh.example.net"); tracker.record_probe(&stale, &time); time.advance(Duration::from_millis( LIVENESS_WINDOW.as_millis() as u64 + 1, @@ -255,7 +260,7 @@ mod tests { tracker.record_probe(&fresh, &time); let expired = tracker.expired_clients(&time); - assert_eq!(expired, vec![stale.key()]); + assert_eq!(expired, vec![stale.quota_key()]); } #[test] diff --git a/crates/binda-core/src/store.rs b/crates/binda-core/src/store.rs index 5142c51..67b1ef4 100644 --- a/crates/binda-core/src/store.rs +++ b/crates/binda-core/src/store.rs @@ -17,6 +17,8 @@ use crate::zone::Record; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Registration { pub client_key: String, + /// Canonical FCrDNS host that consumed this registration slot. + pub quota_key: String, pub token: RegistrationToken, pub records: Vec, pub rumor: Option, @@ -27,7 +29,7 @@ pub struct Registration { pub enum RegistrationError { #[error("client is not currently live (must probe within the liveness window first)")] NotLive, - #[error("client already holds the maximum number of registrations")] + #[error("verified host already holds the maximum number of registrations")] RegistrationLimitReached, } @@ -107,6 +109,7 @@ impl RegistryStore { domain, Registration { client_key: client.key(), + quota_key: client.quota_key(), token, records: Vec::new(), rumor: None, @@ -123,15 +126,16 @@ impl RegistryStore { pub fn resolve_collision( &mut self, domain: DomainName, - a: (String, RegistrationToken), - b: (String, RegistrationToken), + a: (String, String, RegistrationToken), + b: (String, String, RegistrationToken), ) { - let winner_token = negotiate_locally(a.1, b.1); - let (client_key, token) = if winner_token == a.1 { a } else { b }; + let winner_token = negotiate_locally(a.2, b.2); + let (client_key, quota_key, token) = if winner_token == a.2 { a } else { b }; self.registrations.insert( domain, Registration { client_key, + quota_key, token, records: Vec::new(), rumor: None, @@ -224,6 +228,7 @@ impl RegistryStore { domain, Registration { client_key, + quota_key: rumor.rdns.trim().trim_end_matches('.').to_ascii_lowercase(), token, records: rumor.records.clone(), rumor: Some(rumor), @@ -243,6 +248,7 @@ impl RegistryStore { domain, Registration { client_key, + quota_key: rumor.rdns.trim().trim_end_matches('.').to_ascii_lowercase(), token, records: rumor.records.clone(), rumor: Some(rumor), @@ -277,7 +283,7 @@ impl RegistryStore { } let expired: std::collections::HashSet = expired.into_iter().collect(); self.registrations - .retain(|_, reg| !expired.contains(®.client_key)); + .retain(|_, reg| !expired.contains(®.quota_key)); for key in &expired { self.liveness.forget_client_by_key(key); } @@ -311,6 +317,33 @@ mod tests { assert!(store.register(domain, &c, &time).is_ok()); } + #[test] + fn rotating_owner_keys_does_not_reset_a_verified_hosts_quota() { + let time = MockTimeSource::new(0); + let mut store = RegistryStore::new(); + let first = client(); + let replacement = client(); + assert_eq!(first.quota_key(), replacement.quota_key()); + assert_ne!(first.key(), replacement.key()); + store.probe(&first, &time); + for i in 0..crate::liveness::MAX_REGISTRATIONS_PER_CLIENT { + store + .register( + DomainName::new(format!("first-{i}.binda")).unwrap(), + &first, + &time, + ) + .unwrap(); + } + // A probe signed by a replacement key keeps the same reachable + // host live, but must not manufacture another five slots. + store.probe(&replacement, &time); + assert_eq!( + store.register(DomainName::new("sixth.binda").unwrap(), &replacement, &time), + Err(RegistrationError::RegistrationLimitReached) + ); + } + #[test] fn stale_client_cannot_register() { let time = MockTimeSource::new(0); From 0726984bc35211912e74185e4bc8c6a0af52aed1 Mon Sep 17 00:00:00 2001 From: Jeremy Carter Date: Sat, 19 Sep 2026 14:05:50 -0400 Subject: [PATCH 3/4] Reconcile global verified-host quotas AI collaborator: GPT-5.6 Terra --- README.md | 4 + crates/binda-core/src/api.rs | 2 +- crates/binda-core/src/store.rs | 202 +++++++++++++++++++++++++-------- crates/binda/src/node.rs | 31 ++--- 4 files changed, 177 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 0f68275..b1d87f2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ within a 1-minute window. back to that same IP (see [`fcrdns`](crates/binda-core/src/fcrdns.rs)) — so the cap applies to a real, distinctly-controlled host rather than to a free-to-mint keypair. + When separate instances concurrently learn more than five valid claims + for one host, every replica retains the signed candidates through their + normal lease expiry and deterministically exposes the same first five; + a later candidate is promoted when a winner expires. - **Full Unicode names, no length limit** — domain labels may use any printable Unicode scalar value: emoji, combining-mark ("zalgo") sequences, and mixed right-to-left/left-to-right scripts (intermixed diff --git a/crates/binda-core/src/api.rs b/crates/binda-core/src/api.rs index cbd6254..fb50005 100644 --- a/crates/binda-core/src/api.rs +++ b/crates/binda-core/src/api.rs @@ -95,7 +95,7 @@ fn handle_register( records_timestamp_millis: None, records_signature: None, }; - let _ = store.attach_rumor(rumor); + let _ = store.attach_rumor(rumor, time); ClientResponse::Registered { token } } Err(err) => ClientResponse::Error { diff --git a/crates/binda-core/src/store.rs b/crates/binda-core/src/store.rs index 67b1ef4..8609d39 100644 --- a/crates/binda-core/src/store.rs +++ b/crates/binda-core/src/store.rs @@ -42,6 +42,11 @@ pub enum RegistrationError { #[derive(Debug, Default)] pub struct RegistryStore { registrations: HashMap, + /// Every authenticated claim for a quota subject, including claims + /// that currently lost the five-name selection. Keeping losers until + /// their lease expires prevents a peer from resurrecting them and lets + /// the next valid claim be promoted when a winner disappears. + quota_claims: HashMap>, liveness: LivenessTracker, probe_evidence: HashMap)>, } @@ -162,11 +167,15 @@ impl RegistryStore { /// Persist the owner-signed lease created by the API layer. Only an /// exact locally-issued claim may gain gossip authority. - pub fn attach_rumor(&mut self, rumor: RegistrationRumor) -> bool { - match self.registrations.get_mut(&rumor.domain) { + pub fn attach_rumor(&mut self, rumor: RegistrationRumor, time: &dyn TimeSource) -> bool { + let quota_key = quota_key(&rumor.rdns); + match self.registrations.get(&rumor.domain) { Some(reg) if reg.client_key == rumor.client_key && reg.token == rumor.token => { - reg.records = rumor.records.clone(); - reg.rumor = Some(rumor); + self.quota_claims + .entry(quota_key.clone()) + .or_default() + .insert(rumor.domain.clone(), rumor); + self.reconcile_quota("a_key, time); true } _ => false, @@ -184,6 +193,14 @@ impl RegistryStore { } } } + for claims in self.quota_claims.values_mut() { + for rumor in claims.values_mut() { + if rumor.client_key == client_key { + rumor.probe_timestamp_millis = timestamp; + rumor.probe_signature = signature.clone(); + } + } + } } pub fn attach_record_evidence( @@ -198,7 +215,15 @@ impl RegistryStore { if let Some(rumor) = &mut registration.rumor { rumor.records = registration.records.clone(); rumor.records_timestamp_millis = Some(timestamp); - rumor.records_signature = Some(signature); + rumor.records_signature = Some(signature.clone()); + } + } + } + for claims in self.quota_claims.values_mut() { + if let Some(rumor) = claims.get_mut(domain) { + if rumor.client_key == client_key { + rumor.records_timestamp_millis = Some(timestamp); + rumor.records_signature = Some(signature.clone()); } } } @@ -219,44 +244,12 @@ impl RegistryStore { { return false; } - let domain = rumor.domain.clone(); - let client_key = rumor.client_key.clone(); - let token = rumor.token; - match self.registrations.get(&domain) { - None => { - self.registrations.insert( - domain, - Registration { - client_key, - quota_key: rumor.rdns.trim().trim_end_matches('.').to_ascii_lowercase(), - token, - records: rumor.records.clone(), - rumor: Some(rumor), - }, - ); - } - Some(existing) if existing.client_key == client_key && existing.token == token => { - // Already known; nothing to do. - } - Some(existing) => { - // `negotiate_locally` is a symmetric derivation from both - // tokens, so every replica makes the same choice. Retain - // the winning lease itself; retaining only its key/token - // would make the next anti-entropy round unauthenticated. - if negotiate_locally(existing.token, token) == token { - self.registrations.insert( - domain, - Registration { - client_key, - quota_key: rumor.rdns.trim().trim_end_matches('.').to_ascii_lowercase(), - token, - records: rumor.records.clone(), - rumor: Some(rumor), - }, - ); - } - } - } + let quota_key = quota_key(&rumor.rdns); + self.quota_claims + .entry(quota_key.clone()) + .or_default() + .insert(rumor.domain.clone(), rumor); + self.reconcile_quota("a_key, time); true } @@ -278,28 +271,76 @@ impl RegistryStore { /// justify that count. pub fn reclaim_stale(&mut self, time: &dyn TimeSource) { let expired = self.liveness.expired_clients(time); - if expired.is_empty() { - return; - } let expired: std::collections::HashSet = expired.into_iter().collect(); self.registrations .retain(|_, reg| !expired.contains(®.quota_key)); for key in &expired { self.liveness.forget_client_by_key(key); } + let quota_keys: Vec = self.quota_claims.keys().cloned().collect(); + for quota_key in quota_keys { + self.reconcile_quota("a_key, time); + } } /// Look up the current registration for `domain`, if any. pub fn lookup(&self, domain: &DomainName) -> Option<&Registration> { self.registrations.get(domain) } + + fn reconcile_quota(&mut self, quota: &str, time: &dyn TimeSource) { + let Some(claims) = self.quota_claims.get_mut(quota) else { + return; + }; + claims.retain(|_, rumor| rumor.is_authorized() && lease_is_live(rumor, time)); + let mut winners: Vec<_> = claims.values().cloned().collect(); + winners.sort_by(|a, b| { + a.token + .raw_ordering_key() + .cmp(&b.token.raw_ordering_key()) + .then_with(|| a.domain.cmp(&b.domain)) + }); + winners.truncate(crate::liveness::MAX_REGISTRATIONS_PER_CLIENT); + self.registrations + .retain(|_, reg| reg.quota_key != quota || reg.rumor.is_none()); + for rumor in winners { + let replace = match self.registrations.get(&rumor.domain) { + Some(existing) => negotiate_locally(existing.token, rumor.token) == rumor.token, + None => true, + }; + if replace { + self.registrations + .insert(rumor.domain.clone(), registration_from_rumor(rumor, quota)); + } + } + } +} + +fn quota_key(rdns: &str) -> String { + rdns.trim().trim_end_matches('.').to_ascii_lowercase() +} + +fn lease_is_live(rumor: &RegistrationRumor, time: &dyn TimeSource) -> bool { + time.now_millis() + .saturating_sub(rumor.probe_timestamp_millis) + <= crate::liveness::LIVENESS_WINDOW.as_millis() as u64 +} + +fn registration_from_rumor(rumor: RegistrationRumor, quota_key: &str) -> Registration { + Registration { + client_key: rumor.client_key.clone(), + quota_key: quota_key.to_string(), + token: rumor.token, + records: rumor.records.clone(), + rumor: Some(rumor), + } } #[cfg(test)] mod tests { use super::*; use crate::liveness::MockTimeSource; - use ed25519_dalek::SigningKey; + use ed25519_dalek::{Signer, SigningKey}; use rand::rngs::OsRng; fn client() -> ClientIdentity { @@ -307,6 +348,35 @@ mod tests { ClientIdentity::new(signing_key.verifying_key(), "host.example.net") } + fn signed_rumor(domain: DomainName, timestamp: u64) -> RegistrationRumor { + let signing_key = SigningKey::generate(&mut OsRng); + let rdns = "shared.example.net".to_string(); + let owner = ClientIdentity::new(signing_key.verifying_key(), rdns.clone()); + RegistrationRumor { + domain: domain.clone(), + token: RegistrationToken { + issued_at_millis: timestamp, + nonce: [timestamp as u8; 8], + }, + client_key: owner.key(), + owner_key: signing_key.verifying_key().to_bytes().to_vec(), + rdns, + registration_timestamp_millis: timestamp, + registration_signature: signing_key + .sign(&crate::client_api::register_message(&domain, timestamp)) + .to_bytes() + .to_vec(), + probe_timestamp_millis: timestamp, + probe_signature: signing_key + .sign(&crate::client_api::probe_message(timestamp)) + .to_bytes() + .to_vec(), + records: Vec::new(), + records_timestamp_millis: None, + records_signature: None, + } + } + #[test] fn live_client_can_register_free_domain() { let time = MockTimeSource::new(0); @@ -344,6 +414,44 @@ mod tests { ); } + #[test] + fn gossip_claims_converge_to_the_first_five_for_one_verified_host() { + let time = MockTimeSource::new(1_000); + let mut store = RegistryStore::new(); + for i in 0..6 { + let domain = DomainName::new(format!("claim-{i}.binda")).unwrap(); + assert!(store.adopt_rumor(signed_rumor(domain, i), &time)); + } + for i in 0..5 { + assert!(store + .lookup(&DomainName::new(format!("claim-{i}.binda")).unwrap()) + .is_some()); + } + assert!(store + .lookup(&DomainName::new("claim-5.binda").unwrap()) + .is_none()); + } + + #[test] + fn fresh_over_capacity_claim_is_promoted_after_old_winners_expire() { + let time = MockTimeSource::new(0); + let mut store = RegistryStore::new(); + for i in 0..5 { + assert!(store.adopt_rumor( + signed_rumor(DomainName::new(format!("old-{i}.binda")).unwrap(), 0), + &time + )); + } + let fresh_at = crate::liveness::LIVENESS_WINDOW.as_millis() as u64 + 1; + time.advance(std::time::Duration::from_millis(fresh_at)); + let replacement = DomainName::new("replacement.binda").unwrap(); + assert!(store.adopt_rumor(signed_rumor(replacement.clone(), fresh_at), &time)); + assert!(store.lookup(&replacement).is_some()); + assert!(store + .lookup(&DomainName::new("old-0.binda").unwrap()) + .is_none()); + } + #[test] fn stale_client_cannot_register() { let time = MockTimeSource::new(0); diff --git a/crates/binda/src/node.rs b/crates/binda/src/node.rs index 50dbc81..d98b9a0 100644 --- a/crates/binda/src/node.rs +++ b/crates/binda/src/node.rs @@ -604,20 +604,23 @@ mod tests { .to_bytes() .to_vec() }); - assert!(store.attach_rumor(RegistrationRumor { - domain: domain.clone(), - token, - client_key: client.key(), - owner_key: signing_key.verifying_key().to_bytes().to_vec(), - rdns: client.rdns.clone(), - registration_timestamp_millis: timestamp, - registration_signature: registration_signature.to_bytes().to_vec(), - probe_timestamp_millis: timestamp, - probe_signature: probe_signature.to_bytes().to_vec(), - records: records.clone(), - records_timestamp_millis: records_signature.as_ref().map(|_| timestamp), - records_signature, - })); + assert!(store.attach_rumor( + RegistrationRumor { + domain: domain.clone(), + token, + client_key: client.key(), + owner_key: signing_key.verifying_key().to_bytes().to_vec(), + rdns: client.rdns.clone(), + registration_timestamp_millis: timestamp, + registration_signature: registration_signature.to_bytes().to_vec(), + probe_timestamp_millis: timestamp, + probe_signature: probe_signature.to_bytes().to_vec(), + records: records.clone(), + records_timestamp_millis: records_signature.as_ref().map(|_| timestamp), + records_signature, + }, + node.time.as_ref() + )); } #[tokio::test(flavor = "multi_thread")] From 2587ec5beeace15d392a330324d1027500ab64fd Mon Sep 17 00:00:00 2001 From: Jeremy Carter Date: Sat, 19 Sep 2026 14:12:38 -0400 Subject: [PATCH 4/4] Reject over-capacity claims without queueing AI collaborator: GPT-5.6 Terra --- README.md | 6 +++--- crates/binda-core/src/store.rs | 29 +++++++++++++++++++---------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b1d87f2..bb32663 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ within a 1-minute window. [`fcrdns`](crates/binda-core/src/fcrdns.rs)) — so the cap applies to a real, distinctly-controlled host rather than to a free-to-mint keypair. When separate instances concurrently learn more than five valid claims - for one host, every replica retains the signed candidates through their - normal lease expiry and deterministically exposes the same first five; - a later candidate is promoted when a winner expires. + for one host, every replica deterministically retains the same first + five and rejects the rest. A later vacancy is free for a new claim; it + never automatically promotes an earlier rejected registration. - **Full Unicode names, no length limit** — domain labels may use any printable Unicode scalar value: emoji, combining-mark ("zalgo") sequences, and mixed right-to-left/left-to-right scripts (intermixed diff --git a/crates/binda-core/src/store.rs b/crates/binda-core/src/store.rs index 8609d39..9f55245 100644 --- a/crates/binda-core/src/store.rs +++ b/crates/binda-core/src/store.rs @@ -3,7 +3,7 @@ //! their current registration, keyed with a timestamp + random-nonce //! token to make collisions detectable and resolvable. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::client::ClientIdentity; use crate::collision::negotiate_locally; @@ -42,10 +42,9 @@ pub enum RegistrationError { #[derive(Debug, Default)] pub struct RegistryStore { registrations: HashMap, - /// Every authenticated claim for a quota subject, including claims - /// that currently lost the five-name selection. Keeping losers until - /// their lease expires prevents a peer from resurrecting them and lets - /// the next valid claim be promoted when a winner disappears. + /// The currently accepted authenticated claims for a quota subject. + /// Over-capacity claims are deliberately discarded at reconciliation: + /// they are rejected, not placed in a deferred-registration queue. quota_claims: HashMap>, liveness: LivenessTracker, probe_evidence: HashMap)>, @@ -301,6 +300,8 @@ impl RegistryStore { .then_with(|| a.domain.cmp(&b.domain)) }); winners.truncate(crate::liveness::MAX_REGISTRATIONS_PER_CLIENT); + let winner_domains: HashSet<_> = winners.iter().map(|rumor| rumor.domain.clone()).collect(); + claims.retain(|domain, _| winner_domains.contains(domain)); self.registrations .retain(|_, reg| reg.quota_key != quota || reg.rumor.is_none()); for rumor in winners { @@ -433,7 +434,7 @@ mod tests { } #[test] - fn fresh_over_capacity_claim_is_promoted_after_old_winners_expire() { + fn over_capacity_claim_is_not_promoted_after_winners_expire() { let time = MockTimeSource::new(0); let mut store = RegistryStore::new(); for i in 0..5 { @@ -442,11 +443,19 @@ mod tests { &time )); } - let fresh_at = crate::liveness::LIVENESS_WINDOW.as_millis() as u64 + 1; - time.advance(std::time::Duration::from_millis(fresh_at)); let replacement = DomainName::new("replacement.binda").unwrap(); - assert!(store.adopt_rumor(signed_rumor(replacement.clone(), fresh_at), &time)); - assert!(store.lookup(&replacement).is_some()); + assert!(store.adopt_rumor(signed_rumor(replacement.clone(), 1), &time)); + assert!(store.lookup(&replacement).is_none()); + assert!(!store + .quota_claims + .get("shared.example.net") + .unwrap() + .contains_key(&replacement)); + time.advance(std::time::Duration::from_millis( + crate::liveness::LIVENESS_WINDOW.as_millis() as u64 + 2, + )); + store.reclaim_stale(&time); + assert!(store.lookup(&replacement).is_none()); assert!(store .lookup(&DomainName::new("old-0.binda").unwrap()) .is_none());