//! Names that are computed rather than drawn from a list. //! //! [`fragments::Fragments`](crate::fragments::Fragments) is finite in a way //! that matters at scale: `mineral+creature` is 128 words against 112, about //! fourteen thousand names, and [`Naming::issue`](../../didbot_pds/struct.Naming.html)'s //! retry loop makes the usable ceiling nearer eleven thousand. See //! `plan/zone-scale.md`. The namers in this module do not draw from a fixed //! set at all, so "how many names are left" is either "effectively never //! runs out" or "as many as the deployment configured", and each one says //! which in its own docs. //! //! Every namer here still goes through [`check`], the same DNS-label //! validator [`fragments::Fragments`](crate::fragments::Fragments) and //! [`command::CommandNamer`](crate::command::CommandNamer) use, and that //! validator is [`didbot_identity::validate_label`] — the same function that //! decides whether a label can become part of a `did:web` identifier. There //! is one legality rule, not one for DNS and a second one somebody hoped //! agreed with it. //! //! # What a generated name gives up //! //! A word pair leaks nothing about the deployment: `basalt-otter` says //! nothing about when it was minted or how many agents came before it. Every //! namer below trades that away for something else, and what it trades away //! is documented on the type rather than left to be discovered from traffic: //! //! * [`Counter`] tells any observer exactly how many accounts this deployment //! has ever minted — the counter's value *is* that count. So does //! [`fragments::Fragments`](crate::fragments::Fragments)'s `{n}` slot, which //! draws from the same counter: `basalt-otter-4813` discloses everything //! `agent-4813` does. //! * [`Timestamp`] tells an observer when an account was minted, to the //! second. So does //! [`fragments::Fragments`](crate::fragments::Fragments)'s `{t}` slot, //! which reads the same clock. //! * [`Uuid`] and [`Random`] leak nothing beyond their own existence: a //! uniformly random identifier carries no information about the population //! it was drawn from. These are the private-by-default choice among the //! generated forms, the same role the word lists play among the fixed ones. use std::fmt; use std::sync::Arc; use crate::{check, tidy, NameError, Namer, Seed}; /// Where a [`Counter`] gets its next value, and remembers it past a restart. /// /// This crate has no notion of disk or of a write-ahead log — see the crate /// docs — so it does not implement this itself. `didbot-pds` does, backed by /// the same log [`crate::fragments`]'s deployment already writes claims and /// releases to: a value is durable before [`CounterSource::next`] returns it, /// the same check-before-apply order the rest of that log keeps. A counter /// that only lived in memory would hand out `0` again after every restart, /// which is not monotonic, it only looks monotonic between restarts. pub trait CounterSource: Send + Sync + fmt::Debug { /// The next value, guaranteed not equal to any value this has returned /// before — including before a restart, if the implementation is durable. /// /// Fallible, unlike everything else a namer touches here, because a /// durable implementation's whole reason to exist is the write reaching /// disk before the value is handed out: an implementation that could not /// make that promise this call must say so rather than hand out a value /// it cannot back. The error is a message rather than a type, so this /// crate is not made to know the shape of whatever failed underneath it. fn next(&self) -> Result; } /// How a number — a counter's value or a timestamp — is written into a label. /// /// Both spellings are lowercase digits and letters at every value, so a label /// carrying one is legal wherever a label carrying the other is. They differ /// in length and in what a person reads back: decimal is the number itself, /// base32 is shorter once the count passes a million. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum NumberEncoding { /// Decimal digits, no padding: `0`, `4813`, `18446744073709551615`. The /// count as anyone would write it, and 20 characters at its longest. #[default] Decimal, /// Crockford base32, lowercase, no padding: `0`, `4nd`, `fzzzzzzzzzzzz`. /// The alphabet drops `i`, `l`, `o` and `u`, so nothing in a label is /// confusable with a digit or spells a word, and the longest value is 13 /// characters. Base32, } impl NumberEncoding { /// The label text for `value`. pub fn render(self, value: u64) -> String { match self { Self::Decimal => value.to_string(), Self::Base32 => to_crockford(value), } } /// How many characters the longest value takes, which is the room a /// template has to leave for the slot. pub const fn widest(self) -> usize { match self { // u64::MAX is 20 digits; 13 base32 symbols carry 65 bits. Self::Decimal => 20, Self::Base32 => 13, } } /// What the configuration calls this, and what parsing one takes. pub const fn spelling(self) -> &'static str { match self { Self::Decimal => "decimal", Self::Base32 => "base32", } } } impl fmt::Display for NumberEncoding { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.spelling()) } } impl std::str::FromStr for NumberEncoding { type Err = NameError; fn from_str(raw: &str) -> Result { match raw { "decimal" => Ok(Self::Decimal), "base32" => Ok(Self::Base32), other => Err(NameError::Misconfigured(format!( "{other:?} is not a number encoding; write `decimal` or `base32`" ))), } } } /// A namer backed by a counter that only ever goes up. /// /// # Pool size /// /// `u64::MAX`, which is not a number a deployment will reach: at one /// provisioning a second, sustained, it is filled in a little under six /// hundred thousand centuries. Call it unbounded. It is also the only namer /// in this crate that cannot collide with itself — every value /// [`CounterSource::next`] returns is one no earlier call returned — so it /// asks the caller for at most one attempt; see /// [`Namer::max_useful_attempts`]. /// /// # What it leaks /// /// The count itself. `agent-4813` says this deployment has minted at least /// four thousand eight hundred and thirteen accounts, to anyone who can see /// one label. A deployment that would rather not publish its population uses /// a word list, [`Uuid`], or [`Random`] instead. /// /// [`fragments::Fragments`](crate::fragments::Fragments)'s `{n}` slot draws /// from the same source and writes it with the same [`NumberEncoding`], so a /// deployment can have a word and a count in one label. #[derive(Debug, Clone)] pub struct Counter { source: Arc, prefix: String, encoding: NumberEncoding, } impl Counter { /// A counter namer drawing from `source`, labelled `agent-`. pub fn new(source: Arc) -> Self { Self { source, prefix: "agent-".to_owned(), encoding: NumberEncoding::default(), } } /// Uses `prefix` instead of `agent-`. /// /// Not validated until the first name is asked for — [`check`] runs on /// the assembled label either way, so a prefix that would make every name /// illegal is caught there rather than twice. #[must_use] pub fn with_prefix(mut self, prefix: impl Into) -> Self { self.prefix = prefix.into(); self } /// Writes the count in `encoding` instead of decimal. #[must_use] pub fn with_encoding(mut self, encoding: NumberEncoding) -> Self { self.encoding = encoding; self } } impl Namer for Counter { fn suggest(&self, _seed: &Seed) -> Result { let value = self .source .next() .map_err(|error| NameError::Failed(format!("counter: {error}")))?; let name = tidy(&format!("{}{}", self.prefix, self.encoding.render(value))); check(&name)?; Ok(name) } fn describe(&self) -> String { format!( "counter, {} (unbounded; never collides with itself; the label discloses how many accounts this deployment has ever minted)", self.encoding ) } fn max_useful_attempts(&self) -> Option { // A collision here is not the counter's bad luck to try again for — // it means something else holds a name the counter never handed out, // and asking the same source for the same-shaped next value again // does not change that. One attempt is the whole budget it can use. Some(1) } } /// The current unix time, in whole seconds. /// /// The one clock this crate reads, so [`Timestamp`] and /// [`fragments::Fragments`](crate::fragments::Fragments)'s `{t}` slot always /// agree about what time it is. Zero before the epoch, which is a clock so /// wrong that naming is not what is broken. pub fn unix_seconds() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|elapsed| elapsed.as_secs()) .unwrap_or(0) } /// A namer that stamps the current time, in whole seconds. /// /// # Pool size /// /// One name per second. Two accounts minted in the same second collide, /// which is why this namer does *not* claim /// [`Namer::max_useful_attempts`]: it uses the ordinary retry budget, and /// folds the attempt number into the label on any attempt past the first so /// a retry is not the same collision twice. /// /// # What it leaks /// /// When each account was minted, to the second. A deployment that publishes /// hostnames publicly is publishing its provisioning timeline alongside them. /// /// [`fragments::Fragments`](crate::fragments::Fragments)'s `{t}` slot reads /// the same clock and writes it with the same [`NumberEncoding`], so a /// deployment can have a word and a time in one label. #[derive(Debug, Clone, Copy, Default)] pub struct Timestamp { encoding: NumberEncoding, } impl Timestamp { /// A namer that stamps the clock, labelled `t`. pub fn new() -> Self { Self::default() } /// Writes the seconds in `encoding` instead of decimal. #[must_use] pub fn with_encoding(mut self, encoding: NumberEncoding) -> Self { self.encoding = encoding; self } /// The label this namer makes at `seconds`, on `attempt`. /// /// The clock is read once, by [`Namer::suggest`], and handed in here, so /// a test can say what moment it is naming at. pub(crate) fn label(&self, seconds: u64, attempt: u32) -> String { let mut name = format!("t{}", self.encoding.render(seconds)); if attempt > 0 { name.push('-'); name.push_str(&attempt.to_string()); } name } } impl Namer for Timestamp { fn suggest(&self, seed: &Seed) -> Result { let name = tidy(&self.label(unix_seconds(), seed.attempt)); check(&name)?; Ok(name) } fn describe(&self) -> String { format!( "timestamp, {} (one name per second; the label discloses when each account was minted, to the second)", self.encoding ) } } /// A namer that draws a fresh UUID (version 4, i.e. all-random) per name. /// /// # Pool size /// /// 2^122 — 122 bits of randomness after the version and variant bits are /// fixed, per RFC 9562. The birthday bound on that is far past anything a /// deployment will ever mint; a collision is not a case this namer plans for /// any harder than [`Random`] does. /// /// # Canonical form only /// /// [`Uuid::new`] emits the canonical `8-4-4-4-12` hyphenated lowercase hex /// form. Its hyphens land in fixed interior positions — never first, never /// last — which is exactly what [`check`] requires, and that is not a /// coincidence worth assuming holds for other encodings: the RFC 4648 /// base64 alphabet includes `+` and `/`, and this crate's own [`Random`] /// alphabets exist because an *uppercase* rendering would fail the same /// check that lowercase hex passes. [`Uuid::compact`] drops the hyphens /// entirely — 32 lowercase hex characters, still legal — for a shorter label; /// nothing here offers any encoding beyond those two, on purpose. /// /// # What it leaks /// /// Nothing beyond its own existence. A uniformly random 122-bit value carries /// no information about how many agents came before it or when it was /// minted — the private-by-default generated form, alongside [`Random`]. #[derive(Debug, Clone, Copy, Default)] pub struct Uuid { compact: bool, } impl Uuid { /// Canonical `8-4-4-4-12` hyphenated form, 36 characters. pub fn new() -> Self { Self { compact: false } } /// 32 lowercase hex characters, no hyphens. pub fn compact() -> Self { Self { compact: true } } } impl Namer for Uuid { fn suggest(&self, _seed: &Seed) -> Result { let mut bytes = rand::random::<[u8; 16]>(); // Version 4 and the RFC 9562 variant bits. Cosmetic here — nothing // reads a version out of a hostname label — but a "UUID" that skips // them is not one, and setting them costs two lines. bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; let hex = to_hex(&bytes); let name = if self.compact { hex } else { format!( "{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32] ) }; check(&name)?; Ok(name) } fn describe(&self) -> String { if self.compact { "uuid v4, compact (2^122 possible names; discloses nothing about the population)" .to_owned() } else { "uuid v4, canonical (2^122 possible names; discloses nothing about the population)" .to_owned() } } } /// Which characters a [`Random`] namer draws from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Alphabet { /// `234567abcdefghijklmnopqrstuvwxyz` — base32, missing the digits and /// letters most often confused for each other or for punctuation /// (`0`/`o`, `1`/`l`/`i`). What an operator reads off a screen and types /// back is what this is for. Base32, /// `0123456789abcdefghijklmnopqrstuvwxyz` — base36, every lowercase /// letter and digit. Denser than base32 at the same length, at the cost /// of the ambiguous characters base32 leaves out. Base36, } impl Alphabet { fn chars(self) -> &'static [u8] { match self { Self::Base32 => b"234567abcdefghijklmnopqrstuvwxyz", Self::Base36 => b"0123456789abcdefghijklmnopqrstuvwxyz", } } } /// A namer that draws a fixed-length random string per name. /// /// # Pool size /// /// `alphabet_size ^ length`, reported by [`Random::pool_size`] and folded /// into [`Random::describe`] so a deployment configuring one can see the /// number rather than compute it: base36 at 10 characters is a little over /// 3.6 × 10^15, base32 at the same length a little under 1.1 × 10^15. Like /// any drawn-not-derived namer it can collide, so it takes the ordinary /// sixteen-attempt retry budget rather than claiming /// [`Namer::max_useful_attempts`]. /// /// # What it leaks /// /// Nothing beyond its own existence — see [`Uuid`]'s docs, which apply here /// unchanged. #[derive(Debug, Clone)] pub struct Random { alphabet: Alphabet, length: usize, } impl Random { /// A namer drawing `length` characters from `alphabet`. /// /// Refused at construction, not at the first name, for the same reason /// [`Fragments::new`](crate::fragments::Fragments::new) checks its slots /// up front: a namer that starts and then cannot produce a single legal /// name is worse than one that refuses to start. pub fn new(alphabet: Alphabet, length: usize) -> Result { if length == 0 { return Err(NameError::Misconfigured( "a random name needs at least one character".to_owned(), )); } if length > crate::MAX_NAME { return Err(NameError::Misconfigured(format!( "a random name of {length} characters is longer than a DNS label may be ({})", crate::MAX_NAME ))); } Ok(Self { alphabet, length }) } /// How many distinct names this can produce, before a collision is even /// possible: `alphabet_size ^ length`. /// /// Saturating, because [`Random::new`] accepts every length a DNS label /// admits and the answer stops fitting in a `u128` long before that: 25 /// base36 characters already exceeds it. Past that point the exact count /// is not a number anybody acts on — it is far beyond any birthday bound /// a deployment can reach — whereas an arithmetic overflow in what is /// only ever a reported figure is a panic on the path that reports it. pub fn pool_size(&self) -> u128 { (self.alphabet.chars().len() as u128).saturating_pow(self.length as u32) } } impl Namer for Random { fn suggest(&self, _seed: &Seed) -> Result { let alphabet = self.alphabet.chars(); let mut name = String::with_capacity(self.length); for _ in 0..self.length { // Not cryptographic and does not need to be: a collision here // is an availability problem, not a security one, since nothing // is authorized by the label. let index = (rand::random::() as usize) % alphabet.len(); name.push(char::from(alphabet[index])); } let name = tidy(&name); check(&name)?; Ok(name) } fn describe(&self) -> String { format!( "random {:?}, {} characters ({} possible names)", self.alphabet, self.length, self.pool_size() ) } } /// Renders `value` as lowercase Crockford base32, with no padding. /// /// Crockford's alphabet rather than RFC 4648's, because the four symbols it /// leaves out — `i`, `l`, `o` and `u` — are the ones a person reading a /// hostname off a screen confuses with `1` and `0`, and dropping `u` keeps /// the encoding from spelling anything. Lowercase, because [`check`] refuses /// an uppercase label. fn to_crockford(mut value: u64) -> String { const DIGITS: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz"; if value == 0 { return "0".to_owned(); } let mut out = Vec::new(); while value > 0 { out.push(DIGITS[(value % 32) as usize]); value /= 32; } out.reverse(); String::from_utf8(out).expect("crockford digits are ascii") } /// Lowercase hex, dependency-free: this is a handful of lines, not a reason /// to add a crate. fn to_hex(bytes: &[u8]) -> String { const DIGITS: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); for byte in bytes { out.push(char::from(DIGITS[(byte >> 4) as usize])); out.push(char::from(DIGITS[(byte & 0x0f) as usize])); } out } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; /// The zone this crate's tests mint labels under. They assert against /// `didbot_identity::AccountDid::mint` rather than a local copy of the /// rules, so a test can only pass by agreeing with the actual authority. fn mints(label: &str) -> bool { let zone = didbot_identity::Zone::new("agents.example") .expect("agents.example is a valid zone host"); didbot_identity::AccountDid::mint(&zone, label).is_ok() } #[derive(Debug)] struct TestSource(AtomicU64); impl CounterSource for TestSource { fn next(&self) -> Result { Ok(self.0.fetch_add(1, Ordering::SeqCst)) } } #[test] fn a_counter_never_repeats_and_always_mints() { for encoding in [NumberEncoding::Decimal, NumberEncoding::Base32] { let counter = Counter::new(Arc::new(TestSource(AtomicU64::new(0)))).with_encoding(encoding); let mut seen = std::collections::HashSet::new(); for attempt in 0..500 { let seed = Seed::new("tok", "agents.example").with_parent(None); let seed = Seed { attempt, ..seed }; let name = counter.suggest(&seed).expect("counter always succeeds"); assert!(check(&name).is_ok(), "{name:?} failed check()"); assert!(mints(&name), "{name:?} did not mint"); assert!(seen.insert(name.clone()), "{name:?} repeated"); } assert_eq!(counter.max_useful_attempts(), Some(1)); assert!(counter.describe().contains(encoding.spelling())); } } /// The last value the counter can reach still mints, in either encoding: /// a deployment does not discover at some future count that its namer /// has stopped producing legal labels. #[test] fn a_counter_at_its_last_value_still_mints() { for encoding in [NumberEncoding::Decimal, NumberEncoding::Base32] { let counter = Counter::new(Arc::new(TestSource(AtomicU64::new(u64::MAX)))) .with_encoding(encoding); let name = counter .suggest(&Seed::new("tok", "agents.example")) .expect("counter always succeeds"); assert!(check(&name).is_ok(), "{name:?} failed check()"); assert!(mints(&name), "{name:?} did not mint"); assert!(name.ends_with(&encoding.render(u64::MAX)), "{name:?}"); } } #[test] fn a_counter_with_a_custom_prefix_still_mints() { let counter = Counter::new(Arc::new(TestSource(AtomicU64::new(0)))).with_prefix("worker-"); let seed = Seed::new("tok", "agents.example"); let name = counter.suggest(&seed).expect("a legal prefix mints"); assert!(name.starts_with("worker-")); assert!(mints(&name), "{name:?}"); } #[test] fn timestamps_mint_and_vary_with_the_attempt() { for encoding in [NumberEncoding::Decimal, NumberEncoding::Base32] { for attempt in [0u32, 1, 2, 15] { let seed = Seed { attempt, ..Seed::new("tok", "agents.example") }; let name = Timestamp::new() .with_encoding(encoding) .suggest(&seed) .expect("clock is readable"); assert!(check(&name).is_ok(), "{name:?}"); assert!(mints(&name), "{name:?}"); if attempt > 0 { assert!(name.ends_with(&format!("-{attempt}")), "{name:?}"); } } } } /// A clock that has run far past today still names: the label is legal /// in both encodings at the year 2100 and at the last second a `u64` /// can hold. #[test] fn a_timestamp_label_is_legal_at_the_far_end_of_the_clock() { // 2100-01-01T00:00:00Z, and the end of the range itself. for seconds in [4_102_444_800u64, u64::MAX] { for encoding in [NumberEncoding::Decimal, NumberEncoding::Base32] { for attempt in [0u32, 15] { let name = tidy( &Timestamp::new() .with_encoding(encoding) .label(seconds, attempt), ); assert!(check(&name).is_ok(), "{name:?} failed check()"); assert!(mints(&name), "{name:?} did not mint"); } } } } #[test] fn many_uuids_are_legal_labels_in_both_forms() { for uuid in [Uuid::new(), Uuid::compact()] { for _ in 0..500 { let name = uuid .suggest(&Seed::new("tok", "agents.example")) .expect("uuid generation cannot fail"); assert!(check(&name).is_ok(), "{name:?} failed check()"); assert!(mints(&name), "{name:?} did not mint"); } } } #[test] fn a_canonical_uuid_has_hyphens_only_in_the_legal_positions() { let name = Uuid::new() .suggest(&Seed::new("tok", "agents.example")) .expect("uuid generation cannot fail"); assert_eq!(name.len(), 36); for (index, ch) in name.char_indices() { if [8, 13, 18, 23].contains(&index) { assert_eq!(ch, '-', "{name:?} at {index}"); } else { assert!( ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase(), "{name:?} at {index}" ); } } } #[test] fn a_compact_uuid_has_no_hyphens() { let name = Uuid::compact() .suggest(&Seed::new("tok", "agents.example")) .expect("uuid generation cannot fail"); assert_eq!(name.len(), 32); assert!(!name.contains('-')); } #[test] fn random_names_of_every_configured_length_mint() { for alphabet in [Alphabet::Base32, Alphabet::Base36] { for length in [1usize, 4, 16, 63] { let namer = Random::new(alphabet, length).expect("a legal length constructs"); for _ in 0..200 { let name = namer .suggest(&Seed::new("tok", "agents.example")) .expect("random generation cannot fail"); assert_eq!(name.len(), length, "{name:?}"); assert!(check(&name).is_ok(), "{name:?} failed check()"); assert!(mints(&name), "{name:?} did not mint"); } } } } /// [`Random::new`] accepts any length a DNS label admits, and `describe` /// is called on the provisioning path — including from the log line the /// personal data server writes when a namer refuses. The reported figure /// overflowing must not take that path down with it. #[test] fn a_random_namer_long_enough_to_overflow_still_describes_itself() { for alphabet in [Alphabet::Base32, Alphabet::Base36] { let namer = Random::new(alphabet, crate::MAX_NAME).expect("a legal length"); assert_eq!(namer.pool_size(), u128::MAX); assert!(namer.describe().contains("possible names")); // And it still names, which is the point of allowing the length. let name = namer .suggest(&Seed::new("token-a", "agents.example")) .expect("a legal length names"); assert_eq!(name.len(), crate::MAX_NAME); } } #[test] fn random_pool_size_is_alphabet_to_the_length() { let namer = Random::new(Alphabet::Base36, 10).expect("legal"); assert_eq!(namer.pool_size(), 36u128.pow(10)); } #[test] fn random_refuses_zero_length_and_over_length_labels() { assert!(Random::new(Alphabet::Base36, 0).is_err()); assert!(Random::new(Alphabet::Base36, crate::MAX_NAME + 1).is_err()); assert!(Random::new(Alphabet::Base36, crate::MAX_NAME).is_ok()); } }