Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ 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
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 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
Expand Down
43 changes: 40 additions & 3 deletions crates/binda-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand All @@ -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, time);
ClientResponse::Registered { token }
}
Err(err) => ClientResponse::Error {
message: err.to_string(),
},
Expand All @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions crates/binda-core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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());
}
}
50 changes: 39 additions & 11 deletions crates/binda-core/src/collision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand All @@ -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 {
Expand Down
127 changes: 109 additions & 18 deletions crates/binda-core/src/gossip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<u8>,
pub rdns: String,
pub registration_timestamp_millis: u64,
pub registration_signature: Vec<u8>,
pub probe_timestamp_millis: u64,
pub probe_signature: Vec<u8>,
pub records: Vec<Record>,
pub records_timestamp_millis: Option<u64>,
pub records_signature: Option<Vec<u8>>,
}

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(
&register_message(&self.domain, self.registration_timestamp_millis),
&register_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
Expand Down Expand Up @@ -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<String>,
) -> 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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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));
Expand Down Expand Up @@ -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()];
Expand Down
Loading
Loading