Skip to main content

didbot_tls/
acme.rs

1//! Orchestrating one ACME order against DNS-01, generic over the DNS seam in
2//! [`crate::dns`].
3//!
4//! # The two-value collision, made concrete
5//!
6//! An order for `["pds.did.bot", "*.pds.did.bot"]` gets two authorizations.
7//! Both put their DNS-01 challenge at the *same* record name --
8//! `_acme-challenge.pds.did.bot` -- because RFC 8555 ยง8.4 strips the
9//! wildcard label before constructing the challenge name. This module makes
10//! two passes over the order's authorizations rather than the
11//! compute-then-immediately-set-ready loop instant-acme's own docs show,
12//! because that loop publishes and confirms one value at a time: the second
13//! `publish_challenge` call would replace the first identifier's value
14//! before the CA ever saw both. Pass one collects every value, grouped by
15//! name; then every name's whole set is published once; only after that does
16//! pass two mark each challenge ready.
17//!
18//! # What is, and is not, exercised here
19//!
20//! This module's own unit tests below only cover `group_challenges_by_name`
21//! as a pure function -- there is no way to construct a real
22//! [`instant_acme::Account`] without an HTTP round trip to a directory, so a
23//! fake `Account` was never attempted here. The full exchange -- a real
24//! `Account`, a real order, both DNS-01 authorizations grouped and
25//! published together, cleanup on every exit path -- is instead exercised
26//! by `tests/acme_conformance.rs`, against an in-process fake ACME
27//! directory (`tests/support::FakeAcmeServer`); no real CA has ever
28//! answered a call from this module. See that test file and this crate's
29//! top-level documentation for exactly what the fake does and does not
30//! stand in for.
31
32use std::collections::BTreeMap;
33use std::sync::Arc;
34
35use instant_acme::{
36    Account, AuthorizationStatus, ChallengeType, Identifier, NewOrder, OrderStatus, RetryPolicy,
37};
38
39use crate::dns::{AcmeDnsChallenge, AcmeDnsError};
40
41/// A certificate chain and its private key, both PEM, as ACME hands them
42/// back.
43#[derive(Clone)]
44pub struct IssuedCertificate {
45    /// Leaf certificate followed by intermediates.
46    pub cert_chain_pem: String,
47    /// The key ACME generated for this certificate.
48    pub key_pem: String,
49}
50
51impl std::fmt::Debug for IssuedCertificate {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        // Written by hand so that no derive, now or after a future field is
54        // added, can put the key into a log line or a panic message. The
55        // chain is public and identifies the certificate on its own.
56        f.debug_struct("IssuedCertificate")
57            .field("cert_chain_pem", &self.cert_chain_pem)
58            .finish_non_exhaustive()
59    }
60}
61
62/// Why issuance did not produce a certificate.
63#[derive(Debug, thiserror::Error)]
64pub enum IssueError {
65    /// The ACME exchange itself failed.
66    #[error("acme: {0}")]
67    Acme(#[from] instant_acme::Error),
68    /// Publishing or withdrawing a challenge value failed.
69    #[error("dns-01: {0}")]
70    Dns(#[from] AcmeDnsError),
71    /// The CA offered no `dns-01` challenge for one of the identifiers.
72    #[error("no dns-01 challenge offered for {identifier}")]
73    NoDns01Challenge {
74        /// The identifier (without a leading `*.`) that lacked one.
75        identifier: String,
76    },
77    /// The order finished somewhere other than `ready`/`valid`.
78    #[error("order finished in an unexpected state: {0:?}")]
79    UnexpectedOrderStatus(OrderStatus),
80}
81
82/// The full domain name(s) a certificate should cover: `<zone>` and
83/// `*.<zone>`.
84pub fn zone_and_wildcard(zone: &str) -> Vec<String> {
85    vec![zone.to_owned(), format!("*.{zone}")]
86}
87
88/// The `_acme-challenge.<name>` record a DNS-01 answer for `identifier` (as
89/// rendered by `instant_acme`'s `AuthorizedIdentifier`'s `Display`,
90/// which prints `*.<name>` for a wildcard authorization) belongs at.
91///
92/// Stripping the `*.` here, rather than trusting the CA never to send one, is
93/// what makes an apex-plus-wildcard order collapse onto one record name --
94/// which is the entire reason this crate cannot publish one value and move
95/// on.
96fn challenge_record_name(displayed_identifier: &str) -> String {
97    let base = displayed_identifier
98        .strip_prefix("*.")
99        .unwrap_or(displayed_identifier);
100    format!("_acme-challenge.{base}")
101}
102
103/// Groups (name, value) into the value *sets* that must be published
104/// together. Exposed for tests without needing a real order: this is where
105/// the apex-plus-wildcard collision either is or is not avoided.
106pub(crate) fn group_challenges_by_name(
107    pairs: &[(String, String)],
108) -> BTreeMap<String, Vec<String>> {
109    let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
110    for (name, value) in pairs {
111        grouped.entry(name.clone()).or_default().push(value.clone());
112    }
113    grouped
114}
115
116/// Requests a certificate covering every domain in `domains` from `account`,
117/// completing every `dns-01` challenge through `dns`.
118///
119/// Withdraws every challenge record it published before returning, whether
120/// issuance succeeded or failed -- a stale challenge TXT is not dangerous the
121/// way a stale `did:web` record is, but nothing should have to notice and
122/// clean it up by hand.
123pub async fn issue<D: AcmeDnsChallenge>(
124    account: &Account,
125    domains: &[String],
126    dns: &Arc<D>,
127) -> Result<IssuedCertificate, IssueError> {
128    let identifiers: Vec<Identifier> = domains.iter().cloned().map(Identifier::Dns).collect();
129    let mut order = account.new_order(&NewOrder::new(&identifiers)).await?;
130
131    // Pass 1: collect every challenge value, grouped by the record name it
132    // shares with any other identifier in this order.
133    let mut pairs = Vec::new();
134    {
135        let mut authorizations = order.authorizations();
136        while let Some(result) = authorizations.next().await {
137            let mut authz = result?;
138            if authz.status != AuthorizationStatus::Pending {
139                continue;
140            }
141            let identifier = authz.identifier().to_string();
142            let challenge = authz.challenge(ChallengeType::Dns01).ok_or_else(|| {
143                IssueError::NoDns01Challenge {
144                    identifier: identifier.clone(),
145                }
146            })?;
147            let value = challenge.key_authorization().dns_value();
148            pairs.push((challenge_record_name(&identifier), value));
149        }
150    }
151    let grouped = group_challenges_by_name(&pairs);
152
153    for (name, values) in &grouped {
154        dns.publish_challenge(name, values)?;
155    }
156
157    let cleanup = |dns: &Arc<D>, grouped: &BTreeMap<String, Vec<String>>| {
158        for name in grouped.keys() {
159            if let Err(err) = dns.clear_challenge(name) {
160                tracing::warn!(name, %err, "could not withdraw an acme dns-01 challenge");
161            }
162        }
163    };
164
165    // Pass 2: now that every name's complete value set is visible, tell the
166    // CA each challenge is ready to be checked.
167    {
168        let mut authorizations = order.authorizations();
169        while let Some(result) = authorizations.next().await {
170            let mut authz = match result {
171                Ok(authz) => authz,
172                Err(err) => {
173                    cleanup(dns, &grouped);
174                    return Err(err.into());
175                }
176            };
177            if authz.status != AuthorizationStatus::Pending {
178                continue;
179            }
180            let identifier = authz.identifier().to_string();
181            let Some(mut challenge) = authz.challenge(ChallengeType::Dns01) else {
182                cleanup(dns, &grouped);
183                return Err(IssueError::NoDns01Challenge { identifier });
184            };
185            if let Err(err) = challenge.set_ready().await {
186                cleanup(dns, &grouped);
187                return Err(err.into());
188            }
189        }
190    }
191
192    let result = finish_order(&mut order).await;
193    cleanup(dns, &grouped);
194    result
195}
196
197async fn finish_order(order: &mut instant_acme::Order) -> Result<IssuedCertificate, IssueError> {
198    let status = order.poll_ready(&RetryPolicy::default()).await?;
199    if status != OrderStatus::Ready {
200        return Err(IssueError::UnexpectedOrderStatus(status));
201    }
202    let key_pem = order.finalize().await?;
203    let cert_chain_pem = order.poll_certificate(&RetryPolicy::default()).await?;
204    Ok(IssuedCertificate {
205        cert_chain_pem,
206        key_pem,
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn debug_never_prints_the_private_key() {
216        let issued = IssuedCertificate {
217            cert_chain_pem: "-----BEGIN CERTIFICATE-----\nchain\n".to_string(),
218            key_pem: "-----BEGIN PRIVATE KEY-----\nscalar\n".to_string(),
219        };
220        let rendered = format!("{issued:?}");
221
222        // The whole rendering, not a search through it: any added field, any
223        // switch back to a derived `Debug`, changes this string and fails
224        // here rather than waiting for a key to appear in a log.
225        let chain = &issued.cert_chain_pem;
226        assert_eq!(
227            rendered,
228            format!("IssuedCertificate {{ cert_chain_pem: {chain:?}, .. }}")
229        );
230        assert!(!rendered.contains("scalar"), "leaked the key in {rendered}");
231    }
232
233    #[test]
234    fn challenge_record_name_strips_the_wildcard_label() {
235        assert_eq!(
236            challenge_record_name("*.pds.did.bot"),
237            "_acme-challenge.pds.did.bot"
238        );
239        assert_eq!(
240            challenge_record_name("pds.did.bot"),
241            "_acme-challenge.pds.did.bot"
242        );
243    }
244
245    #[test]
246    fn apex_and_wildcard_collapse_onto_one_name_with_two_values() {
247        // This is the exact case the epic calls out: a certificate over
248        // both `<zone>` and `*.<zone>` needs two TXT values live at once
249        // under one name.
250        let pairs = vec![
251            (
252                challenge_record_name("pds.did.bot"),
253                "apex-token".to_string(),
254            ),
255            (
256                challenge_record_name("*.pds.did.bot"),
257                "wildcard-token".to_string(),
258            ),
259        ];
260        let grouped = group_challenges_by_name(&pairs);
261        assert_eq!(grouped.len(), 1, "both identifiers share one record name");
262        let values = &grouped["_acme-challenge.pds.did.bot"];
263        assert_eq!(values.len(), 2);
264        assert!(values.contains(&"apex-token".to_string()));
265        assert!(values.contains(&"wildcard-token".to_string()));
266    }
267
268    #[test]
269    fn unrelated_identifiers_do_not_share_a_name() {
270        let pairs = vec![
271            (challenge_record_name("pds.did.bot"), "a".to_string()),
272            (challenge_record_name("other.example"), "b".to_string()),
273        ];
274        let grouped = group_challenges_by_name(&pairs);
275        assert_eq!(grouped.len(), 2);
276    }
277
278    #[test]
279    fn zone_and_wildcard_covers_both_names() {
280        let domains = zone_and_wildcard("pds.did.bot");
281        assert_eq!(
282            domains,
283            vec!["pds.did.bot".to_string(), "*.pds.did.bot".to_string()]
284        );
285    }
286}