Skip to main content

featherbit/acme/
client.rs

1//! The ACME protocol boundary.
2//!
3//! [`AcmeClient`]/[`AcmeOrder`] are the *only* surface the order state machine
4//! (`order.rs`) and the scheduler (`manager.rs`) see, so both are unit-tested
5//! against [`mock::MockAcmeClient`] with no network. [`InstantAcmeClient`]
6//! implements them over `instant-acme` (JWS, directory, account/EAB, orders,
7//! ARI). [`AcmeClientFactory`] exists because the CA may be unreachable at
8//! startup: the manager connects lazily and retries.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13use base64::Engine;
14use instant_acme::{
15    Account, AccountCredentials, AuthorizationStatus, ChallengeType, ExternalAccountKey,
16    Identifier, NewAccount, NewOrder, OrderStatus, RetryPolicy,
17};
18use tracing::{debug, info};
19
20use super::storage::CertStorage;
21use super::AcmeError;
22use crate::config::AcmeConfig;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PendingChallenge {
26    pub domain: String,
27    /// `<token>.<account-key-thumbprint>`; the solver hashes it.
28    pub key_auth: String,
29}
30
31#[async_trait]
32pub trait AcmeOrder: Send {
33    /// One entry per authorization still `pending` (already-valid ones are skipped).
34    async fn pending_challenges(&mut self) -> Result<Vec<PendingChallenge>, AcmeError>;
35    /// Tells the CA the TLS-ALPN-01 challenge for `domain` may be validated now.
36    async fn mark_ready(&mut self, domain: &str) -> Result<(), AcmeError>;
37    /// Polls until the order is `ready`; `Err` on `invalid` or timeout.
38    async fn wait_ready(&mut self) -> Result<(), AcmeError>;
39    /// Submits the CSR and downloads the PEM chain.
40    async fn finalize(&mut self, csr_der: &[u8]) -> Result<String, AcmeError>;
41}
42
43#[async_trait]
44pub trait AcmeClient: Send + Sync {
45    async fn new_order(&self, domains: &[String]) -> Result<Box<dyn AcmeOrder>, AcmeError>;
46    /// RFC 9773 renewal-info window for the certificate, when the CA offers it.
47    async fn renewal_window(&self, leaf_der: &[u8]) -> Result<Option<(i64, i64)>, AcmeError>;
48}
49
50#[async_trait]
51pub trait AcmeClientFactory: Send + Sync {
52    async fn connect(&self) -> Result<Arc<dyn AcmeClient>, AcmeError>;
53}
54
55/// EAB HMAC keys are handed out base64url; some CAs paste standard base64.
56pub fn decode_hmac_key(s: &str) -> Result<Vec<u8>, AcmeError> {
57    let s = s.trim();
58    base64::engine::general_purpose::URL_SAFE_NO_PAD
59        .decode(s)
60        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(s))
61        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(s))
62        .map_err(|e| AcmeError::Config(format!("acme.eab.hmac_key is not base64: {e}")))
63}
64
65fn proto(e: instant_acme::Error) -> AcmeError {
66    AcmeError::Protocol(e.to_string())
67}
68
69pub struct InstantAcmeFactory {
70    cfg: AcmeConfig,
71    storage: Arc<dyn CertStorage>,
72}
73
74impl InstantAcmeFactory {
75    pub fn new(cfg: AcmeConfig, storage: Arc<dyn CertStorage>) -> Self {
76        Self { cfg, storage }
77    }
78
79    fn builder(&self) -> Result<instant_acme::AccountBuilder, AcmeError> {
80        match &self.cfg.directory_ca_path {
81            Some(path) => Account::builder_with_root(path).map_err(proto),
82            None => Account::builder().map_err(proto),
83        }
84    }
85}
86
87#[async_trait]
88impl AcmeClientFactory for InstantAcmeFactory {
89    /// Loads the stored account or registers a new one (persisting its credentials).
90    async fn connect(&self) -> Result<Arc<dyn AcmeClient>, AcmeError> {
91        if let Some(bytes) = self.storage.load_account().await? {
92            let creds: AccountCredentials = serde_json::from_slice(&bytes).map_err(|e| {
93                AcmeError::Storage(format!("stored account credentials are corrupt: {e}"))
94            })?;
95            let account = self
96                .builder()?
97                .from_credentials(creds)
98                .await
99                .map_err(proto)?;
100            debug!("acme: using stored account {}", account.id());
101            return Ok(Arc::new(InstantAcmeClient { account }));
102        }
103        let contacts: Vec<&str> = self.cfg.contact.iter().map(String::as_str).collect();
104        let eab = match &self.cfg.eab {
105            Some(e) => Some(ExternalAccountKey::new(
106                e.key_id.clone(),
107                &decode_hmac_key(&e.hmac_key)?,
108            )),
109            None => None,
110        };
111        let (account, creds) = self
112            .builder()?
113            .create(
114                &NewAccount {
115                    contact: &contacts,
116                    terms_of_service_agreed: self.cfg.terms_of_service_agreed,
117                    only_return_existing: false,
118                },
119                self.cfg.directory_url.clone(),
120                eab.as_ref(),
121            )
122            .await
123            .map_err(proto)?;
124        let bytes = serde_json::to_vec(&creds)
125            .map_err(|e| AcmeError::Storage(format!("serialize account credentials: {e}")))?;
126        self.storage.save_account(&bytes).await?;
127        info!(
128            "acme: registered account {} at {}",
129            account.id(),
130            self.cfg.directory_url
131        );
132        Ok(Arc::new(InstantAcmeClient { account }))
133    }
134}
135
136pub struct InstantAcmeClient {
137    account: Account,
138}
139
140#[async_trait]
141impl AcmeClient for InstantAcmeClient {
142    async fn new_order(&self, domains: &[String]) -> Result<Box<dyn AcmeOrder>, AcmeError> {
143        let identifiers: Vec<Identifier> = domains.iter().cloned().map(Identifier::Dns).collect();
144        let order = self
145            .account
146            .new_order(&NewOrder::new(&identifiers))
147            .await
148            .map_err(proto)?;
149        Ok(Box::new(InstantAcmeOrder { order }))
150    }
151
152    async fn renewal_window(&self, leaf_der: &[u8]) -> Result<Option<(i64, i64)>, AcmeError> {
153        let der = rustls::pki_types::CertificateDer::from(leaf_der.to_vec());
154        let id = match instant_acme::CertificateIdentifier::try_from(&der) {
155            Ok(id) => id,
156            Err(e) => {
157                debug!("acme: no ARI identifier for certificate: {e}");
158                return Ok(None);
159            }
160        };
161        match self.account.renewal_info(&id).await {
162            Ok((info, _retry_after)) => Ok(Some((
163                info.suggested_window.start.unix_timestamp(),
164                info.suggested_window.end.unix_timestamp(),
165            ))),
166            // ARI is advisory: a CA without it (or a transient error) just means
167            // "use renew_before".
168            Err(e) => {
169                debug!("acme: renewal_info unavailable: {e}");
170                Ok(None)
171            }
172        }
173    }
174}
175
176struct InstantAcmeOrder {
177    order: instant_acme::Order,
178}
179
180fn dns_name(ident: &Identifier) -> Result<String, AcmeError> {
181    match ident {
182        Identifier::Dns(d) => Ok(d.clone()),
183        other => Err(AcmeError::Protocol(format!(
184            "unsupported identifier {other:?}"
185        ))),
186    }
187}
188
189#[async_trait]
190impl AcmeOrder for InstantAcmeOrder {
191    async fn pending_challenges(&mut self) -> Result<Vec<PendingChallenge>, AcmeError> {
192        let mut out = Vec::new();
193        let mut authorizations = self.order.authorizations();
194        while let Some(result) = authorizations.next().await {
195            let mut authz = result.map_err(proto)?;
196            let domain = dns_name(authz.identifier().identifier)?;
197            match authz.status {
198                AuthorizationStatus::Pending => {}
199                AuthorizationStatus::Valid => continue,
200                other => {
201                    return Err(AcmeError::Protocol(format!(
202                        "authorization for {domain} is {other:?}"
203                    )))
204                }
205            }
206            let challenge = authz.challenge(ChallengeType::TlsAlpn01).ok_or_else(|| {
207                AcmeError::Protocol(format!("CA offers no tls-alpn-01 challenge for {domain}"))
208            })?;
209            out.push(PendingChallenge {
210                domain,
211                key_auth: challenge.key_authorization().as_str().to_string(),
212            });
213        }
214        Ok(out)
215    }
216
217    async fn mark_ready(&mut self, domain: &str) -> Result<(), AcmeError> {
218        let mut authorizations = self.order.authorizations();
219        while let Some(result) = authorizations.next().await {
220            let mut authz = result.map_err(proto)?;
221            if dns_name(authz.identifier().identifier)? != domain {
222                continue;
223            }
224            let mut challenge = authz.challenge(ChallengeType::TlsAlpn01).ok_or_else(|| {
225                AcmeError::Protocol(format!("CA offers no tls-alpn-01 challenge for {domain}"))
226            })?;
227            return challenge.set_ready().await.map_err(proto);
228        }
229        Err(AcmeError::Protocol(format!(
230            "order has no authorization for {domain}"
231        )))
232    }
233
234    async fn wait_ready(&mut self) -> Result<(), AcmeError> {
235        let status = self
236            .order
237            .poll_ready(&RetryPolicy::default())
238            .await
239            .map_err(proto)?;
240        if matches!(status, OrderStatus::Ready | OrderStatus::Valid) {
241            Ok(())
242        } else {
243            let detail = self
244                .order
245                .state()
246                .error
247                .as_ref()
248                .map(|p| p.to_string())
249                .unwrap_or_default();
250            Err(AcmeError::Protocol(format!(
251                "order is {status:?}: {detail}"
252            )))
253        }
254    }
255
256    async fn finalize(&mut self, csr_der: &[u8]) -> Result<String, AcmeError> {
257        self.order.finalize_csr(csr_der).await.map_err(proto)?;
258        self.order
259            .poll_certificate(&RetryPolicy::default())
260            .await
261            .map_err(proto)
262    }
263}
264
265/// A scripted in-process CA for unit tests: hands out deterministic key
266/// authorizations, requires every challenge to be marked ready, and signs the
267/// CSR's public key with its own root so `load_certified_key` accepts the
268/// chain. `MockBehavior` injects failures.
269#[cfg(test)]
270pub(crate) mod mock {
271    use super::*;
272    use std::sync::atomic::{AtomicUsize, Ordering};
273    use std::sync::Mutex;
274
275    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
276    pub enum MockStep {
277        NewOrder,
278        WaitReady,
279        Finalize,
280    }
281
282    #[derive(Debug, Clone)]
283    pub struct MockBehavior {
284        pub fail_step: Option<MockStep>,
285        pub ari: Option<(i64, i64)>,
286        /// Sign a random key instead of the CSR's (verification must reject it).
287        pub wrong_key_chain: bool,
288        /// Echo back an authorization for an identifier that was never
289        /// requested (a hostile/broken CA).
290        pub extra_pending_domain: Option<String>,
291        pub validity_secs: i64,
292    }
293
294    impl Default for MockBehavior {
295        fn default() -> Self {
296            Self {
297                fail_step: None,
298                ari: None,
299                wrong_key_chain: false,
300                extra_pending_domain: None,
301                validity_secs: 90 * 86_400,
302            }
303        }
304    }
305
306    pub struct MockAcmeClient {
307        ca_params: rcgen::CertificateParams,
308        ca_key: rcgen::KeyPair,
309        ca_pem: String,
310        pub behavior: Mutex<MockBehavior>,
311        orders: AtomicUsize,
312    }
313
314    impl MockAcmeClient {
315        pub fn new(behavior: MockBehavior) -> Arc<Self> {
316            let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
317            ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
318            ca_params
319                .distinguished_name
320                .push(rcgen::DnType::CommonName, "Mock ACME CA");
321            let ca_key = rcgen::KeyPair::generate().unwrap();
322            let ca_pem = ca_params.self_signed(&ca_key).unwrap().pem();
323            Arc::new(Self {
324                ca_params,
325                ca_key,
326                ca_pem,
327                behavior: Mutex::new(behavior),
328                orders: AtomicUsize::new(0),
329            })
330        }
331
332        pub fn orders(&self) -> usize {
333            self.orders.load(Ordering::SeqCst)
334        }
335
336        pub fn set_behavior(&self, b: MockBehavior) {
337            *self.behavior.lock().unwrap() = b;
338        }
339
340        fn behavior(&self) -> MockBehavior {
341            self.behavior.lock().unwrap().clone()
342        }
343
344        fn sign(&self, csr_der: &[u8], domains: &[String]) -> Result<String, AcmeError> {
345            let b = self.behavior();
346            let csr = rcgen::CertificateSigningRequestParams::from_der(
347                &rustls::pki_types::CertificateSigningRequestDer::from(csr_der.to_vec()),
348            )
349            .map_err(|e| AcmeError::Protocol(format!("mock: bad CSR: {e}")))?;
350            let mut params = rcgen::CertificateParams::new(domains.to_vec()).unwrap();
351            let now = time::OffsetDateTime::now_utc();
352            params.not_before = now - time::Duration::minutes(1);
353            params.not_after = now + time::Duration::seconds(b.validity_secs);
354            let issuer = rcgen::Issuer::from_params(&self.ca_params, &self.ca_key);
355            let leaf = if b.wrong_key_chain {
356                let other = rcgen::KeyPair::generate().unwrap();
357                params.signed_by(&other, &issuer).unwrap()
358            } else {
359                // Same params (SANs, validity) but the CSR's public key.
360                rcgen::CertificateSigningRequestParams {
361                    params,
362                    public_key: csr.public_key,
363                }
364                .signed_by(&issuer)
365                .unwrap()
366            };
367            Ok(format!("{}{}", leaf.pem(), self.ca_pem))
368        }
369    }
370
371    pub struct MockOrder {
372        client: Arc<MockAcmeClient>,
373        domains: Vec<String>,
374        ready: Vec<String>,
375    }
376
377    /// Implemented on `Arc<MockAcmeClient>` (not `MockAcmeClient`) so an order
378    /// can call back into the CA; tests always hold the `Arc` `new()` returns.
379    #[async_trait]
380    impl AcmeClient for Arc<MockAcmeClient> {
381        async fn new_order(&self, domains: &[String]) -> Result<Box<dyn AcmeOrder>, AcmeError> {
382            self.orders.fetch_add(1, Ordering::SeqCst);
383            if self.behavior().fail_step == Some(MockStep::NewOrder) {
384                return Err(AcmeError::Protocol(
385                    "mock: newOrder rejected (rateLimited)".into(),
386                ));
387            }
388            Ok(Box::new(MockOrder {
389                client: self.clone(),
390                domains: domains.to_vec(),
391                ready: Vec::new(),
392            }))
393        }
394
395        async fn renewal_window(&self, _leaf_der: &[u8]) -> Result<Option<(i64, i64)>, AcmeError> {
396            Ok(self.behavior().ari)
397        }
398    }
399
400    #[async_trait]
401    impl AcmeOrder for MockOrder {
402        async fn pending_challenges(&mut self) -> Result<Vec<PendingChallenge>, AcmeError> {
403            let mut out: Vec<PendingChallenge> = self
404                .domains
405                .iter()
406                .map(|d| PendingChallenge {
407                    domain: d.clone(),
408                    key_auth: format!("tok-{d}.mockthumb"),
409                })
410                .collect();
411            if let Some(extra) = self.client.behavior().extra_pending_domain {
412                out.push(PendingChallenge {
413                    key_auth: format!("tok-{extra}.mockthumb"),
414                    domain: extra,
415                });
416            }
417            Ok(out)
418        }
419
420        async fn mark_ready(&mut self, domain: &str) -> Result<(), AcmeError> {
421            if !self.domains.iter().any(|d| d == domain) {
422                return Err(AcmeError::Protocol(format!(
423                    "mock: no authorization for {domain}"
424                )));
425            }
426            self.ready.push(domain.to_string());
427            Ok(())
428        }
429
430        async fn wait_ready(&mut self) -> Result<(), AcmeError> {
431            if self.client.behavior().fail_step == Some(MockStep::WaitReady) {
432                return Err(AcmeError::Protocol(
433                    "mock: order is Invalid: challenge failed".into(),
434                ));
435            }
436            if self.domains.iter().all(|d| self.ready.contains(d)) {
437                Ok(())
438            } else {
439                Err(AcmeError::Protocol(
440                    "mock: order is Invalid: authorization pending".into(),
441                ))
442            }
443        }
444
445        async fn finalize(&mut self, csr_der: &[u8]) -> Result<String, AcmeError> {
446            if self.client.behavior().fail_step == Some(MockStep::Finalize) {
447                return Err(AcmeError::Protocol(
448                    "mock: finalize rejected (badCSR)".into(),
449                ));
450            }
451            self.client.sign(csr_der, &self.domains)
452        }
453    }
454
455    /// Factory returning the same mock every time (or failing `fail_connects` times first).
456    pub struct MockFactory {
457        pub client: Arc<MockAcmeClient>,
458        pub fail_connects: AtomicUsize,
459    }
460
461    impl MockFactory {
462        pub fn new(client: Arc<MockAcmeClient>) -> Arc<Self> {
463            Arc::new(Self {
464                client,
465                fail_connects: AtomicUsize::new(0),
466            })
467        }
468    }
469
470    #[async_trait]
471    impl AcmeClientFactory for MockFactory {
472        async fn connect(&self) -> Result<Arc<dyn AcmeClient>, AcmeError> {
473            if self.fail_connects.load(Ordering::SeqCst) > 0 {
474                self.fail_connects.fetch_sub(1, Ordering::SeqCst);
475                return Err(AcmeError::Protocol("mock: directory unreachable".into()));
476            }
477            Ok(Arc::new(self.client.clone()) as Arc<dyn AcmeClient>)
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn decode_hmac_key_accepts_url_safe_and_standard_base64() {
488        assert_eq!(decode_hmac_key("AQID").unwrap(), vec![1, 2, 3]);
489        assert_eq!(decode_hmac_key("_-8").unwrap(), vec![0xff, 0xef]);
490        assert_eq!(decode_hmac_key("/+8=").unwrap(), vec![0xff, 0xef]);
491        assert!(decode_hmac_key("not base64!").is_err());
492    }
493
494    #[tokio::test]
495    async fn mock_client_issues_a_chain_for_the_csr_key() {
496        let client = mock::MockAcmeClient::new(mock::MockBehavior::default());
497        let mut order = client
498            .new_order(&["a.example.com".into(), "b.example.com".into()])
499            .await
500            .unwrap();
501        let pending = order.pending_challenges().await.unwrap();
502        assert_eq!(pending.len(), 2);
503        assert!(
504            order.wait_ready().await.is_err(),
505            "not all challenges marked ready"
506        );
507        for p in &pending {
508            order.mark_ready(&p.domain).await.unwrap();
509        }
510        order.wait_ready().await.unwrap();
511        let key = rcgen::KeyPair::generate().unwrap();
512        let csr = rcgen::CertificateParams::new(vec![
513            "a.example.com".to_string(),
514            "b.example.com".to_string(),
515        ])
516        .unwrap()
517        .serialize_request(&key)
518        .unwrap();
519        let chain = order.finalize(csr.der()).await.unwrap();
520        let (ck, leaf) = crate::acme::load_certified_key(&chain, &key.serialize_pem()).unwrap();
521        assert!(ck.end_entity_cert().is_ok());
522        let mut sans = crate::acme::leaf_dns_sans(&leaf).unwrap();
523        sans.sort();
524        assert_eq!(
525            sans,
526            vec!["a.example.com".to_string(), "b.example.com".to_string()]
527        );
528        assert!(crate::acme::parse_cert_meta(&leaf)
529            .unwrap()
530            .issuer
531            .contains("Mock ACME CA"));
532        assert_eq!(client.orders(), 1);
533    }
534
535    #[tokio::test]
536    async fn mock_client_honors_failure_script_and_ari() {
537        let client = mock::MockAcmeClient::new(mock::MockBehavior {
538            fail_step: Some(mock::MockStep::Finalize),
539            ari: Some((100, 200)),
540            ..Default::default()
541        });
542        let mut order = client.new_order(&["a.example.com".into()]).await.unwrap();
543        for p in order.pending_challenges().await.unwrap() {
544            order.mark_ready(&p.domain).await.unwrap();
545        }
546        order.wait_ready().await.unwrap();
547        assert!(order.finalize(b"irrelevant").await.is_err());
548        assert_eq!(
549            client.renewal_window(b"any").await.unwrap(),
550            Some((100, 200))
551        );
552    }
553}