Skip to main content

featherbit/acme/
order.rs

1//! One certificate issuance, start to finish: fresh key → newOrder → register
2//! every TLS-ALPN-01 key authorization with the solver → tell the CA "ready" →
3//! wait → CSR → finalize → download → **verify** → hand back a [`StoredCert`].
4//! Challenges are cleared on every exit path so a stuck order never leaves a
5//! validatable challenge cert behind. Verification runs before anything is
6//! persisted: a CA returning garbage never evicts a working certificate.
7
8use super::challenge::TlsAlpnSolver;
9use super::client::{AcmeClient, PendingChallenge};
10use super::{leaf_dns_sans, load_certified_key, now_unix, parse_cert_meta, AcmeError, StoredCert};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum KeyType {
14    EcdsaP256,
15    EcdsaP384,
16}
17
18impl KeyType {
19    pub fn parse(s: &str) -> Result<Self, AcmeError> {
20        match s {
21            "ecdsa-p256" => Ok(KeyType::EcdsaP256),
22            "ecdsa-p384" => Ok(KeyType::EcdsaP384),
23            other => Err(AcmeError::Config(format!(
24                "key_type '{other}' is not supported (ecdsa-p256 | ecdsa-p384)"
25            ))),
26        }
27    }
28
29    fn alg(self) -> &'static rcgen::SignatureAlgorithm {
30        match self {
31            KeyType::EcdsaP256 => &rcgen::PKCS_ECDSA_P256_SHA256,
32            KeyType::EcdsaP384 => &rcgen::PKCS_ECDSA_P384_SHA384,
33        }
34    }
35}
36
37/// A fresh private key for one issuance (never reused across renewals).
38pub fn generate_key(kt: KeyType) -> Result<rcgen::KeyPair, AcmeError> {
39    rcgen::KeyPair::generate_for(kt.alg()).map_err(|e| AcmeError::Crypto(e.to_string()))
40}
41
42/// DER CSR with SAN = `domains`, CN = the first domain.
43pub fn build_csr(domains: &[String], key: &rcgen::KeyPair) -> Result<Vec<u8>, AcmeError> {
44    let mut params = rcgen::CertificateParams::new(domains.to_vec())
45        .map_err(|e| AcmeError::Crypto(e.to_string()))?;
46    if let Some(first) = domains.first() {
47        params
48            .distinguished_name
49            .push(rcgen::DnType::CommonName, first.as_str());
50    }
51    let csr = params
52        .serialize_request(key)
53        .map_err(|e| AcmeError::Crypto(e.to_string()))?;
54    Ok(csr.der().as_ref().to_vec())
55}
56
57/// Clock-skew tolerance for a just-issued `not_before`.
58const NOT_BEFORE_SKEW_SECS: i64 = 300;
59
60/// The chain parses, the leaf's public key is `key`, it is valid at `now`
61/// (±skew), and its DNS SANs cover every domain.
62pub fn verify_chain(
63    chain_pem: &str,
64    key: &rcgen::KeyPair,
65    domains: &[String],
66    now: i64,
67) -> Result<(), AcmeError> {
68    // `load_certified_key` is the key-match gate: it calls rustls'
69    // `CertifiedKey::keys_match()`, which errors (`AcmeError::Certificate`) if
70    // the leaf's public key doesn't match `key`. No separate SPKI compare
71    // needed here.
72    let (_, leaf) = load_certified_key(chain_pem, &key.serialize_pem())?;
73    let meta = parse_cert_meta(&leaf)?;
74    if meta.not_after <= now {
75        return Err(AcmeError::Certificate(format!(
76            "issued certificate is already expired (not_after={})",
77            meta.not_after
78        )));
79    }
80    if meta.not_before > now + NOT_BEFORE_SKEW_SECS {
81        return Err(AcmeError::Certificate(format!(
82            "issued certificate is not yet valid (not_before={})",
83            meta.not_before
84        )));
85    }
86    let sans = leaf_dns_sans(&leaf)?;
87    for d in domains {
88        if !sans.iter().any(|s| s == &d.to_ascii_lowercase()) {
89            return Err(AcmeError::Certificate(format!(
90                "issued certificate lacks SAN {d} (has {sans:?})"
91            )));
92        }
93    }
94    Ok(())
95}
96
97/// Runs one order. On success the certificate is verified but **not** stored —
98/// the caller persists and publishes it.
99pub async fn issue(
100    client: &dyn AcmeClient,
101    solver: &TlsAlpnSolver,
102    domains: &[String],
103    key_type: KeyType,
104) -> Result<StoredCert, AcmeError> {
105    let mut registered: Vec<String> = Vec::new();
106    let result = run(client, solver, domains, key_type, &mut registered).await;
107    for domain in &registered {
108        if let Err(e) = solver.clear(domain).await {
109            tracing::warn!("acme: failed to clear challenge for {domain}: {e}");
110        }
111    }
112    result
113}
114
115async fn run(
116    client: &dyn AcmeClient,
117    solver: &TlsAlpnSolver,
118    domains: &[String],
119    key_type: KeyType,
120    registered: &mut Vec<String>,
121) -> Result<StoredCert, AcmeError> {
122    let key = generate_key(key_type)?;
123    let mut order = client.new_order(domains).await?;
124    let pending: Vec<PendingChallenge> = order.pending_challenges().await?;
125    // The CA echoes the identifiers back, and those strings become filesystem
126    // paths and redis keys in the challenge store. Anything we did not ask for
127    // is refused *before* the solver (and therefore storage) ever sees it, so a
128    // hostile or broken CA cannot steer a write with something like
129    // `../../evil`.
130    let requested: std::collections::HashSet<String> =
131        domains.iter().map(|d| d.to_ascii_lowercase()).collect();
132    for p in &pending {
133        if !requested.contains(&p.domain.to_ascii_lowercase()) {
134            return Err(AcmeError::Protocol(format!(
135                "CA returned an authorization for an unrequested identifier '{}'",
136                p.domain
137            )));
138        }
139    }
140    for p in &pending {
141        solver.register(&p.domain, &p.key_auth).await?;
142        registered.push(p.domain.clone());
143    }
144    for p in &pending {
145        order.mark_ready(&p.domain).await?;
146    }
147    order.wait_ready().await?;
148    let csr = build_csr(domains, &key)?;
149    let chain_pem = order.finalize(&csr).await?;
150    let now = now_unix();
151    verify_chain(&chain_pem, &key, domains, now)?;
152    Ok(StoredCert {
153        chain_pem,
154        key_pem: key.serialize_pem(),
155        issued_at: now,
156    })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::acme::client::mock::{MockAcmeClient, MockBehavior, MockStep};
163    use crate::acme::storage::fs::FsCertStorage;
164    use std::sync::Arc;
165
166    fn solver(tag: &str) -> Arc<TlsAlpnSolver> {
167        let d = std::env::temp_dir().join(format!("fb_acme_order_{}_{}", tag, std::process::id()));
168        let _ = std::fs::remove_dir_all(&d);
169        TlsAlpnSolver::new(Arc::new(FsCertStorage::new(d)))
170    }
171
172    fn doms() -> Vec<String> {
173        vec!["a.example.com".to_string(), "b.example.com".to_string()]
174    }
175
176    #[test]
177    fn key_type_parse_and_generate() {
178        assert_eq!(KeyType::parse("ecdsa-p256").unwrap(), KeyType::EcdsaP256);
179        assert_eq!(KeyType::parse("ecdsa-p384").unwrap(), KeyType::EcdsaP384);
180        assert!(KeyType::parse("rsa-2048").is_err());
181        let k = generate_key(KeyType::EcdsaP384).unwrap();
182        assert!(k.is_compatible(&rcgen::PKCS_ECDSA_P384_SHA384));
183        let csr = build_csr(&doms(), &k).unwrap();
184        assert!(!csr.is_empty());
185    }
186
187    #[tokio::test]
188    async fn issue_happy_path_returns_verified_cert_and_clears_challenges() {
189        let client = MockAcmeClient::new(MockBehavior::default());
190        let solver = solver("happy");
191        let stored = issue(&client, &solver, &doms(), KeyType::EcdsaP256)
192            .await
193            .unwrap();
194        let (_, leaf) =
195            crate::acme::load_certified_key(&stored.chain_pem, &stored.key_pem).unwrap();
196        let mut sans = crate::acme::leaf_dns_sans(&leaf).unwrap();
197        sans.sort();
198        assert_eq!(sans, doms());
199        assert!(stored.issued_at > 0);
200        assert!(
201            solver.cached_domains().is_empty(),
202            "challenges cleared after success"
203        );
204        assert_eq!(client.orders(), 1);
205    }
206
207    #[tokio::test]
208    async fn issue_rejects_chain_for_a_foreign_key_and_clears_challenges() {
209        let client = MockAcmeClient::new(MockBehavior {
210            wrong_key_chain: true,
211            ..Default::default()
212        });
213        let solver = solver("wrongkey");
214        let err = issue(&client, &solver, &doms(), KeyType::EcdsaP256)
215            .await
216            .unwrap_err();
217        assert!(matches!(err, AcmeError::Certificate(_)), "{err}");
218        assert!(solver.cached_domains().is_empty());
219    }
220
221    #[tokio::test]
222    async fn issue_surfaces_ca_failures_and_clears_challenges() {
223        for step in [MockStep::NewOrder, MockStep::WaitReady, MockStep::Finalize] {
224            let client = MockAcmeClient::new(MockBehavior {
225                fail_step: Some(step),
226                ..Default::default()
227            });
228            let solver = solver(&format!("fail{step:?}"));
229            let err = issue(&client, &solver, &doms(), KeyType::EcdsaP256)
230                .await
231                .unwrap_err();
232            assert!(matches!(err, AcmeError::Protocol(_)), "{step:?}: {err}");
233            assert!(
234                solver.cached_domains().is_empty(),
235                "{step:?}: challenges must be cleared"
236            );
237        }
238    }
239
240    #[tokio::test]
241    async fn issue_rejects_an_authorization_for_an_unrequested_identifier() {
242        let client = MockAcmeClient::new(MockBehavior {
243            extra_pending_domain: Some("../../evil".into()),
244            ..Default::default()
245        });
246        let solver = solver("bogusident");
247        let err = issue(&client, &solver, &doms(), KeyType::EcdsaP256)
248            .await
249            .unwrap_err();
250        assert!(
251            matches!(&err, AcmeError::Protocol(m) if m.contains("unrequested identifier")),
252            "{err}"
253        );
254        assert!(
255            solver.cached_domains().is_empty(),
256            "nothing is registered — not even the legitimate domains —              before the whole set is validated"
257        );
258    }
259
260    #[test]
261    fn verify_chain_checks_sans_validity_and_key() {
262        let key = generate_key(KeyType::EcdsaP256).unwrap();
263        let mut params = rcgen::CertificateParams::new(vec!["a.example.com".to_string()]).unwrap();
264        let now = time::OffsetDateTime::now_utc();
265        params.not_before = now - time::Duration::hours(1);
266        params.not_after = now + time::Duration::days(30);
267        let cert = params.self_signed(&key).unwrap();
268        let pem = cert.pem();
269        let t = crate::acme::now_unix();
270        verify_chain(&pem, &key, &["a.example.com".to_string()], t).unwrap();
271        // Missing SAN.
272        assert!(verify_chain(&pem, &key, &doms(), t).is_err());
273        // Expired (evaluate "now" after not_after).
274        assert!(verify_chain(&pem, &key, &["a.example.com".to_string()], t + 31 * 86_400).is_err());
275        // Foreign key: rejected by `load_certified_key`'s key-match check
276        // (rustls `CertifiedKey::keys_match()`), not a separate SPKI compare.
277        let other = generate_key(KeyType::EcdsaP256).unwrap();
278        assert!(verify_chain(&pem, &other, &["a.example.com".to_string()], t).is_err());
279    }
280}