didbot_claim_check/document.rs
1//! Reading the server's own `did:web` document: the parse, the `id` check,
2//! and the signing key it publishes.
3//!
4//! The fetch itself belongs to whoever has a network — `didbot-claim` over
5//! HTTP, a page over `fetch`. Everything a caller does with the body it got
6//! back is here, so both read the document the same way and refuse the same
7//! documents.
8
9use didbot_identity::did::AccountDid;
10use didbot_identity::document::DidDocument;
11use didbot_identity::resolve::ResolveError;
12
13/// The URL `hostname`'s `did:web` document is served at.
14///
15/// Split out so a caller that wants to make the fetch itself -- and report
16/// its failure as its own thing rather than as a document failure -- can
17/// build the same URL this module would have.
18pub fn did_json_url(hostname: &str) -> Result<String, ResolveError> {
19 let did = AccountDid::parse(&did_of(hostname)).map_err(|error| ResolveError::Malformed {
20 url: hostname.to_owned(),
21 message: format!("{hostname:?} is not a usable did:web hostname: {error}"),
22 })?;
23 Ok(did.did_json_url())
24}
25
26/// The `did:web` a hostname spells; see
27/// [`didbot_identity::did::did_web_for_host`].
28fn did_of(hostname: &str) -> String {
29 didbot_identity::did::did_web_for_host(hostname)
30}
31
32/// Parses `body` as `hostname`'s DID document, checking its `id` against
33/// what was asked for.
34pub fn parse_document(hostname: &str, url: &str, body: &str) -> Result<DidDocument, ResolveError> {
35 parse_did_document(&did_of(hostname), url, body)
36}
37
38/// Parses `body` as `did`'s document, checking its `id` against what was
39/// asked for.
40///
41/// Every caller fetches the body its own way and then comes here, so the
42/// parse and the `id` check are one piece of code rather than a second,
43/// drifting copy of them. The spec is
44/// blunt about that check: a document that claims to be a different account
45/// is, for `did:web`, whatever the host chose to serve.
46pub fn parse_did_document(did: &str, url: &str, body: &str) -> Result<DidDocument, ResolveError> {
47 let document: DidDocument =
48 serde_json::from_str(body).map_err(|error| ResolveError::Malformed {
49 url: url.to_owned(),
50 message: error.to_string(),
51 })?;
52 if document.id != did {
53 return Err(ResolveError::IdMismatch {
54 url: url.to_owned(),
55 expected: did.to_owned(),
56 found: document.id,
57 });
58 }
59 Ok(document)
60}
61
62/// Why the server's document does not yield a usable signing key.
63#[derive(Debug, thiserror::Error)]
64pub enum SigningKeyError {
65 /// No `#atproto` verification method of type `Multikey` at all.
66 #[error(
67 "the did:web document at {hostname} has no #atproto signing key. The server may not \
68 have finished self-provisioning yet; wait and retry."
69 )]
70 Missing {
71 /// The hostname this command was asked to check.
72 hostname: String,
73 },
74 /// There is one, and it is not a key atproto can verify a commit with.
75 #[error(
76 "the did:web document at {hostname} names an #atproto signing key this reader cannot \
77 read as an atproto public key ({source})."
78 )]
79 Unusable {
80 /// The hostname this command was asked to check.
81 hostname: String,
82 /// What `didbot-key` said about the multibase string.
83 #[source]
84 source: didbot_key::KeyError,
85 },
86}
87
88/// The document's `#atproto` signing key, checked to be a public key
89/// atproto actually verifies commits with.
90///
91/// The document is the *server's own* answer, so the string in
92/// `publicKeyMultibase` is attacker-supplied in the sense that matters
93/// here: nothing above this point constrains it, and
94/// [`didbot_identity::document::DidDocument`] carries it as an ordinary
95/// `String`.
96///
97/// Both of atproto's curves are accepted, and only those: `didbot-key`
98/// mints secp256k1 alone, but the server being claimed is not this crate's
99/// to constrain and half the network signs with P-256. Nothing here is
100/// implemented in this module -- both readers are `didbot-key`'s, which is
101/// where the multibase, multicodec and point-encoding rules already live.
102pub fn signing_key<'a>(
103 hostname: &str,
104 document: &'a DidDocument,
105) -> Result<&'a str, SigningKeyError> {
106 let multibase = document
107 .signing_key_multibase()
108 .ok_or_else(|| SigningKeyError::Missing {
109 hostname: hostname.to_owned(),
110 })?;
111 usable(hostname, multibase)
112}
113
114/// `multibase`, once it is known to be a public key on a curve atproto
115/// verifies with.
116fn usable<'a>(hostname: &str, multibase: &'a str) -> Result<&'a str, SigningKeyError> {
117 match didbot_key::VerifyingKey::from_multibase(multibase) {
118 Ok(_) => Ok(multibase),
119 // A P-256 key reaches the secp256k1 reader as the wrong curve and
120 // nothing else; anything the secp256k1 reader rejected for another
121 // reason is reported as it found it, rather than as whatever second
122 // complaint the P-256 reader would have made about the same bytes.
123 Err(didbot_key::KeyError::WrongCurve {
124 codec: jacquard_common::types::crypto::KeyCodec::P256,
125 }) => didbot_key::secp256r1::VerifyingKey::from_multibase(multibase)
126 .map(|_| multibase)
127 .map_err(|source| SigningKeyError::Unusable {
128 hostname: hostname.to_owned(),
129 source,
130 }),
131 Err(source) => Err(SigningKeyError::Unusable {
132 hostname: hostname.to_owned(),
133 source,
134 }),
135 }
136}