1use 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#[derive(Clone)]
44pub struct IssuedCertificate {
45 pub cert_chain_pem: String,
47 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 f.debug_struct("IssuedCertificate")
57 .field("cert_chain_pem", &self.cert_chain_pem)
58 .finish_non_exhaustive()
59 }
60}
61
62#[derive(Debug, thiserror::Error)]
64pub enum IssueError {
65 #[error("acme: {0}")]
67 Acme(#[from] instant_acme::Error),
68 #[error("dns-01: {0}")]
70 Dns(#[from] AcmeDnsError),
71 #[error("no dns-01 challenge offered for {identifier}")]
73 NoDns01Challenge {
74 identifier: String,
76 },
77 #[error("order finished in an unexpected state: {0:?}")]
79 UnexpectedOrderStatus(OrderStatus),
80}
81
82pub fn zone_and_wildcard(zone: &str) -> Vec<String> {
85 vec![zone.to_owned(), format!("*.{zone}")]
86}
87
88fn 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
103pub(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
116pub 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 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 {
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 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 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}