Skip to main content

didbot_tls/
dns.rs

1//! The DNS-01 seam.
2//!
3//! A certificate that covers both `<zone>` and `*.<zone>` needs two ACME
4//! authorizations — one per identifier — and RFC 8555 §8.4 puts both
5//! challenges' answers at the *same* DNS name: `_acme-challenge.<zone>`. The
6//! two values must be published together and both must be visible when the
7//! CA checks, so this is not "publish, then overwrite" — it is "publish a
8//! set of values at one name, then withdraw all of them together."
9//!
10//! `didbot_dns::DnsProvider`'s `publish_txt`/`withdraw_txt`/`txt_values`
11//! are the multi-value API a challenge name needs: additive per value, and
12//! idempotent for a value already there.
13//!
14//! This module does not fork a second DNS client to use them; instead
15//! [`AcmeDnsChallenge`] states exactly what ACME's DNS-01 step
16//! needs — nothing more — and [`DnsProviderAdapter`] is the thin join: it
17//! diffs the value set a challenge asks for against what
18//! `didbot_dns::DnsProvider::txt_values` already has published at that
19//! name, and issues one `publish_txt`/`withdraw_txt` call per value that
20//! needs to change, so a retried publish for values already there is a
21//! no-op and a withdraw of a value someone else already cleared is not an
22//! error. Any `DnsProvider` — `Route53Dns` in production, `InMemoryDns` in
23//! a test — becomes an `AcmeDnsChallenge` this way with no new network
24//! code. [`InMemoryAcmeDns`] remains this crate's own hand-rolled double,
25//! kept because it models the set-per-name bookkeeping independently of
26//! `didbot-dns`'s, which is what makes it useful as a check on
27//! `DnsProviderAdapter` rather than a copy of what it wraps.
28
29//! The same seam carries [`AcmeDnsChallenge::authorize_issuer`], which is
30//! about the zone rather than about one order: the `CAA` record saying which
31//! authority, and which account at that authority, may issue for any name in
32//! it. It belongs here because both halves of that record — the authority
33//! and the account URI — are read off the very objects an order is placed
34//! through, and a `CAA` record that disagrees with them is an outage.
35
36use std::collections::{BTreeMap, BTreeSet};
37use std::sync::{Arc, Mutex};
38
39use didbot_dns::{CaaValue, DnsError, DnsProvider};
40
41/// Why a challenge value could not be published or withdrawn.
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum AcmeDnsError {
44    /// The backend refused the name, request, or credential.
45    #[error("could not publish the challenge at {name:?}: {reason}")]
46    Publish {
47        /// The `_acme-challenge.<zone>` name that was refused.
48        name: String,
49        /// What the backend said.
50        reason: String,
51    },
52    /// The backend could not withdraw a previously published set.
53    #[error("could not withdraw the challenge at {name:?}: {reason}")]
54    Withdraw {
55        /// The `_acme-challenge.<zone>` name that could not be cleared.
56        name: String,
57        /// What the backend said.
58        reason: String,
59    },
60}
61
62/// Who may issue certificates for a zone: one authority, one account at it,
63/// and where that authority should report a request it refused.
64///
65/// Every field is read off the live ACME objects an order is about to be
66/// placed through, never from configuration. A `CAA` record naming an
67/// authority or an account this deployment cannot actually use does not fail
68/// safe — it stops the next renewal, and an expired certificate is a total
69/// outage for every hostname in the zone, agents included. Deriving both
70/// halves from the same objects is what makes that failure unreachable.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct IssuancePolicy {
73    /// The authority's domain, for RFC 8659's `issue` property.
74    pub issuer: String,
75    /// The ACME account URL, for RFC 8657's `accounturi` parameter.
76    pub account_uri: String,
77    /// Where an authority should report an issuance this record refused, for
78    /// RFC 8659's `iodef` property. Empty is legitimate: a deployment with no
79    /// contact still gets the refusal, it just does not hear about it.
80    pub report_to: Vec<String>,
81}
82
83impl IssuancePolicy {
84    /// The `CAA` value set this policy publishes.
85    ///
86    /// No `issuewild`. RFC 8659 falls back to `issue` for a wildcard request
87    /// when no `issuewild` is present, and this deployment covers a zone and
88    /// its wildcard from one order through one account — so a second property
89    /// could only restate this one or contradict it.
90    pub fn caa_values(&self) -> Vec<CaaValue> {
91        let mut values = vec![CaaValue::issue_for_account(&self.issuer, &self.account_uri)];
92        values.extend(self.report_to.iter().map(CaaValue::iodef));
93        values
94    }
95}
96
97/// What ACME's DNS-01 step needs from a DNS backend, and nothing more.
98///
99/// `name` is always a full `_acme-challenge.<host>` name; this trait does not
100/// prepend the label, because the caller is the one that knows how many
101/// identifiers share it. `values` is the complete set that must be visible at
102/// once — a wildcard-plus-apex order asks for two — and a real
103/// implementation must publish all of them as one operation's worth of
104/// records, not a sequence of single-value writes that a resolver could catch
105/// mid-update.
106pub trait AcmeDnsChallenge: Send + Sync {
107    /// Publishes exactly `values` as TXT records at `name`, replacing
108    /// whatever this backend had published there before.
109    ///
110    /// Idempotent by design: a renewal that retries after a partial failure
111    /// must be able to call this again with the same or a refreshed value
112    /// set without first working out what is already there.
113    fn publish_challenge(&self, name: &str, values: &[String]) -> Result<(), AcmeDnsError>;
114
115    /// Removes every value published at `name`.
116    ///
117    /// Called once the order no longer needs the challenge, whether it
118    /// succeeded or failed — a stale challenge TXT record is not harmful the
119    /// way a stale `did:web` record is, but it is a leftover nobody should
120    /// have to notice and clean up by hand.
121    fn clear_challenge(&self, name: &str) -> Result<(), AcmeDnsError>;
122
123    /// Publishes `policy` as the `CAA` record set at `zone`, replacing
124    /// whatever was there.
125    ///
126    /// Called at every start, before the first order, so the record always
127    /// names the account the process is about to use. There is no method to
128    /// remove it: a zone with no `CAA` record authorises every authority,
129    /// which is the state this exists to leave.
130    ///
131    /// This is not a defence against whoever holds the credential that writes
132    /// it — that holder can rewrite this record along with everything else in
133    /// the zone. It narrows issuance to one account, so obtaining a
134    /// certificate for a name here by any other route requires changing this
135    /// record first, and it points refusals at a contact the authority
136    /// reaches directly rather than through this host.
137    fn authorize_issuer(&self, zone: &str, policy: &IssuancePolicy) -> Result<(), AcmeDnsError>;
138}
139
140/// The in-memory double: what this crate's own tests use, and a model for a
141/// production implementation's bookkeeping.
142///
143/// Deliberately not wired to `didbot-dns`'s `InMemoryDns`: this one holds a
144/// whole set per name and replaces it, so it checks [`DnsProviderAdapter`]'s
145/// diff-per-value join from outside rather than sharing its bookkeeping.
146#[derive(Debug, Default)]
147pub struct InMemoryAcmeDns {
148    published: Mutex<BTreeMap<String, Vec<String>>>,
149    caa: Mutex<BTreeMap<String, IssuancePolicy>>,
150}
151
152impl InMemoryAcmeDns {
153    /// A backend with nothing published.
154    pub fn new() -> Self {
155        Self::default()
156    }
157
158    /// The values currently published at `name`, if any.
159    pub fn values(&self, name: &str) -> Option<Vec<String>> {
160        self.published
161            .lock()
162            .unwrap_or_else(|poisoned| poisoned.into_inner())
163            .get(name)
164            .cloned()
165    }
166}
167
168impl AcmeDnsChallenge for InMemoryAcmeDns {
169    fn publish_challenge(&self, name: &str, values: &[String]) -> Result<(), AcmeDnsError> {
170        let mut map = self
171            .published
172            .lock()
173            .unwrap_or_else(|poisoned| poisoned.into_inner());
174        map.insert(name.to_owned(), values.to_vec());
175        tracing::info!(
176            name,
177            count = values.len(),
178            "published acme dns-01 challenge"
179        );
180        Ok(())
181    }
182
183    fn clear_challenge(&self, name: &str) -> Result<(), AcmeDnsError> {
184        let mut map = self
185            .published
186            .lock()
187            .unwrap_or_else(|poisoned| poisoned.into_inner());
188        map.remove(name);
189        tracing::info!(name, "withdrew acme dns-01 challenge");
190        Ok(())
191    }
192
193    fn authorize_issuer(&self, zone: &str, policy: &IssuancePolicy) -> Result<(), AcmeDnsError> {
194        self.caa
195            .lock()
196            .unwrap_or_else(|poisoned| poisoned.into_inner())
197            .insert(zone.to_owned(), policy.clone());
198        tracing::info!(zone, issuer = %policy.issuer, "recorded zone issuance policy");
199        Ok(())
200    }
201}
202
203/// Adapts any [`didbot_dns::DnsProvider`] into an [`AcmeDnsChallenge`].
204///
205/// See the module docs for why this is safe to be this thin: the provider's
206/// own [`DnsProvider::publish_txt`]/[`DnsProvider::withdraw_txt`] are
207/// already additive and idempotent per value, so publishing a *set* is just
208/// diffing that set against [`DnsProvider::txt_values`] and issuing one call
209/// per value that needs to change. Both directions of that diff run to
210/// completion (including waiting for propagation, on a backend that does
211/// that — see `Route53Dns`) before [`Self::publish_challenge`] or
212/// [`Self::clear_challenge`] return, so a caller sequencing several names —
213/// as `crate::acme::issue` does — never proceeds to the next step with a
214/// change still in flight underneath it.
215pub struct DnsProviderAdapter<D> {
216    dns: Arc<D>,
217}
218
219impl<D: DnsProvider> DnsProviderAdapter<D> {
220    /// Wraps `dns` so it can answer ACME's DNS-01 step.
221    pub fn new(dns: Arc<D>) -> Self {
222        Self { dns }
223    }
224}
225
226impl<D: DnsProvider> AcmeDnsChallenge for DnsProviderAdapter<D> {
227    fn publish_challenge(&self, name: &str, values: &[String]) -> Result<(), AcmeDnsError> {
228        let existing: BTreeSet<String> = self.dns.txt_values(name).into_iter().collect();
229        let wanted: BTreeSet<String> = values.iter().cloned().collect();
230
231        // Add first, then remove: a caller reading mid-way through this call
232        // sees a superset of `wanted`, never a subset of it — the direction
233        // that matters, since a missing value is a failed challenge and an
234        // extra one briefly still there is not.
235        for value in wanted.difference(&existing) {
236            self.dns
237                .publish_txt(name, value)
238                .map_err(|err| publish_error(name, &err))?;
239        }
240        for value in existing.difference(&wanted) {
241            // Already gone by the time we get here — a concurrent caller, or
242            // a retried publish that already cleaned it up — is the goal
243            // state reached by a different path, not a failure.
244            match self.dns.withdraw_txt(name, value) {
245                Ok(()) | Err(DnsError::NotPublished { .. }) => {}
246                Err(err) => return Err(publish_error(name, &err)),
247            }
248        }
249        Ok(())
250    }
251
252    fn authorize_issuer(&self, zone: &str, policy: &IssuancePolicy) -> Result<(), AcmeDnsError> {
253        self.dns
254            .publish_caa(zone, &policy.caa_values())
255            .map_err(|err| publish_error(zone, &err))
256    }
257
258    fn clear_challenge(&self, name: &str) -> Result<(), AcmeDnsError> {
259        for value in self.dns.txt_values(name) {
260            match self.dns.withdraw_txt(name, &value) {
261                Ok(()) | Err(DnsError::NotPublished { .. }) => {}
262                Err(err) => return Err(withdraw_error(name, &err)),
263            }
264        }
265        Ok(())
266    }
267}
268
269fn publish_error(name: &str, err: &DnsError) -> AcmeDnsError {
270    AcmeDnsError::Publish {
271        name: name.to_owned(),
272        reason: err.to_string(),
273    }
274}
275
276fn withdraw_error(name: &str, err: &DnsError) -> AcmeDnsError {
277    AcmeDnsError::Withdraw {
278        name: name.to_owned(),
279        reason: err.to_string(),
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use didbot_dns::InMemoryDns;
287
288    #[test]
289    fn publishes_two_values_at_one_name() {
290        let dns = InMemoryAcmeDns::new();
291        let name = "_acme-challenge.pds.did.bot";
292        dns.publish_challenge(name, &["one".into(), "two".into()])
293            .unwrap();
294        let values = dns.values(name).unwrap();
295        assert_eq!(values, vec!["one".to_string(), "two".to_string()]);
296    }
297
298    #[test]
299    fn the_caa_set_names_the_account_and_every_contact() {
300        let policy = IssuancePolicy {
301            issuer: "letsencrypt.org".into(),
302            account_uri: "https://acme-v02.api.letsencrypt.org/acme/acct/7".into(),
303            report_to: vec!["mailto:ops@example.com".into()],
304        };
305        let values = policy.caa_values();
306        assert_eq!(values.len(), 2);
307        assert_eq!(
308            values[0].to_wire(),
309            "0 issue \"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/7\""
310        );
311        assert_eq!(values[1].to_wire(), "0 iodef \"mailto:ops@example.com\"");
312        // No issuewild: RFC 8659 falls back to `issue` for a wildcard when
313        // none is present, and the wildcard is in the same order.
314        assert!(values.iter().all(|v| v.tag != "issuewild"));
315    }
316
317    #[test]
318    fn authorizing_an_issuer_reaches_the_underlying_zone() {
319        let dns = Arc::new(InMemoryDns::new());
320        let adapter = DnsProviderAdapter::new(dns.clone());
321        let policy = IssuancePolicy {
322            issuer: "letsencrypt.org".into(),
323            account_uri: "https://acme.example/acct/7".into(),
324            report_to: Vec::new(),
325        };
326        adapter.authorize_issuer("pds.did.bot", &policy).unwrap();
327        assert_eq!(dns.caa_values("pds.did.bot"), policy.caa_values());
328
329        // A restart with a new account replaces the record; it must not end
330        // up authorising both.
331        let rotated = IssuancePolicy {
332            account_uri: "https://acme.example/acct/8".into(),
333            ..policy
334        };
335        adapter.authorize_issuer("pds.did.bot", &rotated).unwrap();
336        assert_eq!(dns.caa_values("pds.did.bot"), rotated.caa_values());
337        assert_eq!(dns.caa_values("pds.did.bot").len(), 1);
338    }
339
340    #[test]
341    fn republishing_replaces_rather_than_accumulates() {
342        let dns = InMemoryAcmeDns::new();
343        let name = "_acme-challenge.pds.did.bot";
344        dns.publish_challenge(name, &["stale".into()]).unwrap();
345        dns.publish_challenge(name, &["fresh".into()]).unwrap();
346        assert_eq!(dns.values(name).unwrap(), vec!["fresh".to_string()]);
347    }
348
349    #[test]
350    fn clearing_removes_the_whole_set() {
351        let dns = InMemoryAcmeDns::new();
352        let name = "_acme-challenge.pds.did.bot";
353        dns.publish_challenge(name, &["one".into(), "two".into()])
354            .unwrap();
355        dns.clear_challenge(name).unwrap();
356        assert_eq!(dns.values(name), None);
357    }
358
359    #[test]
360    fn adapter_publishes_both_values_at_the_shared_name() {
361        let backend = Arc::new(InMemoryDns::new());
362        let adapter = DnsProviderAdapter::new(backend.clone());
363        let name = "_acme-challenge.pds.did.bot";
364        adapter
365            .publish_challenge(
366                name,
367                &["apex-token".to_string(), "wildcard-token".to_string()],
368            )
369            .unwrap();
370        assert_eq!(
371            backend.txt_values(name),
372            vec!["apex-token".to_string(), "wildcard-token".to_string()]
373        );
374    }
375
376    #[test]
377    fn adapter_a_second_publish_adds_rather_than_drops_the_first_value() {
378        // The regression this exists to catch: an adapter that withdrew and
379        // republished the whole set non-additively would leave a window with
380        // only one value visible. Publishing the apex's value, then the
381        // wildcard's, as two separate `publish_challenge` calls -- which is
382        // exactly what a renewal replaying a partially-completed attempt
383        // looks like -- must end with both still present.
384        let backend = Arc::new(InMemoryDns::new());
385        let adapter = DnsProviderAdapter::new(backend.clone());
386        let name = "_acme-challenge.pds.did.bot";
387        adapter
388            .publish_challenge(name, &["apex-token".to_string()])
389            .unwrap();
390        adapter
391            .publish_challenge(
392                name,
393                &["apex-token".to_string(), "wildcard-token".to_string()],
394            )
395            .unwrap();
396        assert_eq!(
397            backend.txt_values(name),
398            vec!["apex-token".to_string(), "wildcard-token".to_string()]
399        );
400    }
401
402    #[test]
403    fn adapter_publish_withdraws_values_no_longer_wanted() {
404        let backend = Arc::new(InMemoryDns::new());
405        let adapter = DnsProviderAdapter::new(backend.clone());
406        let name = "_acme-challenge.pds.did.bot";
407        adapter
408            .publish_challenge(name, &["stale".to_string()])
409            .unwrap();
410        adapter
411            .publish_challenge(name, &["fresh".to_string()])
412            .unwrap();
413        assert_eq!(backend.txt_values(name), vec!["fresh".to_string()]);
414    }
415
416    #[test]
417    fn adapter_republishing_the_same_set_is_a_no_op() {
418        let backend = Arc::new(InMemoryDns::new());
419        let adapter = DnsProviderAdapter::new(backend.clone());
420        let name = "_acme-challenge.pds.did.bot";
421        let values = vec!["one".to_string(), "two".to_string()];
422        adapter.publish_challenge(name, &values).unwrap();
423        adapter.publish_challenge(name, &values).unwrap();
424        assert_eq!(backend.txt_values(name), values);
425    }
426
427    #[test]
428    fn adapter_clear_removes_every_value() {
429        let backend = Arc::new(InMemoryDns::new());
430        let adapter = DnsProviderAdapter::new(backend.clone());
431        let name = "_acme-challenge.pds.did.bot";
432        adapter
433            .publish_challenge(name, &["one".to_string(), "two".to_string()])
434            .unwrap();
435        adapter.clear_challenge(name).unwrap();
436        assert!(backend.txt_values(name).is_empty());
437    }
438
439    #[test]
440    fn adapter_clear_of_nothing_published_is_not_an_error() {
441        let backend = Arc::new(InMemoryDns::new());
442        let adapter = DnsProviderAdapter::new(backend);
443        assert!(adapter
444            .clear_challenge("_acme-challenge.never.example")
445            .is_ok());
446    }
447}