didbot_claim_check/agree.rs
1//! Cross-checking `com.atproto.server.describeServer` against the DID
2//! document.
3//!
4//! This is not a second proof of anything -- TLS already is the proof, and
5//! the DID document is the first unauthenticated read of "who does this
6//! hostname say it is." What this adds is a second, independent
7//! unauthenticated read of the same fact, from a different route (an XRPC
8//! query rather than a static well-known document), and a refusal to write
9//! anything if the two disagree. `describeServer` answers unauthenticated
10//! and unaffected by the server's own claim state, specifically so this
11//! can be checked before a server is claimed.
12
13use didbot_identity::{hostname_is_at_or_below, AccountDid};
14
15/// Why the two unauthenticated reads of the server's identity disagreed.
16#[derive(Debug, Clone, thiserror::Error)]
17#[error(
18 "describeServer at {hostname} reports {described_did}, but its did:web document resolves \
19 to {document_did} -- these must agree before anything is written. This usually means DNS \
20 points somewhere other than the server this was told about, or the deployment is \
21 mid-cutover; try again once both agree."
22)]
23pub struct DescribeMismatch {
24 /// The hostname being checked.
25 pub hostname: String,
26 /// What the DID document at that hostname claims.
27 pub document_did: String,
28 /// What `describeServer` claims.
29 pub described_did: String,
30}
31
32/// Whether `describeServer`'s answer and the DID document name the same
33/// server: the same DID, or a host whose name sits under the server that
34/// describes itself.
35#[must_use]
36pub fn describes_same_server(document_did: &str, described_did: &str) -> bool {
37 described_did == document_did || hosted_by(document_did, described_did)
38}
39
40/// Whether `subject` is an account on the server `server` names: both are
41/// `did:web`, and the subject's hostname sits strictly below the server's.
42///
43/// `describeServer` at a host's hostname answers with the DID of the PDS
44/// serving it, never the host's own, so for a host the two reads agree when
45/// the server describing itself is the one the host's name is under. The
46/// same consistency check as for a server, read across a zone rather than
47/// an exact match.
48fn hosted_by(subject: &str, server: &str) -> bool {
49 let (Ok(subject), Ok(server)) = (AccountDid::parse(subject), AccountDid::parse(server)) else {
50 return false;
51 };
52 subject.host() != server.host() && hostname_is_at_or_below(subject.host(), server.host())
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58
59 #[test]
60 fn the_same_did_agrees() {
61 assert!(describes_same_server(
62 "did:web:pds.example.com",
63 "did:web:pds.example.com"
64 ));
65 }
66
67 #[test]
68 fn a_host_under_the_described_server_agrees() {
69 assert!(describes_same_server(
70 "did:web:mossy-vole.pds.example.com",
71 "did:web:pds.example.com"
72 ));
73 }
74
75 #[test]
76 fn a_host_under_some_other_server_does_not() {
77 assert!(!describes_same_server(
78 "did:web:mossy-vole.pds.example.com",
79 "did:web:elsewhere.example.com"
80 ));
81 assert!(!describes_same_server(
82 "did:web:pds.example.com",
83 "did:web:someone-else.example.com"
84 ));
85 }
86}