didbot_identity/did.rs
1//! Minting and validating the `did:web` identifiers that agents get.
2
3use std::fmt;
4
5/// Hard cap on DID length, from the atproto DID specification: "DID
6/// identifiers do not generally have a maximum length restriction, but in the
7/// context of atproto, there is a current hard limit of 2048 characters."
8pub const MAX_DID_LENGTH: usize = 2048;
9
10/// The prefix every DID this crate handles starts with.
11const DID_WEB_PREFIX: &str = "did:web:";
12
13/// Path the DID document is served at, per the `did:web` method.
14const DID_JSON_PATH: &str = "/.well-known/did.json";
15
16/// The only percent escape an atproto `did:web` may contain: the colon that
17/// separates a bare loopback hostname from its port. Uppercase hex only,
18/// because the spec says "DIDs are case-sensitive" and implementations
19/// "should reject DIDs with invalid casing" — one spelling keeps comparison
20/// a byte compare.
21///
22/// Nothing here mints a DID carrying one. A zone's accounts answer on 443,
23/// so their DIDs name a hostname and nothing else; this is read, not
24/// written, so a DID somebody else minted still parses.
25const ENCODED_COLON: &str = "%3A";
26
27/// Why a string is not a usable agent `did:web`.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum DidError {
30 /// The string does not start with `did:web:`.
31 #[error("not a did:web identifier: {0:?}")]
32 NotDidWeb(String),
33 /// Longer than the atproto cap of 2048 characters.
34 #[error("did is {0} characters, over the atproto limit of {MAX_DID_LENGTH}")]
35 TooLong(usize),
36 /// The method-specific identifier is empty.
37 #[error("did:web with an empty identifier")]
38 EmptyIdentifier,
39 /// A literal colon appeared in the identifier. In `did:web` that is the
40 /// path separator, and atproto supports hostname-level DIDs only.
41 #[error("path-based did:web is not supported in atproto: {0:?}")]
42 PathBased(String),
43 /// A percent escape that would decode to `/`, which would graft a path
44 /// onto the fetch URL.
45 #[error("did contains an encoded path separator: {0:?}")]
46 EncodedPathSeparator(String),
47 /// Some percent escape other than the port colon.
48 #[error("unsupported percent escape in did: {0:?}")]
49 BadPercentEscape(String),
50 /// A character outside the allowed subset, or an uppercase letter in a
51 /// hostname that must be lowercase to be comparable.
52 #[error("invalid character {0:?} in did:web hostname")]
53 InvalidCharacter(char),
54 /// A `..`, a leading `.`, or a trailing `.` produced an empty DNS label.
55 #[error("empty hostname label in {0:?}")]
56 EmptyLabel(String),
57 /// A label starting or ending with a hyphen.
58 #[error("hostname label {0:?} starts or ends with a hyphen")]
59 HyphenEdge(String),
60 /// A port on something that is not localhost. Ports are development-only.
61 #[error("port numbers are only allowed for localhost, not {0:?}")]
62 PortOnNonLocalhost(String),
63 /// The port is missing, empty, or not a number.
64 #[error("invalid port {0:?}")]
65 InvalidPort(String),
66 /// A hostname under `.arpa`, which handles and `did:web` both exclude.
67 #[error("the .arpa top-level domain is not allowed")]
68 ArpaDomain,
69 /// The agent id offered to [`AccountDid::mint`] is not a single DNS label.
70 #[error("agent id {0:?} is not a usable hostname label")]
71 InvalidAccountId(String),
72 /// The zone host offered to [`Zone::new`] is not a usable hostname.
73 #[error("zone host {0:?} is not a usable hostname")]
74 InvalidZoneHost(String),
75 /// The PDS hostname is not a usable hostname.
76 #[error("pds host {0:?} is not a usable hostname")]
77 InvalidPdsHost(String),
78}
79
80/// The DNS zone agents are minted under, plus the port their PDS listens on
81/// during development.
82///
83/// A zone exists because atproto supports "only hostname-level `did:web`
84/// DIDs: path-based DIDs are not supported". Every agent therefore needs its
85/// own *hostname*, not its own path under a shared one, so the deployment has
86/// to own a name it can put arbitrary subdomains under.
87///
88/// A zone is constructed against the hostname of the PDS that will serve the
89/// accounts, but it does not have to sit at or below that hostname: a PDS at
90/// `pds.example.com` may delegate `pds.example.com`, `agents.pds.example.com`,
91/// or `agents.anotherexample.com`, provided it holds DNS credentials for
92/// whichever zone it names. Which server serves a given agent is answered by
93/// resolving the agent's DID document, which carries the PDS's
94/// `serviceEndpoint` under `#atproto_pds` — not by inspecting the DID string.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Zone {
97 pds_host: String,
98 host: String,
99}
100
101impl Zone {
102 /// A zone that is the PDS's own hostname: agents become its immediate
103 /// subdomains.
104 pub fn new(pds_host: &str) -> Result<Self, DidError> {
105 validate_hostname(pds_host).map_err(|_| DidError::InvalidPdsHost(pds_host.to_owned()))?;
106 Ok(Self {
107 pds_host: pds_host.to_owned(),
108 host: pds_host.to_owned(),
109 })
110 }
111
112 /// A zone the PDS delegates agent names from. It may be any hostname the
113 /// PDS holds DNS credentials for, including one that does not sit under
114 /// `pds_host` at all.
115 pub fn delegated(pds_host: &str, zone_host: &str) -> Result<Self, DidError> {
116 let zone = Self::new(pds_host)?;
117 validate_hostname(zone_host)
118 .map_err(|_| DidError::InvalidZoneHost(zone_host.to_owned()))?;
119 Ok(Self {
120 host: zone_host.to_owned(),
121 ..zone
122 })
123 }
124
125 /// The base hostname agents are minted under.
126 pub fn host(&self) -> &str {
127 &self.host
128 }
129
130 /// Whether this zone is `localhost` or under it, and so development-only.
131 ///
132 /// RFC 6761 reserves that subtree for loopback, so every agent hostname
133 /// minted here resolves without a zone to write, has no operator's
134 /// repository to read and is reachable by no relay. The server picks its
135 /// listener, its certificate source and what it polls for by this.
136 pub fn is_loopback(&self) -> bool {
137 is_loopback_host(&self.host)
138 }
139
140 /// The hostname of the PDS this zone is delegated from.
141 pub fn pds_host(&self) -> &str {
142 &self.pds_host
143 }
144
145 /// The `did:web` naming the server itself, rather than any account on it.
146 ///
147 /// This is the DID a human's vouch record points at when it says "this is
148 /// my personal data server and I take responsibility for the agents on
149 /// it". It is the *zone* hostname and not the PDS hostname: a
150 /// [`ZoneRegistry`] holds one `Zone` per zone a deployment mints under,
151 /// and the vouch has to name the zone whose accounts it is vouching for
152 /// — naming the PDS hostname instead would not say which of a
153 /// deployment's several zones is covered.
154 ///
155 /// Returned as a string. It is a `did:web` for a service, not for an
156 /// account, and handing back an [`AccountDid`] would say otherwise.
157 pub fn service_did(&self) -> String {
158 format!("{DID_WEB_PREFIX}{}", self.host)
159 }
160}
161
162/// Why a set of zones could not be assembled.
163#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
164pub enum ZoneRegistryError {
165 /// No zones were offered at all.
166 #[error("a zone registry needs at least one zone")]
167 Empty,
168 /// The same hostname was offered twice.
169 #[error("zone {0:?} is configured more than once")]
170 Duplicate(String),
171}
172
173/// Several [`Zone`]s under one server. They may nest — a zone at
174/// `dev.agents.bot` under a deployment that also mints from `agents.bot` is
175/// fine — because nesting is not actually ambiguous: a wildcard certificate
176/// matches exactly one label, so `*.agents.bot` covers `foo.agents.bot` and
177/// does not cover `foo.dev.agents.bot`. The two namespaces do not intersect.
178/// The single point of contact between them is `dev.agents.bot` itself,
179/// which the outer zone could otherwise mint as an agent name, and that is
180/// guarded separately — by reserving the inner zone's apex label in the
181/// outer zone's own name registry, not by refusing the pair here. What this
182/// type does check is what only a *set* of zones can violate: the same
183/// hostname configured twice.
184///
185/// A deployment with one zone is the common case and stays exactly as simple
186/// as it always was: [`ZoneRegistry::single`] wraps it.
187///
188/// # A zone here mints agents; the server's own hostname might not be one
189///
190/// `Zone::new(pds_host)` makes `pds_host` itself an agent-minting zone —
191/// every label under it is a candidate agent name. A deployment delegating
192/// subzones keeps its own identity — its service DID, its own pages — as a
193/// [`Zone`] held separately, outside this registry.
194///
195/// # Nested zones each need their own certificate
196///
197/// One wildcard cannot span both depths: `agents.bot` plus `*.agents.bot` is
198/// one certificate pair, and `dev.agents.bot` plus `*.dev.agents.bot` is
199/// another. A deployment nesting zones needs a certificate pair per zone.
200#[derive(Debug, Clone)]
201pub struct ZoneRegistry {
202 /// The zones offered to [`ZoneRegistry::new`], in order. None of them
203 /// share a hostname; see [`ZoneRegistry::new`].
204 zones: Vec<Zone>,
205}
206
207impl ZoneRegistry {
208 /// Assembles a registry, refusing a set with the same hostname twice.
209 ///
210 /// `zones` is ordered; the first entry is the registry's
211 /// [`ZoneRegistry::default_zone`]. Each zone must already be
212 /// independently valid — that is [`Zone::new`] and [`Zone::delegated`]'s
213 /// job, not this one's. Nesting is allowed; see the struct docs on why
214 /// it is not the ambiguity a naive reading suggests, and on where the
215 /// real protection against an outer zone minting an inner zone's apex
216 /// actually lives.
217 pub fn new(zones: Vec<Zone>) -> Result<Self, ZoneRegistryError> {
218 if zones.is_empty() {
219 return Err(ZoneRegistryError::Empty);
220 }
221 for i in 0..zones.len() {
222 for j in (i + 1)..zones.len() {
223 let a = &zones[i];
224 let b = &zones[j];
225 if a.host().eq_ignore_ascii_case(b.host()) {
226 return Err(ZoneRegistryError::Duplicate(a.host().to_owned()));
227 }
228 }
229 }
230 Ok(Self { zones })
231 }
232
233 /// A registry holding exactly one zone. Cannot fail: one zone can never
234 /// duplicate itself.
235 pub fn single(zone: Zone) -> Self {
236 Self { zones: vec![zone] }
237 }
238
239 /// Every configured zone, in the order they were offered.
240 pub fn zones(&self) -> &[Zone] {
241 &self.zones
242 }
243
244 /// The registry's default zone: the first one offered to
245 /// [`ZoneRegistry::new`], and where provisioning lands when nothing more
246 /// specific routes it elsewhere.
247 ///
248 /// Not to be confused with the server's own identity, which is a
249 /// separate [`Zone`] a deployment holds outside this registry — see the
250 /// module docs on why a zone that mints agents and the PDS's own
251 /// hostname are not required to be the same hostname once subzones
252 /// exist.
253 pub fn default_zone(&self) -> &Zone {
254 &self.zones[0]
255 }
256
257 /// The configured zone named `host`, if any.
258 pub fn find(&self, host: &str) -> Option<&Zone> {
259 self.zones
260 .iter()
261 .find(|z| z.host().eq_ignore_ascii_case(host))
262 }
263
264 /// The most specific configured zone `host` sits at or below, if any.
265 ///
266 /// Zones may nest — see the struct docs — so a host can sit at or below
267 /// more than one configured zone at once: everything under
268 /// `dev.agents.bot` is, trivially, also under `agents.bot`. The deepest
269 /// match, by label count, is the one that actually minted it, so that is
270 /// what this returns. Two zones can never tie at the same depth without
271 /// being the same zone, since a duplicate hostname is refused at
272 /// construction.
273 pub fn containing(&self, host: &str) -> Option<&Zone> {
274 self.zones
275 .iter()
276 .filter(|z| hostname_is_at_or_below(host, z.host()))
277 .max_by_key(|z| z.host().matches('.').count())
278 }
279}
280
281/// Whether `host` is `ancestor` or a subdomain of it.
282///
283/// Compares DNS labels from the right, case-insensitively. Label boundaries
284/// are the point: `evilfoo.bar` shares a suffix with `foo.bar` but no label
285/// alignment, and `foo.bar.attacker.com` contains `foo.bar` but is below
286/// `attacker.com`. Both are false here.
287pub fn hostname_is_at_or_below(host: &str, ancestor: &str) -> bool {
288 if host.is_empty() || ancestor.is_empty() {
289 return false;
290 }
291 let host_labels: Vec<&str> = host.split('.').collect();
292 let ancestor_labels: Vec<&str> = ancestor.split('.').collect();
293 if host_labels.len() < ancestor_labels.len() {
294 return false;
295 }
296 let tail = &host_labels[host_labels.len() - ancestor_labels.len()..];
297 tail.iter()
298 .zip(ancestor_labels.iter())
299 .all(|(a, b)| a.eq_ignore_ascii_case(b))
300}
301
302/// A validated `did:web` belonging to one agent.
303///
304/// Held as the exact DID string plus its decoded authority, so that neither
305/// the identifier nor the fetch URL has to be recomputed — and so that the
306/// decoded form can never disagree with the encoded one.
307#[derive(Debug, Clone, PartialEq, Eq, Hash)]
308pub struct AccountDid {
309 /// The DID exactly as it is written, port colon still encoded.
310 did: String,
311 /// Decoded hostname, without a port.
312 host: String,
313 /// Decoded port, if the DID carried one.
314 port: Option<u16>,
315}
316
317impl AccountDid {
318 /// Mints the DID for `account_id` within `zone`.
319 ///
320 /// Produces `did:web:<account_id>.<zone host>`, which resolves at
321 /// `https://<account_id>.<zone host>/.well-known/did.json` — no port,
322 /// because the spec allows one only for a bare loopback hostname and an
323 /// account is a subdomain of its zone.
324 pub fn mint(zone: &Zone, account_id: &str) -> Result<Self, DidError> {
325 validate_label(account_id)
326 .map_err(|_| DidError::InvalidAccountId(account_id.to_owned()))?;
327 let mut did =
328 String::with_capacity(DID_WEB_PREFIX.len() + account_id.len() + zone.host.len() + 8);
329 did.push_str(DID_WEB_PREFIX);
330 did.push_str(account_id);
331 did.push('.');
332 did.push_str(&zone.host);
333 // Parsing what we just built is not redundant: it is the only thing
334 // that guarantees mint can never emit a DID that parse would refuse.
335 Self::parse(&did)
336 }
337
338 /// Parses and validates an agent `did:web`.
339 pub fn parse(did: &str) -> Result<Self, DidError> {
340 if did.len() > MAX_DID_LENGTH {
341 return Err(DidError::TooLong(did.len()));
342 }
343 let identifier = did
344 .strip_prefix(DID_WEB_PREFIX)
345 .ok_or_else(|| DidError::NotDidWeb(did.to_owned()))?;
346 if identifier.is_empty() {
347 return Err(DidError::EmptyIdentifier);
348 }
349 // A literal colon here is the `did:web` path separator. atproto:
350 // "only hostname-level did:web DIDs are supported: path-based DIDs
351 // are not supported."
352 if identifier.contains(':') {
353 return Err(DidError::PathBased(did.to_owned()));
354 }
355 // The generic atproto DID regex ends `[a-zA-Z0-9._-]$`: an identifier
356 // may not end mid-escape.
357 if identifier.ends_with('%') {
358 return Err(DidError::BadPercentEscape(identifier.to_owned()));
359 }
360
361 let authority = decode_authority(identifier)?;
362 let (host, port) = match authority.split_once(':') {
363 Some((host, port)) => (host.to_owned(), Some(parse_port(port)?)),
364 None => (authority, None),
365 };
366
367 validate_hostname(&host)?;
368 // The spec allows a port "only ... for `localhost`, and only in
369 // testing and development". A bare loopback hostname, not anything
370 // merely under `.localhost`: an atproto client refuses the rest, so
371 // accepting them here would produce DIDs only this codebase can
372 // resolve.
373 if port.is_some() && !is_loopback_literal(&host) {
374 return Err(DidError::PortOnNonLocalhost(host));
375 }
376
377 Ok(Self {
378 did: did.to_owned(),
379 host,
380 port,
381 })
382 }
383
384 /// The agent's own label: the leftmost component of the hostname.
385 pub fn account_id(&self) -> &str {
386 self.host.split('.').next().unwrap_or(&self.host)
387 }
388
389 /// The decoded hostname, without any port.
390 pub fn host(&self) -> &str {
391 &self.host
392 }
393
394 /// The decoded port, if this is a development DID that carries one.
395 pub fn port(&self) -> Option<u16> {
396 self.port
397 }
398
399 /// The decoded authority: `host`, or `host:port` for a development DID.
400 ///
401 /// This is the `did:web` identifier with its port colon *decoded*, which
402 /// is what an atproto record key may contain — `:` is in the record-key
403 /// character set and `%` is not. Anything keying a record by the server
404 /// a DID names has to use this rather than the encoded form
405 /// [`Self::as_str`] carries, and both sides of `bot.did.operator` do:
406 /// `didbot operate` writes at this key and the server's own poll reads at
407 /// it. Deriving it here rather than at each end is what stops the two
408 /// from disagreeing.
409 pub fn authority(&self) -> String {
410 match self.port {
411 Some(port) => format!("{}:{port}", self.host),
412 None => self.host.clone(),
413 }
414 }
415
416 /// Whether this DID names a loopback host, and so is development-only.
417 pub fn is_loopback(&self) -> bool {
418 is_loopback_host(&self.host)
419 }
420
421 /// The URL the DID document is served at. Always `https://`: every
422 /// server this project runs terminates TLS, on a developer's machine as
423 /// much as anywhere else.
424 ///
425 /// A DID carries no port, so this is port 443 — except for a loopback
426 /// host [`RESOLVE_PORTS`] names, which is how a harness reaches several
427 /// servers at once on one machine.
428 pub fn did_json_url(&self) -> String {
429 match self.port.or_else(|| resolve_port(&self.host)) {
430 Some(port) => format!("https://{}:{port}{DID_JSON_PATH}", self.host),
431 None => format!("https://{}{DID_JSON_PATH}", self.host),
432 }
433 }
434
435 /// The DID as written, with the port colon still encoded.
436 pub fn as_str(&self) -> &str {
437 &self.did
438 }
439}
440
441impl fmt::Display for AccountDid {
442 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443 f.write_str(&self.did)
444 }
445}
446
447impl std::str::FromStr for AccountDid {
448 type Err = DidError;
449
450 fn from_str(s: &str) -> Result<Self, Self::Err> {
451 Self::parse(s)
452 }
453}
454
455/// Reads the port half of a decoded authority, in one spelling only.
456///
457/// `u16::from_str` is not that: it accepts a leading `+` and any number of
458/// leading zeros, so `%3A3000`, `%3A03000` and `%3A+3000` would all decode to
459/// port 3000. That is the two-spellings-one-identity problem, and it is not
460/// hypothetical here — [`AccountDid`] compares and hashes on the DID string it
461/// was given, while [`AccountDid::did_json_url`] and [`AccountDid::authority`]
462/// derive from the decoded port, so the spellings are distinct identifiers
463/// naming one account. The `+` form is
464/// worse still: [`validate_did`] refuses `+` outright, so accepting it here
465/// made this supposedly stricter parser admit a string that is not a
466/// syntactically valid DID of any method at all.
467///
468/// Digits only, and no leading zero unless the port is exactly `0` — which
469/// is then refused as a port anyway, since nothing listens on it.
470fn parse_port(port: &str) -> Result<u16, DidError> {
471 let bad = || DidError::InvalidPort(port.to_owned());
472 if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
473 return Err(bad());
474 }
475 if port.len() > 1 && port.starts_with('0') {
476 return Err(bad());
477 }
478 let port: u16 = port.parse().map_err(|_| bad())?;
479 if port == 0 {
480 return Err(DidError::InvalidPort("0".to_owned()));
481 }
482 Ok(port)
483}
484
485/// Turns the method-specific identifier into a decoded `host` or `host:port`.
486///
487/// Deliberately not a general percent decoder. `%3A` is the only escape an
488/// atproto `did:web` has any reason to contain, and a general decoder is
489/// exactly how a crafted DID smuggles a path separator into a fetch URL.
490fn decode_authority(identifier: &str) -> Result<String, DidError> {
491 let mut out = String::with_capacity(identifier.len());
492 let bytes = identifier.as_bytes();
493 let mut i = 0;
494 while i < bytes.len() {
495 if bytes[i] == b'%' {
496 let escape = identifier
497 .get(i..i + 3)
498 .ok_or_else(|| DidError::BadPercentEscape(identifier.to_owned()))?;
499 if escape.eq_ignore_ascii_case("%2F") {
500 return Err(DidError::EncodedPathSeparator(identifier.to_owned()));
501 }
502 if escape != ENCODED_COLON {
503 return Err(DidError::BadPercentEscape(escape.to_owned()));
504 }
505 out.push(':');
506 i += 3;
507 } else {
508 out.push(bytes[i] as char);
509 i += 1;
510 }
511 }
512 Ok(out)
513}
514
515/// Checks a decoded hostname: lowercase DNS labels, no empties, no `.arpa`.
516fn validate_hostname(host: &str) -> Result<(), DidError> {
517 if host.is_empty() {
518 return Err(DidError::EmptyIdentifier);
519 }
520 for label in host.split('.') {
521 if label.is_empty() {
522 return Err(DidError::EmptyLabel(host.to_owned()));
523 }
524 validate_label(label)?;
525 }
526 // The spec: "The same restrictions on top-level domains that apply to
527 // handles (eg, no .arpa) also apply to did:web domains."
528 if let Some(tld) = host.rsplit('.').next() {
529 if tld == "arpa" {
530 return Err(DidError::ArpaDomain);
531 }
532 }
533 Ok(())
534}
535
536/// Checks one DNS label. Uppercase is rejected rather than normalized: a DID
537/// is compared byte for byte across the network, so two spellings of one
538/// identity would be two identities.
539///
540/// This is the authority on what an agent id may be: [`AccountDid::mint`] and
541/// `validate_hostname` (this crate's `did:web` host check) both call it
542/// rather than a copy, and anything upstream that generates a label — a
543/// namer, or any derivation — should too. Two validators that disagree about which
544/// labels are legal is how an illegal one reaches DNS: one accepts what the
545/// other would have refused. This function has no notion of a maximum label
546/// length; that is a DNS-label rule with no `did:web`-specific meaning, and a
547/// caller that cares (`didbot-name`, publishing under a zone) enforces its own
548/// bound on top of this.
549pub fn validate_label(label: &str) -> Result<(), DidError> {
550 if label.is_empty() {
551 return Err(DidError::EmptyLabel(label.to_owned()));
552 }
553 for ch in label.chars() {
554 if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') {
555 return Err(DidError::InvalidCharacter(ch));
556 }
557 }
558 if label.starts_with('-') || label.ends_with('-') {
559 return Err(DidError::HyphenEdge(label.to_owned()));
560 }
561 Ok(())
562}
563
564/// The environment variable naming which port a loopback hostname's
565/// documents answer on, as `host=port` pairs separated by commas.
566///
567/// A `did:web` carries no port, so resolving one means port 443 — and one
568/// machine has one port 443. A developer's own stack takes it (see
569/// `docs/running-locally.md`); a harness starting several servers at once
570/// cannot, so it says here where each one is and every tool in this
571/// workspace resolves accordingly. A browser is told the same thing through
572/// its own resolver rules.
573///
574/// Only a loopback hostname may be named. An entry for anything else is
575/// ignored, so this can never move where a real name resolves.
576pub const RESOLVE_PORTS: &str = "DIDBOT_RESOLVE_PORTS";
577
578/// The port [`RESOLVE_PORTS`] gives `host`, if it gives one.
579///
580/// A zone's entry covers everything under it, because a zone's accounts are
581/// one listener; the most specific entry wins, matching
582/// [`ZoneRegistry`]'s own rule. Read once per process: this is a harness's
583/// own wiring, fixed before anything it starts runs.
584pub fn resolve_port(host: &str) -> Option<u16> {
585 static PORTS: std::sync::OnceLock<Vec<(String, u16)>> = std::sync::OnceLock::new();
586 let ports = PORTS.get_or_init(|| {
587 let Ok(raw) = std::env::var(RESOLVE_PORTS) else {
588 return Vec::new();
589 };
590 raw.split(',')
591 .filter_map(|entry| entry.trim().split_once('='))
592 .filter(|(host, _)| is_loopback_host(host))
593 .filter_map(|(host, port)| Some((host.to_ascii_lowercase(), port.parse().ok()?)))
594 .collect()
595 });
596 let host = host.to_ascii_lowercase();
597 ports
598 .iter()
599 .filter(|(named, _)| host == *named || host.ends_with(&format!(".{named}")))
600 .max_by_key(|(named, _)| named.len())
601 .map(|(_, port)| *port)
602}
603
604/// A hostname without the port it is reached on.
605///
606/// A person types the port their server answers on; a `did:web`, a record
607/// key and a zone all carry none. This is the half of a typed name that can
608/// be compared against any of them.
609///
610/// ```
611/// use didbot_identity::did::host_without_port;
612/// assert_eq!(host_without_port("agents.localhost:3000"), "agents.localhost");
613/// assert_eq!(host_without_port("agents.example.com"), "agents.example.com");
614/// ```
615pub fn host_without_port(name: &str) -> &str {
616 match name.rsplit_once(':') {
617 Some((host, port))
618 if !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()) =>
619 {
620 host
621 }
622 _ => name,
623 }
624}
625
626/// The `did:web` a hostname spells.
627///
628/// A hostname a person types carries the port they reach the server on. A
629/// `did:web` carries one only for a bare loopback hostname — an atproto
630/// client reads a port on anything else as a handle — so anywhere else the
631/// port is dropped, which is also what the server's own
632/// [`Zone::service_did`] does. The colon that stays is percent-encoded,
633/// the spelling [`AccountDid::parse`] reads back.
634///
635/// One function because seven callers had a copy of it, and a DID that
636/// disagrees with the one the server minted is a record written where
637/// nothing reads it.
638///
639/// ```
640/// use didbot_identity::did::did_web_for_host;
641/// assert_eq!(did_web_for_host("kestrel.agents.localhost:3000"), "did:web:kestrel.agents.localhost");
642/// assert_eq!(did_web_for_host("localhost:3000"), "did:web:localhost%3A3000");
643/// assert_eq!(did_web_for_host("agents.example.com"), "did:web:agents.example.com");
644/// ```
645pub fn did_web_for_host(host: &str) -> String {
646 let name = host_without_port(host);
647 match host.strip_prefix(name) {
648 Some(port) if !port.is_empty() && is_loopback_literal(name) => {
649 format!("{DID_WEB_PREFIX}{name}{ENCODED_COLON}{}", &port[1..])
650 }
651 _ => format!("{DID_WEB_PREFIX}{name}"),
652 }
653}
654
655/// How a person reads an account's name in a command's output.
656///
657/// A `did:web` is its hostname. Any other DID is `@handle` when `handle` is
658/// one the caller verified both ways, and the DID itself when it is not.
659///
660/// ```
661/// use didbot_identity::did::account_name;
662/// assert_eq!(account_name("did:web:kestrel.pds.example", None), "kestrel.pds.example");
663/// assert_eq!(account_name("did:web:localhost%3A3000", None), "localhost:3000");
664/// assert_eq!(account_name("did:plc:abc", Some("operator.example")), "@operator.example");
665/// assert_eq!(account_name("did:plc:abc", None), "did:plc:abc");
666/// ```
667pub fn account_name(did: &str, handle: Option<&str>) -> String {
668 if let Ok(web) = AccountDid::parse(did) {
669 return web.authority();
670 }
671 match handle {
672 Some(handle) => format!("@{handle}"),
673 None => did.to_owned(),
674 }
675}
676
677/// Whether a hostname is one of the bare loopback names the atproto profile
678/// permits a port on.
679///
680/// Narrower than [`is_loopback_host`], and deliberately: `agents.localhost`
681/// resolves to this machine, but an atproto client reads
682/// `did:web:agents.localhost%3A3000` as a handle rather than a DID, so a DID
683/// in that shape is one nothing else can resolve.
684pub fn is_loopback_literal(host: &str) -> bool {
685 host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]"
686}
687
688/// Whether a hostname resolves to this machine.
689///
690/// `*.localhost` counts. RFC 6761 reserves the whole subtree for loopback,
691/// and minting gives every agent a subdomain, so a development zone is
692/// `agents.localhost` and every agent under it lands here. This answers what
693/// a deployment has around it — no public DNS, no operator's repository, no
694/// relay that can reach it — not what scheme it speaks.
695pub fn is_loopback_host(host: &str) -> bool {
696 host == "localhost"
697 || host.ends_with(".localhost")
698 || host == "127.0.0.1"
699 || host == "::1"
700 || host == "[::1]"
701}
702
703/// Why a string is not a syntactically valid DID of any method.
704///
705/// Separate from [`DidError`] because the two validators answer separate
706/// questions; see [`validate_did`].
707#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
708pub enum SyntaxError {
709 /// Longer than the atproto cap of 2048 characters.
710 #[error("did is {0} characters, over the atproto limit of {MAX_DID_LENGTH}")]
711 TooLong(usize),
712 /// Does not begin with the lowercase literal `did:`.
713 #[error("did does not start with `did:`")]
714 NoDidPrefix,
715 /// No colon after the method name, so there is no method-specific
716 /// identifier at all.
717 #[error("did has no method-specific identifier")]
718 NoIdentifier,
719 /// The method name was empty or held something other than `a-z`.
720 #[error("did method {0:?} must be one or more lowercase letters")]
721 BadMethod(String),
722 /// The method-specific identifier was empty.
723 #[error("did:{0} with an empty identifier")]
724 EmptyIdentifier(String),
725 /// A character outside `A-Za-z0-9._:%-` appeared in the identifier.
726 #[error("invalid character {0:?} in did identifier")]
727 InvalidCharacter(char),
728 /// A `%` not followed by two hexadecimal digits.
729 #[error("malformed percent escape in did identifier")]
730 BadPercentEscape,
731 /// The identifier ended on the `:` that separates its own parts, which
732 /// would make the last part empty.
733 #[error("did identifier ends with a colon")]
734 TrailingColon,
735}
736
737/// Checks a string against the generic atproto DID syntax rules.
738///
739/// # This is not [`AccountDid::parse`], and the difference is deliberate
740///
741/// [`AccountDid`] is much stricter: `did:web` only, hostname-level only, ports
742/// only on loopback, lowercase only. That strictness is this project's
743/// security posture rather than atproto's grammar — see [`Zone`]. Running the
744/// protocol's generic DID vectors against `AccountDid` would report failures
745/// that are the entire point of the type, so they are run against this
746/// function instead.
747///
748/// The two are still connected: every DID [`AccountDid::mint`] produces is also
749/// valid here, and the conformance suite asserts it.
750///
751/// ```
752/// use didbot_identity::did::validate_did;
753/// assert!(validate_did("did:plc:7iza6de2dwap2sbkpav7c6c6").is_ok());
754/// assert!(validate_did("did:web:localhost%3A1234").is_ok());
755/// assert!(validate_did("did:m123:val").is_err()); // method is letters only
756/// assert!(validate_did("did:method:val:").is_err());
757/// ```
758pub fn validate_did(did: &str) -> Result<(), SyntaxError> {
759 if did.len() > MAX_DID_LENGTH {
760 return Err(SyntaxError::TooLong(did.len()));
761 }
762 let rest = did.strip_prefix("did:").ok_or(SyntaxError::NoDidPrefix)?;
763 let (method, identifier) = rest.split_once(':').ok_or(SyntaxError::NoIdentifier)?;
764
765 if method.is_empty() || !method.bytes().all(|b| b.is_ascii_lowercase()) {
766 return Err(SyntaxError::BadMethod(method.to_owned()));
767 }
768 if identifier.is_empty() {
769 return Err(SyntaxError::EmptyIdentifier(method.to_owned()));
770 }
771 // A trailing colon would leave an empty final part. Colons *inside* the
772 // identifier are ordinary: `did:method::::val` is valid, and `did:web`
773 // uses them for the paths this crate refuses at a higher level.
774 if identifier.ends_with(':') {
775 return Err(SyntaxError::TrailingColon);
776 }
777
778 let bytes = identifier.as_bytes();
779 let mut i = 0;
780 while i < bytes.len() {
781 let byte = bytes[i];
782 if byte == b'%' {
783 // Percent escapes are checked, never decoded. Decoding is a
784 // method's business, and a general decoder in a syntax check is
785 // how a crafted identifier smuggles a separator past it.
786 let hex = identifier
787 .get(i + 1..i + 3)
788 .ok_or(SyntaxError::BadPercentEscape)?;
789 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
790 return Err(SyntaxError::BadPercentEscape);
791 }
792 i += 3;
793 continue;
794 }
795 if !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':')) {
796 // The identifier is not necessarily ASCII, so the offending
797 // character comes from the char iterator rather than the byte.
798 let ch = identifier[i..]
799 .chars()
800 .next()
801 .unwrap_or(char::REPLACEMENT_CHARACTER);
802 return Err(SyntaxError::InvalidCharacter(ch));
803 }
804 i += 1;
805 }
806 Ok(())
807}
808
809/// A DID from a URL, spelled the way this project stores it.
810///
811/// Both a path segment and a query value are percent-decoded once before a
812/// handler sees them, and a local agent's DID already contains a
813/// percent-encoding of its own: a DID on a bare loopback host is written
814/// `did:web:localhost%3A3400`, so naming one in a URL means encoding the
815/// `%` as well. A client that builds URLs properly does that and
816/// this changes nothing; a person with `curl` and a DID copied out of a log
817/// writes the bare colon, and without this gets an empty answer for an agent
818/// that is plainly there.
819///
820/// The repair is unambiguous rather than a guess: in `did:web` a colon
821/// separates path segments, a port must be written `%3A`, and a bare
822/// `host:3400` in the identifier position is not a DID this server could have
823/// minted. Anything that is not a `did:web` with a numeric last segment comes
824/// back untouched, so a `did:plc`, a handle and a properly encoded DID all
825/// pass straight through.
826///
827/// Applied to every DID-valued input rather than only where a bug was noticed:
828/// a filter that silently matched nothing is worse than a 404, because a
829/// caller reads it as "no such children" rather than as "ask differently".
830///
831/// It lives here rather than beside either caller because both the query
832/// service and the personal data server's `com.atproto.repo.*` routes need
833/// exactly this repair, and two copies of it would agree until one was
834/// edited.
835///
836/// ```
837/// use didbot_identity::canonical_did;
838/// assert_eq!(
839/// canonical_did("did:web:localhost:3400"),
840/// "did:web:localhost%3A3400"
841/// );
842/// // Already encoded, a plain hostname, and a `did:plc` all pass through.
843/// assert_eq!(
844/// canonical_did("did:web:localhost%3A3400"),
845/// "did:web:localhost%3A3400"
846/// );
847/// assert_eq!(canonical_did("did:web:agents.example"), "did:web:agents.example");
848/// assert_eq!(canonical_did("did:plc:abc123"), "did:plc:abc123");
849/// ```
850pub fn canonical_did(value: &str) -> String {
851 let Some(rest) = value.strip_prefix("did:web:") else {
852 return value.to_owned();
853 };
854 let Some((host, port)) = rest.rsplit_once(':') else {
855 return value.to_owned();
856 };
857 if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
858 return value.to_owned();
859 }
860 format!("did:web:{host}%3A{port}")
861}