//! Signed state that outlives a session. //! //! This is the first slice of the model in `docs/sync-model.md`: **each author //! signs its own records, and replicas merge them**. It exists because some //! facts have to survive a participant being away — an overlay address it //! claimed months ago, for instance — and a fact that only lives in a live //! session cannot do that. //! //! # Why there is no vote //! //! Anyone who knows the network secret can mint as many identities as they //! like, so a majority proves nothing; the threat model says as much. A quorum //! would also stall whenever a single participant is online and diverge across //! a partition. Instead: //! //! * an author signs only its **own** records, so nobody needs anybody's //! permission to state a fact about itself; //! * merging is **deterministic**, so every replica that has seen the same //! records reaches the same conclusion without exchanging opinions; //! * a genuine clash — two authors claiming one address at the same moment — //! is resolved by a rule both sides compute identically, and the loser //! simply picks again with a higher version. //! //! # What a record is //! //! One record per author per network, holding that author's **complete //! current** statement rather than a delta, exactly as the model requires: a //! replica that has the record needs nothing else to interpret it, and //! recovery never depends on replaying a chain from the beginning. //! //! Any member may also sign a separate retirement of another author's proved //! claim version. It suppresses stale reservations without changing that //! author's record, and a newer claim remains valid. //! //! # What this slice does not do yet //! //! Compaction and permanent revocation of a whole author. See //! `docs/sync-model.md` for the implemented cleanup semantics. pub mod allocator; /// Signed shared virtual service state and its merge rules. pub mod vip; use std::collections::HashMap; use std::net::Ipv4Addr; mod retirement; pub use retirement::{MAX_RETIREMENT_LEN, SignedRetirement}; use iroh::{EndpointId, SecretKey, Signature}; use serde::{Deserialize, Serialize}; use crate::identity::NetworkId; /// Why an IPv4 range could not be used. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[error("{0}")] pub struct RangeError(pub String); /// An IPv4 range the overlay allocates addresses from. /// /// One range per network. An agent proposes one through /// [`crate::config::AgentConfig::overlay_ipv4_range`], but a network that has /// already settled on another wins: see [`StateSet::agreed_range`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Ipv4Range { /// Base address of the range. pub base: Ipv4Addr, /// Prefix length, at most 30 so there is room for hosts. pub prefix_len: u8, } impl Ipv4Range { /// Builds a range, rejecting one with no room for hosts. pub fn new(base: Ipv4Addr, prefix_len: u8) -> Result { if prefix_len > 30 { return Err(RangeError(format!( "a /{prefix_len} has no room for hosts; use /30 or larger" ))); } Ok(Self { base, prefix_len }) } /// Whether an address falls inside the range. pub fn contains(&self, address: Ipv4Addr) -> bool { let host_bits = 32 - u32::from(self.prefix_len); let mask = if host_bits >= 32 { 0 } else { u32::MAX << host_bits }; u32::from(address) & mask == u32::from(self.base) & mask } } impl std::fmt::Display for Ipv4Range { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}/{}", self.base, self.prefix_len) } } impl std::str::FromStr for Ipv4Range { type Err = RangeError; fn from_str(text: &str) -> Result { let (base, prefix) = text.split_once('/').ok_or_else(|| { RangeError(format!( "`{text}` is not an address with a prefix, for example 10.77.0.0/16" )) })?; let base = base .parse() .map_err(|err| RangeError(format!("`{base}` is not an IPv4 address: {err}")))?; let prefix_len = prefix .parse() .map_err(|err| RangeError(format!("`{prefix}` is not a prefix length: {err}")))?; Self::new(base, prefix_len) } } /// The legacy default range, retained for existing networks and explicit use. /// New networks use [`derived_ipv4_range`] by default. pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range { base: Ipv4Addr::new(10, 13, 37, 0), prefix_len: 24, }; /// A range derived from a network's own identity. /// /// Every member independently derives the same pseudorandom `/24` in 10/8, /// including isolated first members. Stored signed ranges take precedence. /// The derivation is kept stable for networks already using it; the legacy /// default is excluded. Distinct network ids can still produce collisions. pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range { let bytes = network.as_bytes(); let second = bytes[0]; let third = bytes[1]; let candidate = Ipv4Addr::new(10, second, third, 0); // The default is somebody's already. One step along is still derived // from the id and still the same everywhere. let base = if candidate == DEFAULT_IPV4_RANGE.base { Ipv4Addr::new(10, second, third.wrapping_add(1), 0) } else { candidate }; Ipv4Range { base, prefix_len: 24, } } /// Frozen domain separator for the bytes a record signature covers. pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v3"; /// Longest hostname a record may carry. /// /// One DNS label's worth. It bounds what arrives from the network before /// anything is allocated for it, and keeps a name short enough to print in a /// column. pub const MAX_HOSTNAME_LEN: usize = 63; /// Largest number of records accepted in one exchange. pub const MAX_RECORDS_PER_MESSAGE: usize = 256; /// Why a record could not be used. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum StateError { /// The signature does not match the author. #[error("record signature does not verify")] BadSignature, /// The author field is not a valid public key. #[error("record author is not a valid endpoint id")] BadAuthor, /// The signature field is not the right length. #[error("record signature is not {expected} bytes")] BadSignatureLength { /// Expected length. expected: usize, }, /// The record belongs to a different network. #[error("record belongs to another network")] WrongNetwork, /// The record's contents are not acceptable. #[error("record is malformed: {0}")] Malformed(&'static str), } /// What an author is saying about itself. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub enum RecordBody { /// Everything this author currently asserts about itself. /// /// One record per author, so everything it claims travels together and a /// later version supersedes the lot. That is what makes changing a claim /// a revocation of the previous one rather than an addition beside it: /// there is no way to leave the old value standing. Claim { /// The IPv4 overlay address this author holds, if it holds one. address: Option, /// The overlay range that address was allocated from. /// /// The range travels with the claim so that a participant joining /// later learns which range the network actually settled on, rather /// than having to be told. range: Option, /// The name this author answers to. hostname: Option, /// When this author first claimed that name, in milliseconds since /// the Unix epoch, kept across every later version that keeps the /// name. It is what makes a name belong to whoever claimed it first /// rather than to whoever happens to sort lowest, so a member that /// arrives later with the same name cannot take it over. /// /// The author's own word: a member with a wrong clock can claim an /// earlier time. That costs it nothing it could not do by other /// means, since anybody who knows the network secret is a member, /// and it cannot take an address or reach anything it was not /// already entitled to. hostname_since: Option, }, /// This author gives up everything it claimed. /// /// A tombstone, not an absence: it is a positive statement, so it /// survives merging and cannot be undone by a replica that simply has not /// heard of it. Published when a device key is replaced, so the address /// and name it held are freed for somebody else rather than reserved /// forever to a key nobody has. Release, } impl RecordBody { /// The address this body claims, if any. pub fn claimed_address(&self) -> Option { match self { RecordBody::Claim { address, .. } => *address, RecordBody::Release => None, } } /// The range this body names, if any. pub fn range(&self) -> Option { match self { RecordBody::Claim { range, .. } => *range, RecordBody::Release => None, } } /// The hostname this body claims, if any. pub fn hostname(&self) -> Option<&str> { match self { RecordBody::Claim { hostname, .. } => hostname.as_deref(), RecordBody::Release => None, } } /// When the author first claimed the hostname, if it has one. pub fn hostname_since(&self) -> Option { match self { RecordBody::Claim { hostname_since, .. } => *hostname_since, RecordBody::Release => None, } } fn canonical(&self) -> Vec { let mut out = Vec::with_capacity(96); match self { RecordBody::Claim { address, range, hostname, hostname_since, } => { push_lp(&mut out, b"claim"); push_opt( &mut out, address .map(|address| address.octets()) .as_ref() .map(|o| &o[..]), ); push_opt( &mut out, range .map(|range| { let mut bytes = range.base.octets().to_vec(); bytes.push(range.prefix_len); bytes }) .as_deref(), ); push_opt(&mut out, hostname.as_deref().map(str::as_bytes)); push_opt( &mut out, hostname_since .map(|since| since.to_be_bytes()) .as_ref() .map(|bytes| &bytes[..]), ); } RecordBody::Release => { push_lp(&mut out, b"release"); } } out } } /// Reduces a hostname to something safe to store, compare and print. /// /// Three jobs at once. It bounds the length, so a record from the network /// cannot carry an unbounded string. It strips everything outside a /// conservative set, so a name can never be mistaken for a path, an option or /// a shell word by anything downstream — nothing here is ever executed, and /// this keeps it that way even if some later caller is careless. And it /// lower-cases, so that two members claiming the same name in different cases /// are recognised as claiming the same name rather than quietly both holding /// it. pub fn sanitise_hostname(raw: &str) -> String { let mut out = String::with_capacity(raw.len().min(MAX_HOSTNAME_LEN)); for ch in raw.chars() { if out.len() >= MAX_HOSTNAME_LEN { break; } let ch = ch.to_ascii_lowercase(); if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '.' | '_') { out.push(ch); } } // A name that is only separators distinguishes nothing. let trimmed = out.trim_matches(|ch| matches!(ch, '-' | '.' | '_')); trimmed.to_string() } /// Writes an optional value unambiguously: a presence byte, then the value. fn push_opt(out: &mut Vec, value: Option<&[u8]>) { match value { Some(bytes) => { out.push(1); push_lp(out, bytes); } None => out.push(0), } } fn push_lp(out: &mut Vec, bytes: &[u8]) { let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX); out.extend_from_slice(&len.to_be_bytes()); out.extend_from_slice(bytes); } /// One author's current statement about itself, signed by that author. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SignedRecord { /// The author's persistent endpoint id. pub author: [u8; 32], /// The network the statement belongs to. pub network: [u8; 32], /// The author's own counter. Only the author increments it. pub version: u64, /// The statement. pub body: RecordBody, /// Ed25519 signature over [`SignedRecord::canonical_bytes`]. pub signature: Vec, } impl SignedRecord { /// The bytes a signature covers. /// /// Length-prefixed throughout, so no two different records can produce the /// same bytes. pub fn canonical_bytes( network: NetworkId, author: EndpointId, version: u64, body: &RecordBody, ) -> Vec { let mut out = Vec::with_capacity(160); push_lp(&mut out, RECORD_DOMAIN.as_bytes()); push_lp(&mut out, network.as_bytes()); push_lp(&mut out, author.as_bytes()); push_lp(&mut out, &version.to_be_bytes()); push_lp(&mut out, &body.canonical()); out } /// Signs a new record with the author's persistent device key. pub fn sign(secret: &SecretKey, network: NetworkId, version: u64, body: RecordBody) -> Self { let author = secret.public(); let signature = secret.sign(&Self::canonical_bytes(network, author, version, &body)); Self { author: *author.as_bytes(), network: *network.as_bytes(), version, body, signature: signature.to_bytes().to_vec(), } } /// The author, if the field is a valid key. pub fn author_id(&self) -> Result { EndpointId::from_bytes(&self.author).map_err(|_| StateError::BadAuthor) } /// The network this record belongs to. pub fn network_id(&self) -> NetworkId { NetworkId::from_bytes(self.network) } /// Checks the signature and that the record belongs to `network`. /// /// Everything that reaches this from the network goes through it first. pub fn verify(&self, network: NetworkId) -> Result { if self.network != *network.as_bytes() { return Err(StateError::WrongNetwork); } if let RecordBody::Claim { address, range, hostname, hostname_since, } = &self.body { if hostname.is_none() && hostname_since.is_some() { return Err(StateError::Malformed("a time was claimed for no hostname")); } // Bounds before anything is believed, because all of this came // off the network. if let Some(range) = range { if range.prefix_len > 30 { return Err(StateError::Malformed("claimed range has no room for hosts")); } if let Some(address) = address && !range.contains(*address) { return Err(StateError::Malformed( "claimed address is outside its range", )); } } else if address.is_some() { return Err(StateError::Malformed("claimed an address with no range")); } if let Some(hostname) = hostname { // Rejected rather than sanitised: a name that does not // survive sanitising unchanged would hash and compare // differently from what the author signed, so accepting a // repaired version would mean believing something nobody // signed. if hostname.len() > MAX_HOSTNAME_LEN { return Err(StateError::Malformed("claimed hostname is too long")); } if *hostname != sanitise_hostname(hostname) { return Err(StateError::Malformed("claimed hostname is not canonical")); } } } let author = self.author_id()?; let raw: [u8; Signature::LENGTH] = self.signature .as_slice() .try_into() .map_err(|_| StateError::BadSignatureLength { expected: Signature::LENGTH, })?; let signature = Signature::from_bytes(&raw); author .verify( &Self::canonical_bytes(network, author, self.version, &self.body), &signature, ) .map_err(|_| StateError::BadSignature)?; Ok(author) } } /// What merging one record did. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Merged { /// Nothing was known about this author; the record was taken. Added, /// It replaced an older version from the same author. Updated, /// Already known, or older than what is held. Nothing changed. /// /// An older version never rolls back a newer one. Ignored, /// Two different records from one author at the same version. /// /// Resolved deterministically so every replica picks the same one, and /// reported because it means a key is being used from two places at once. Conflicted, } /// Everything known about one network, one record per author. #[derive(Debug, Clone, Default)] pub struct StateSet { records: HashMap, retirements: HashMap, } impl StateSet { /// An empty set. pub fn new() -> Self { Self::default() } /// Builds a set from records already known to be verified. pub fn from_verified(records: impl IntoIterator) -> Self { Self { records: records.into_iter().collect(), retirements: HashMap::new(), } } /// How many authors are known. pub fn len(&self) -> usize { self.records.len() } /// Whether nothing is known. pub fn is_empty(&self) -> bool { self.records.is_empty() } /// The record of one author. pub fn get(&self, author: &EndpointId) -> Option<&SignedRecord> { self.records.get(author) } /// Every record, in a stable order. pub fn records(&self) -> Vec { let mut authors: Vec<&EndpointId> = self.records.keys().collect(); authors.sort_by_key(|author| *author.as_bytes()); authors .into_iter() .filter_map(|author| self.records.get(author).cloned()) .collect() } /// Visible records, excluding versions retired by authenticated members. pub fn active_records(&self) -> Vec { self.records() .into_iter() .filter(|r| r.author_id().is_ok_and(|id| !self.is_retired(&id))) .collect() } /// Whether the current known version is at or below a proved retirement. pub fn is_retired(&self, author: &EndpointId) -> bool { self.retirements .get(author) .is_some_and(|m| self.version_of(author) <= m.target.version) } /// Strongest retained cleanup proof for an identity. pub fn retirement_of(&self, target: &EndpointId) -> Option<&SignedRetirement> { self.retirements.get(target) } /// Durable markers in target-id order, including markers surpassed by a fresh claim. pub fn retirements(&self) -> Vec { let mut markers: Vec<_> = self.retirements.values().cloned().collect(); markers.sort_by_key(|m| m.target.author); markers } /// Accepts any member's signed cleanup after checking both signatures. pub fn merge_retirement( &mut self, network: NetworkId, marker: SignedRetirement, max: usize, ) -> Result { let target = marker.verify(network)?; if let Some(old) = self.retirements.get(&target) { if !marker.supersedes(old) { return Ok(false); } } else if self.retirements.len() >= max { return Err(StateError::Malformed("retirement capacity reached")); } self.retirements.insert(target, marker); Ok(true) } /// Merges one record, verifying it first. /// /// Merging is into the existing set, never a wholesale replacement, and an /// author missing from an incoming batch is left untouched — absence is /// not deletion. pub fn merge( &mut self, network: NetworkId, record: SignedRecord, ) -> Result { let author = record.verify(network)?; match self.records.get(&author) { None => { self.records.insert(author, record); Ok(Merged::Added) } Some(existing) if existing.version < record.version => { self.records.insert(author, record); Ok(Merged::Updated) } Some(existing) if existing.version > record.version => Ok(Merged::Ignored), Some(existing) if existing.body == record.body => Ok(Merged::Ignored), Some(existing) => { // Same author, same version, different content: the author's // key is in use in two places. Neither is more true than the // other, so pick by a rule every replica computes identically // and report it rather than letting replicas diverge. if record.signature < existing.signature { self.records.insert(author, record); } Ok(Merged::Conflicted) } } } /// Merges a batch, returning what happened and the first error seen. /// /// A bad record in a batch is skipped; the rest still merge. pub fn merge_all( &mut self, network: NetworkId, records: impl IntoIterator, ) -> (Vec, Vec) { let mut outcomes = Vec::new(); let mut errors = Vec::new(); for record in records { match self.merge(network, record) { Ok(outcome) => outcomes.push(outcome), Err(err) => errors.push(err), } } (outcomes, errors) } /// Who currently holds each claimed address. /// /// When two authors claim one address, the one whose endpoint id sorts /// lower holds it — again a rule every replica computes identically. The /// other is expected to notice and claim a different one. pub fn address_holders(&self) -> HashMap { let mut holders: HashMap = HashMap::new(); for (author, record) in &self.records { if self.is_retired(author) { continue; } let Some(address) = record.body.claimed_address() else { continue; }; holders .entry(address) .and_modify(|held| { if author.as_bytes() < held.as_bytes() { *held = *author; } }) .or_insert(*author); } holders } /// Who currently holds each claimed hostname. /// /// A name belongs to the member that claimed it first, and the others /// that claim it keep working by address but are not found by that name. /// "First" is the time each author signed into its own claim, with the /// lower id deciding a tie and a claim with no time counting as last, so /// every replica reaches the same answer about who has it without /// asking anybody. It is a pure function of the records: nothing depends /// on the order they arrived in, so there is no race to lose. pub fn hostname_holders(&self) -> HashMap { let mut holders: HashMap = HashMap::new(); for (author, record) in &self.records { if self.is_retired(author) { continue; } let Some(hostname) = record.body.hostname() else { continue; }; let claim = (record.body.hostname_since().unwrap_or(u64::MAX), *author); holders .entry(hostname.to_string()) .and_modify(|held| { if (claim.0, claim.1.as_bytes()) < (held.0, held.1.as_bytes()) { *held = claim; } }) .or_insert(claim); } holders .into_iter() .map(|(hostname, (_, author))| (hostname, author)) .collect() } /// The hostname an author holds, if it holds one uncontested. pub fn hostname_of(&self, author: &EndpointId) -> Option<&str> { let hostname = self.records.get(author)?.body.hostname()?; (self.hostname_holders().get(hostname) == Some(author)).then_some(hostname) } /// The name an author claimed and the member that holds it instead, when /// somebody who claimed it earlier does. pub fn hostname_taken(&self, author: &EndpointId) -> Option<(&str, EndpointId)> { if self.is_retired(author) { return None; } let hostname = self.records.get(author)?.body.hostname()?; let holder = *self.hostname_holders().get(hostname)?; (holder != *author).then_some((hostname, holder)) } /// When an author first claimed the name it holds now, if it has one. pub fn hostname_since_of(&self, author: &EndpointId) -> Option { self.records.get(author)?.body.hostname_since() } /// The address an author holds, if it holds one uncontested. pub fn address_of(&self, author: &EndpointId) -> Option { let address = self.records.get(author)?.body.claimed_address()?; (self.address_holders().get(&address) == Some(author)).then_some(address) } /// The range the network settled on, if anybody has said. /// /// A legacy or explicit range takes precedence over the derived default, /// so a fresh member's default cannot renumber an established network. /// Other disagreements use the lowest author id, independently of arrival /// order. The signed record format and network identity remain unchanged. pub fn agreed_range(&self) -> Option { self.records .iter() .chain( self.retirements .iter() .filter(|(author, _)| !self.records.contains_key(*author)) .map(|(author, marker)| (author, &marker.target)), ) .filter_map(|(author, record)| { let range = record.body.range()?; let derived = derived_ipv4_range(NetworkId::from_bytes(record.network)); Some(((range == derived, *author.as_bytes()), range)) }) .min_by_key(|(priority, _)| *priority) .map(|(_, range)| range) } /// The highest version this author has published, as far as is known. pub fn version_of(&self, author: &EndpointId) -> u64 { self.records.get(author).map_or(0, |record| record.version) } /// Highest witnessed author counter across claims and authored cleanup. /// This differs from the claim version used to decide retirement. pub fn authored_version(&self, author: &EndpointId) -> u64 { self.retirements .values() .filter(|m| m.author == *author.as_bytes()) .map(|m| m.version) .chain(std::iter::once(self.version_of(author))) .max() .unwrap_or(0) } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; use crate::identity::{NetworkKeys, NetworkName, NetworkSecret}; fn network(name: &str) -> NetworkId { NetworkKeys::derive( &NetworkName::new(name).unwrap(), &NetworkSecret::from_bytes(vec![6u8; 32]).unwrap(), ) .network_id() } fn range() -> Ipv4Range { "10.13.37.0/24".parse().unwrap() } fn claim(address: &str) -> RecordBody { RecordBody::Claim { address: Some(address.parse().unwrap()), range: Some(range()), hostname: None, hostname_since: None, } } #[test] fn a_record_verifies_only_against_its_own_author_and_network() { let id = network("verify"); let secret = SecretKey::generate(); let record = SignedRecord::sign(&secret, id, 1, claim("10.13.37.5")); assert_eq!(record.verify(id).unwrap(), secret.public()); // A record from another network does not apply here. assert_eq!( record.verify(network("other")).unwrap_err(), StateError::WrongNetwork ); // Changing anything invalidates the signature. for tampered in [ SignedRecord { version: 2, ..record.clone() }, SignedRecord { body: claim("10.13.37.6"), ..record.clone() }, SignedRecord { author: *SecretKey::generate().public().as_bytes(), ..record.clone() }, ] { assert!(tampered.verify(id).is_err(), "tampering must be caught"); } } #[test] fn malformed_records_are_rejected_without_panicking() { let id = network("malformed"); let secret = SecretKey::generate(); let good = SignedRecord::sign(&secret, id, 1, claim("10.13.37.5")); let short_signature = SignedRecord { signature: vec![0u8; 8], ..good.clone() }; assert!(matches!( short_signature.verify(id), Err(StateError::BadSignatureLength { .. }) )); // An address outside the range it names is nonsense. let outside = SignedRecord::sign( &secret, id, 1, RecordBody::Claim { address: Some("10.99.0.1".parse().unwrap()), range: Some(range()), hostname: None, hostname_since: None, }, ); assert!(matches!(outside.verify(id), Err(StateError::Malformed(_)))); let no_hosts = SignedRecord::sign( &secret, id, 1, RecordBody::Claim { address: Some("10.13.37.1".parse().unwrap()), range: Some(Ipv4Range { base: "10.13.37.0".parse().unwrap(), prefix_len: 31, }), hostname: None, hostname_since: None, }, ); assert!(matches!(no_hosts.verify(id), Err(StateError::Malformed(_)))); } #[test] fn a_hostname_is_reduced_to_something_safe_to_store_and_compare() { // Lower-cased, so two members cannot both "own" the same name in // different cases without noticing. assert_eq!(sanitise_hostname("Music"), "music"); // Stripped, so nothing downstream can mistake a name for a path, an // option or a shell word. assert_eq!(sanitise_hostname("ab; rm -rf /"), "abrm-rf"); assert_eq!(sanitise_hostname("a/b\\c"), "abc"); assert_eq!(sanitise_hostname("host name"), "hostname"); // Bounded before anything is allocated for it. assert_eq!(sanitise_hostname(&"x".repeat(200)).len(), MAX_HOSTNAME_LEN); // A name of nothing but separators distinguishes nothing. assert_eq!(sanitise_hostname("---"), ""); assert_eq!(sanitise_hostname(""), ""); // Already canonical names survive untouched, or the check in // `verify` would reject what this produced. for name in ["music", "ab-laptop", "host.example", "a_b.c-1"] { assert_eq!(sanitise_hostname(name), name); } } #[test] fn a_hostname_that_is_not_canonical_is_rejected_rather_than_repaired() { // Repairing it would mean storing something the author never signed. let secret = SecretKey::generate(); let id = network("naming"); for bad in ["Music", "ab; rm", "x".repeat(MAX_HOSTNAME_LEN + 1).as_str()] { let record = SignedRecord::sign( &secret, id, 1, RecordBody::Claim { address: None, range: None, hostname: Some(bad.to_string()), hostname_since: None, }, ); assert!( matches!(record.verify(id), Err(StateError::Malformed(_))), "accepted {bad:?}" ); } } #[test] fn an_address_without_a_range_is_rejected() { let secret = SecretKey::generate(); let id = network("naming"); let record = SignedRecord::sign( &secret, id, 1, RecordBody::Claim { address: Some("10.13.37.5".parse().unwrap()), range: None, hostname: None, hostname_since: None, }, ); assert!(matches!(record.verify(id), Err(StateError::Malformed(_)))); } #[test] fn two_members_claiming_one_name_resolve_it_the_same_way_everywhere() { // The same rule as addresses: a name is owned, and every replica has // to reach the same answer about who owns it with nobody to ask. let a = SecretKey::generate(); let b = SecretKey::generate(); let id = network("naming"); let named = |secret: &SecretKey| { SignedRecord::sign( secret, id, 1, RecordBody::Claim { address: None, range: None, hostname: Some("music".into()), hostname_since: None, }, ) }; let mut set = StateSet::new(); set.merge(id, named(&a)).unwrap(); set.merge(id, named(&b)).unwrap(); let (lower, higher) = if a.public().as_bytes() < b.public().as_bytes() { (a.public(), b.public()) } else { (b.public(), a.public()) }; assert_eq!(set.hostname_of(&lower), Some("music")); assert_eq!( set.hostname_of(&higher), None, "the loser does not hold the name it claimed" ); // And the order the records arrived in cannot change the answer. let mut reversed = StateSet::new(); reversed.merge(id, named(&b)).unwrap(); reversed.merge(id, named(&a)).unwrap(); assert_eq!(reversed.hostname_of(&lower), Some("music")); } #[test] fn changing_a_name_revokes_the_old_one_everywhere() { // There is one record per author, so a new version replaces the whole // claim. The previous name cannot survive beside it. let secret = SecretKey::generate(); let id = network("naming"); let claim = |version, name: &str| { SignedRecord::sign( &secret, id, version, RecordBody::Claim { address: None, range: None, hostname: Some(name.to_string()), hostname_since: None, }, ) }; let mut set = StateSet::new(); set.merge(id, claim(1, "old")).unwrap(); set.merge(id, claim(2, "new")).unwrap(); assert_eq!(set.hostname_of(&secret.public()), Some("new")); assert!( !set.hostname_holders().contains_key("old"), "the old name is gone, not merely shadowed" ); // A replica that has not heard of the change cannot bring it back. set.merge(id, claim(1, "old")).unwrap(); assert_eq!(set.hostname_of(&secret.public()), Some("new")); } fn named_at( secret: &SecretKey, id: NetworkId, version: u64, name: &str, since: u64, ) -> SignedRecord { SignedRecord::sign( secret, id, version, RecordBody::Claim { address: None, range: None, hostname: Some(name.into()), hostname_since: Some(since), }, ) } #[test] fn a_name_belongs_to_whoever_claimed_it_first_whatever_their_ids_or_arrival_order() { let id = network("naming"); let mut keys = [SecretKey::generate(), SecretKey::generate()]; keys.sort_by_key(|key| *key.public().as_bytes()); let [low, high] = keys; // The member with the higher id claimed first, so it holds the name, // and the lower id does not take it back by sorting first. let early = named_at(&high, id, 1, "music", 1_000); let late = named_at(&low, id, 1, "music", 2_000); for order in [[&early, &late], [&late, &early]] { let mut set = StateSet::new(); for record in order { set.merge(id, (*record).clone()).unwrap(); } assert_eq!(set.hostname_of(&high.public()), Some("music")); assert_eq!(set.hostname_of(&low.public()), None); assert_eq!( set.hostname_taken(&low.public()), Some(("music", high.public())) ); assert_eq!(set.hostname_taken(&high.public()), None); } // At the same instant the lower id decides, the same way everywhere. let mut set = StateSet::new(); set.merge(id, named_at(&high, id, 1, "music", 5)).unwrap(); set.merge(id, named_at(&low, id, 1, "music", 5)).unwrap(); assert_eq!(set.hostname_of(&low.public()), Some("music")); assert_eq!(set.hostname_of(&high.public()), None); } #[test] fn a_name_that_loses_is_found_again_once_the_holder_lets_it_go() { let id = network("naming"); let first = SecretKey::generate(); let second = SecretKey::generate(); let mut set = StateSet::new(); set.merge(id, named_at(&first, id, 1, "music", 1)).unwrap(); set.merge(id, named_at(&second, id, 1, "music", 2)).unwrap(); assert_eq!(set.hostname_of(&second.public()), None); // Renaming starts the clock afresh, so it cannot be used to jump the // queue for a name somebody else already holds. set.merge(id, named_at(&first, id, 2, "other", 3)).unwrap(); assert_eq!(set.hostname_of(&second.public()), Some("music")); set.merge(id, named_at(&first, id, 3, "music", 4)).unwrap(); assert_eq!(set.hostname_of(&second.public()), Some("music")); assert_eq!(set.hostname_of(&first.public()), None); // And a release hands it on. set.merge(id, SignedRecord::sign(&second, id, 2, RecordBody::Release)) .unwrap(); assert_eq!(set.hostname_of(&first.public()), Some("music")); } #[test] fn a_time_with_no_name_is_not_a_valid_claim() { let id = network("naming"); let secret = SecretKey::generate(); let record = SignedRecord::sign( &secret, id, 1, RecordBody::Claim { address: None, range: None, hostname: None, hostname_since: Some(1), }, ); assert!(matches!(record.verify(id), Err(StateError::Malformed(_)))); // The time is signed: moving it breaks the signature, so nobody can // improve another member's place in the queue. let mut forged = named_at(&secret, id, 1, "music", 9_000); if let RecordBody::Claim { hostname_since, .. } = &mut forged.body { *hostname_since = Some(1); } assert_eq!(forged.verify(id).unwrap_err(), StateError::BadSignature); } #[test] fn a_release_gives_up_the_name_as_well_as_the_address() { let secret = SecretKey::generate(); let id = network("naming"); let mut set = StateSet::new(); set.merge( id, SignedRecord::sign( &secret, id, 1, RecordBody::Claim { address: Some("10.13.37.5".parse().unwrap()), range: Some(range()), hostname: Some("music".into()), hostname_since: None, }, ), ) .unwrap(); set.merge(id, SignedRecord::sign(&secret, id, 2, RecordBody::Release)) .unwrap(); assert_eq!(set.address_of(&secret.public()), None); assert_eq!(set.hostname_of(&secret.public()), None); assert!(set.address_holders().is_empty()); assert!(set.hostname_holders().is_empty()); } #[test] fn a_newer_version_wins_and_an_older_one_never_rolls_back() { let id = network("versions"); let secret = SecretKey::generate(); let mut set = StateSet::new(); let first = SignedRecord::sign(&secret, id, 1, claim("10.13.37.5")); let second = SignedRecord::sign(&secret, id, 2, claim("10.13.37.6")); assert_eq!(set.merge(id, first.clone()).unwrap(), Merged::Added); assert_eq!(set.merge(id, second.clone()).unwrap(), Merged::Updated); // The old one coming back later must not undo the new one. assert_eq!(set.merge(id, first).unwrap(), Merged::Ignored); assert_eq!( set.address_of(&secret.public()), Some("10.13.37.6".parse().unwrap()) ); // Merging the same record twice changes nothing. assert_eq!(set.merge(id, second).unwrap(), Merged::Ignored); assert_eq!(set.len(), 1); } #[test] fn two_authors_claiming_one_address_resolve_the_same_way_everywhere() { let id = network("clash"); let (low, high) = { let a = SecretKey::generate(); let b = SecretKey::generate(); if a.public().as_bytes() < b.public().as_bytes() { (a, b) } else { (b, a) } }; let record_low = SignedRecord::sign(&low, id, 1, claim("10.13.37.5")); let record_high = SignedRecord::sign(&high, id, 1, claim("10.13.37.5")); // Merge order must not change the outcome. let mut forwards = StateSet::new(); forwards.merge(id, record_low.clone()).unwrap(); forwards.merge(id, record_high.clone()).unwrap(); let mut backwards = StateSet::new(); backwards.merge(id, record_high).unwrap(); backwards.merge(id, record_low).unwrap(); let expected = Some(low.public()); assert_eq!( forwards .address_holders() .get(&"10.13.37.5".parse().unwrap()), expected.as_ref() ); assert_eq!( backwards .address_holders() .get(&"10.13.37.5".parse().unwrap()), expected.as_ref() ); // The loser holds nothing, and is expected to pick again. assert_eq!(forwards.address_of(&high.public()), None); assert_eq!( forwards.address_of(&low.public()), Some("10.13.37.5".parse().unwrap()) ); } #[test] fn one_key_used_in_two_places_is_reported_not_silently_merged() { let id = network("split-brain"); let secret = SecretKey::generate(); let mut set = StateSet::new(); let here = SignedRecord::sign(&secret, id, 3, claim("10.13.37.5")); let there = SignedRecord::sign(&secret, id, 3, claim("10.13.37.9")); set.merge(id, here.clone()).unwrap(); assert_eq!(set.merge(id, there.clone()).unwrap(), Merged::Conflicted); // Whatever it picked, it must pick the same thing from the other side. let mut other = StateSet::new(); other.merge(id, there).unwrap(); assert_eq!(other.merge(id, here).unwrap(), Merged::Conflicted); assert_eq!( set.get(&secret.public()).unwrap(), other.get(&secret.public()).unwrap() ); } #[test] fn a_release_is_a_statement_that_survives_merging() { let id = network("release"); let secret = SecretKey::generate(); let mut set = StateSet::new(); set.merge(id, SignedRecord::sign(&secret, id, 1, claim("10.13.37.5"))) .unwrap(); assert!(set.address_of(&secret.public()).is_some()); set.merge(id, SignedRecord::sign(&secret, id, 2, RecordBody::Release)) .unwrap(); assert_eq!(set.address_of(&secret.public()), None); assert!(set.address_holders().is_empty()); // The old claim arriving late does not resurrect the address. assert_eq!( set.merge(id, SignedRecord::sign(&secret, id, 1, claim("10.13.37.5"))) .unwrap(), Merged::Ignored ); assert_eq!(set.address_of(&secret.public()), None); } #[test] fn a_batch_with_one_bad_record_still_merges_the_rest() { let id = network("batch"); let good = SecretKey::generate(); let mut set = StateSet::new(); let valid = SignedRecord::sign(&good, id, 1, claim("10.13.37.5")); let forged = SignedRecord { signature: vec![0u8; Signature::LENGTH], ..SignedRecord::sign(&SecretKey::generate(), id, 1, claim("10.13.37.6")) }; let (outcomes, errors) = set.merge_all(id, [forged, valid]); assert_eq!(outcomes, vec![Merged::Added]); assert_eq!(errors, vec![StateError::BadSignature]); assert_eq!(set.len(), 1); } #[test] fn a_later_joiner_adopts_the_range_already_in_use() { let id = network("ranges"); let mut set = StateSet::new(); assert_eq!(set.agreed_range(), None, "nothing known yet"); let custom: Ipv4Range = "10.99.0.0/16".parse().unwrap(); let author = SecretKey::generate(); set.merge( id, SignedRecord::sign( &author, id, 1, RecordBody::Claim { address: Some("10.99.0.7".parse().unwrap()), range: Some(custom), hostname: None, hostname_since: None, }, ), ) .unwrap(); assert_eq!(set.agreed_range(), Some(custom)); } #[test] fn the_signed_bytes_are_unambiguous() { let id = network("encoding"); let author = SecretKey::generate().public(); // Two bodies whose parts would concatenate identically must not // produce the same signed bytes. let a = SignedRecord::canonical_bytes(id, author, 1, &claim("10.13.37.5")); let b = SignedRecord::canonical_bytes(id, author, 1, &claim("10.13.37.6")); assert_ne!(a, b); assert_ne!( a, SignedRecord::canonical_bytes(id, author, 2, &claim("10.13.37.5")) ); assert_ne!( a, SignedRecord::canonical_bytes(network("other"), author, 1, &claim("10.13.37.5")) ); } #[test] fn a_derived_range_is_the_same_wherever_it_is_derived() { // Every member has to arrive at it without being told, so it comes // from the one thing they all already agree on. let network = NetworkKeys::derive( &NetworkName::new("second").unwrap(), &NetworkSecret::from_bytes([7u8; 32]).unwrap(), ) .network_id(); let once = derived_ipv4_range(network); assert_eq!(once, derived_ipv4_range(network)); assert_eq!(once.prefix_len, 24); assert_eq!(once.base.octets()[0], 10, "inside 10/8 like the default"); assert_ne!( once, DEFAULT_IPV4_RANGE, "the legacy default remains excluded" ); // A different network derives a different range, which is the // whole point of deriving it. let other = NetworkKeys::derive( &NetworkName::new("third").unwrap(), &NetworkSecret::from_bytes([9u8; 32]).unwrap(), ) .network_id(); assert_ne!(once, derived_ipv4_range(other)); } #[test] fn a_derived_default_cannot_displace_a_legacy_or_explicit_range() { let id = network("range-migration"); let a = SecretKey::generate(); let b = SecretKey::generate(); let (low, high) = if a.public().as_bytes() < b.public().as_bytes() { (a, b) } else { (b, a) }; for established in [DEFAULT_IPV4_RANGE, "10.90.0.0/16".parse().unwrap()] { let body = |range: Ipv4Range| RecordBody::Claim { address: Some(Ipv4Addr::from(u32::from(range.base) + 1)), range: Some(range), hostname: None, hostname_since: None, }; let fresh = SignedRecord::sign(&low, id, 1, body(derived_ipv4_range(id))); let existing = SignedRecord::sign(&high, id, 1, body(established)); for records in [ vec![fresh.clone(), existing.clone()], vec![existing.clone(), fresh.clone()], ] { let mut state = StateSet::new(); state.merge_all(id, records); assert_eq!(state.agreed_range(), Some(established)); } } } }