Skip to main content

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}