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}