Skip to main content

postrust_proxy/tls/
acme.rs

1//! ACME certificate issuance over HTTP-01.
2//!
3//! One certificate at a time, for one domain, on demand. That shape is what the
4//! multi-tenant case needs: domains arrive and leave while the proxy runs, so
5//! the set cannot be known at startup.
6//!
7//! This replaced a `rustls-acme` wrapper that took a fixed domain list and
8//! handed back a `ServerConfig` whose resolver answered challenges inside the
9//! TLS handshake. That model cannot serve a domain it was not started with, and
10//! the wrapper was never constructed anywhere — so rather than keep two ACME
11//! clients in one crate (which also resolved two copies of `rcgen`), it is
12//! gone.
13//!
14//! # The flow
15//!
16//! 1. Load the account from `proxy_acme_accounts`, or register one and store it.
17//! 2. Place an order for the domain.
18//! 3. For each authorization, take the `http-01` challenge and write its token
19//!    and key authorization to `proxy_acme_challenges`, where
20//!    [`crate::saas::handlers::wellknown::handle_acme_challenge`] can find them.
21//! 4. Tell the CA the challenge is ready, and poll until the order is.
22//! 5. Finalize — which builds the CSR and returns a fresh private key — then
23//!    poll for the certificate chain.
24//! 6. Store both in the certificate store and delete the challenge rows.
25//!
26//! # Why HTTP-01 and not DNS-01
27//!
28//! HTTP-01 needs only that the domain resolves to this proxy, which is already
29//! true for any domain it is serving. DNS-01 would need write access to each
30//! tenant's zone, which a proxy has no way to obtain. The trade is that HTTP-01
31//! cannot issue wildcards.
32
33use std::sync::Arc;
34use std::time::Duration;
35
36use instant_acme::{
37    Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount,
38    NewOrder, RetryPolicy,
39};
40use sqlx::{PgPool, Row};
41
42use crate::error::{ProxyError, ProxyResult};
43use crate::tls::cert_store::{Certificate, CertificateStore};
44
45/// Let's Encrypt's production directory.
46pub const LETS_ENCRYPT_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";
47/// Let's Encrypt's staging directory, which issues untrusted certificates
48/// against far looser rate limits.
49pub const LETS_ENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
50
51/// How long to keep polling one order before giving up.
52///
53/// Pebble, and Let's Encrypt under load, can leave an order pending for tens of
54/// seconds. The worker retries with backoff, so a timeout here is a delay
55/// rather than a failure.
56const ORDER_TIMEOUT: Duration = Duration::from_secs(60);
57
58/// The lifetime assumed when a chain's own expiry cannot be read.
59///
60/// Short on purpose. Renewal falls due 30 days before this, so a certificate
61/// with an unreadable expiry is replaced after about a month rather than left
62/// until it stops working.
63const FALLBACK_LIFETIME: chrono::TimeDelta = chrono::TimeDelta::days(60);
64
65/// Issues certificates for individual domains over ACME HTTP-01.
66pub struct AcmeIssuer {
67    directory_url: String,
68    contact: Option<String>,
69    /// A PEM root to trust for the directory itself.
70    ///
71    /// Only for testing against a CA with a private root -- Pebble serves its
72    /// directory over a self-signed chain. `None` uses the platform roots.
73    root_pem_path: Option<std::path::PathBuf>,
74    pool: PgPool,
75    cert_store: Arc<CertificateStore>,
76}
77
78impl AcmeIssuer {
79    /// Create an issuer against a directory URL.
80    pub fn new(
81        directory_url: impl Into<String>,
82        contact: Option<String>,
83        pool: PgPool,
84        cert_store: Arc<CertificateStore>,
85    ) -> Self {
86        Self {
87            directory_url: directory_url.into(),
88            contact,
89            root_pem_path: None,
90            pool,
91            cert_store,
92        }
93    }
94
95    /// Trust a private root for the ACME directory's own TLS.
96    ///
97    /// For testing against Pebble, which serves its directory with a
98    /// self-signed chain. Never needed against a public CA.
99    pub fn with_root_certificate(mut self, pem_path: impl Into<std::path::PathBuf>) -> Self {
100        self.root_pem_path = Some(pem_path.into());
101        self
102    }
103
104    /// The directory this issuer talks to.
105    pub fn directory_url(&self) -> &str {
106        &self.directory_url
107    }
108
109    /// Obtain a certificate for `domain` and store it.
110    ///
111    /// Returns the stored certificate. Any challenge rows this created are
112    /// removed before returning, on the failure path too -- a token left
113    /// answerable after its order is gone is a response the proxy will hand out
114    /// to anyone who asks for it.
115    pub async fn issue(&self, domain: &str) -> ProxyResult<Certificate> {
116        let account = self.account().await?;
117
118        let identifiers = [Identifier::Dns(domain.to_owned())];
119        let mut order = account
120            .new_order(&NewOrder::new(&identifiers))
121            .await
122            .map_err(|e| ProxyError::Acme(format!("could not place an order for {domain}: {e}")))?;
123
124        let result = self.satisfy_and_finalize(&mut order, domain).await;
125
126        // Whatever happened, the tokens must stop being answerable.
127        if let Err(cleanup) = self.clear_challenges(domain).await {
128            tracing::warn!(%domain, error = %cleanup, "could not clear ACME challenge rows");
129        }
130
131        let (cert_pem, key_pem) = result?;
132
133        // An expiry we cannot read must not mean "never renew".
134        //
135        // The renewal scan selects on `expires_at IS NOT NULL AND expires_at <
136        // deadline`, so storing `None` here would leave the certificate to
137        // expire with nothing scheduled to replace it -- silently, months
138        // later. Discarding a certificate the CA has already issued is worse
139        // (the rate limit is spent either way, and the domain would have no
140        // certificate at all), so it is stored with a deliberately short
141        // assumed lifetime instead: renewal then falls due at
142        // `FALLBACK_LIFETIME - RENEW_WITHIN`, comfortably inside the 90 days a
143        // public CA issues for.
144        let expires_at = match crate::tls::expiry_of(cert_pem.as_bytes()) {
145            Some(expiry) => expiry,
146            None => {
147                let assumed = chrono::Utc::now() + FALLBACK_LIFETIME;
148                tracing::error!(
149                    %domain,
150                    assumed_expiry = %assumed,
151                    "could not read the expiry from the issued chain; \
152                     assuming a short lifetime so renewal is still scheduled"
153                );
154                assumed
155            }
156        };
157
158        let certificate = Certificate {
159            domain: domain.to_owned(),
160            expires_at: Some(expires_at),
161            cert_pem: cert_pem.into_bytes(),
162            key_pem: key_pem.into_bytes(),
163        };
164        self.cert_store.save(certificate.clone()).await?;
165
166        tracing::info!(
167            %domain,
168            expires_at = ?certificate.expires_at,
169            "issued an ACME certificate"
170        );
171        Ok(certificate)
172    }
173
174    /// Answer every authorization, finalize, and fetch the chain.
175    ///
176    /// Returns `(certificate_chain_pem, private_key_pem)`.
177    async fn satisfy_and_finalize(
178        &self,
179        order: &mut instant_acme::Order,
180        domain: &str,
181    ) -> ProxyResult<(String, String)> {
182        let mut authorizations = order.authorizations();
183        while let Some(result) = authorizations.next().await {
184            let mut authz = result
185                .map_err(|e| ProxyError::Acme(format!("could not read an authorization: {e}")))?;
186
187            match authz.status {
188                // Already satisfied, from an earlier order for the same domain.
189                AuthorizationStatus::Valid => continue,
190                AuthorizationStatus::Pending => {}
191                other => {
192                    return Err(ProxyError::Acme(format!(
193                        "authorization for {domain} is {other:?}, which cannot be answered"
194                    )))
195                }
196            }
197
198            let mut challenge = authz.challenge(ChallengeType::Http01).ok_or_else(|| {
199                ProxyError::Acme(format!(
200                    "the CA offered no http-01 challenge for {domain}; \
201                     only http-01 is implemented"
202                ))
203            })?;
204
205            // Store the response *before* telling the CA to look for it. The
206            // other order loses the race: validation can arrive immediately.
207            let token = challenge.token.clone();
208            let key_authorization = challenge.key_authorization().as_str().to_owned();
209            self.store_challenge(&token, domain, &key_authorization)
210                .await?;
211
212            challenge.set_ready().await.map_err(|e| {
213                ProxyError::Acme(format!("the CA rejected our challenge for {domain}: {e}"))
214            })?;
215        }
216
217        let retry = RetryPolicy::default().timeout(ORDER_TIMEOUT);
218
219        order.poll_ready(&retry).await.map_err(|e| {
220            ProxyError::Acme(format!(
221                "the order for {domain} did not become ready: {e}. \
222                 The usual cause is that {domain} does not resolve to this proxy, \
223                 so the CA could not fetch the challenge."
224            ))
225        })?;
226
227        let key_pem = order
228            .finalize()
229            .await
230            .map_err(|e| ProxyError::Acme(format!("could not finalize {domain}: {e}")))?;
231
232        let cert_pem = order
233            .poll_certificate(&retry)
234            .await
235            .map_err(|e| ProxyError::Acme(format!("no certificate came back for {domain}: {e}")))?;
236
237        Ok((cert_pem, key_pem))
238    }
239
240    /// Load the stored account, or register one and store it.
241    ///
242    /// Registering is rate-limited -- Let's Encrypt allows 10 per IP per three
243    /// hours -- so this must not happen per issuance. Keyed by directory URL so
244    /// moving between staging and production does not reuse an account the
245    /// other CA has never heard of.
246    async fn account(&self) -> ProxyResult<Account> {
247        if let Some(credentials) = self.load_credentials().await? {
248            return self
249                .builder()?
250                .from_credentials(credentials)
251                .await
252                .map_err(|e| {
253                    ProxyError::Acme(format!("stored ACME credentials are unusable: {e}"))
254                });
255        }
256
257        let contact = self.contact.as_ref().map(|email| format!("mailto:{email}"));
258        let contact_refs: Vec<&str> = contact.iter().map(String::as_str).collect();
259
260        let (account, credentials) = self
261            .builder()?
262            .create(
263                &NewAccount {
264                    contact: &contact_refs,
265                    terms_of_service_agreed: true,
266                    only_return_existing: false,
267                },
268                self.directory_url.clone(),
269                None,
270            )
271            .await
272            .map_err(|e| {
273                ProxyError::Acme(format!(
274                    "could not register an ACME account at {}: {e}",
275                    self.directory_url
276                ))
277            })?;
278
279        self.save_credentials(&credentials).await?;
280        tracing::info!(directory = %self.directory_url, "registered a new ACME account");
281        Ok(account)
282    }
283
284    fn builder(&self) -> ProxyResult<instant_acme::AccountBuilder> {
285        // instant-acme talks to the directory over rustls, and rustls refuses
286        // to guess a provider when both aws-lc-rs and ring are reachable in the
287        // tree -- it panics on first use instead. The TLS listener installs one
288        // for the same reason; an ACME order can happen without any listener
289        // having been built, so it has to be done here as well. Idempotent.
290        crate::tls::server::install_crypto_provider();
291
292        match &self.root_pem_path {
293            Some(path) => Account::builder_with_root(path).map_err(|e| {
294                ProxyError::Acme(format!(
295                    "could not use {} as an ACME root: {e}",
296                    path.display()
297                ))
298            }),
299            None => Account::builder()
300                .map_err(|e| ProxyError::Acme(format!("could not build an ACME client: {e}"))),
301        }
302    }
303
304    async fn load_credentials(&self) -> ProxyResult<Option<AccountCredentials>> {
305        let row =
306            sqlx::query("SELECT credentials FROM proxy_acme_accounts WHERE directory_url = $1")
307                .bind(&self.directory_url)
308                .fetch_optional(&self.pool)
309                .await?;
310
311        let Some(row) = row else { return Ok(None) };
312        let value: serde_json::Value = row.try_get("credentials")?;
313        serde_json::from_value(value)
314            .map(Some)
315            .map_err(|e| ProxyError::Acme(format!("stored ACME credentials do not parse: {e}")))
316    }
317
318    async fn save_credentials(&self, credentials: &AccountCredentials) -> ProxyResult<()> {
319        let value = serde_json::to_value(credentials)
320            .map_err(|e| ProxyError::Acme(format!("could not serialize ACME credentials: {e}")))?;
321
322        // ON CONFLICT DO NOTHING, not DO UPDATE: if two workers registered at
323        // once, the first account stored is the one whose authorizations the CA
324        // already knows about. Overwriting it would discard them.
325        sqlx::query(
326            "INSERT INTO proxy_acme_accounts (directory_url, credentials) \
327             VALUES ($1, $2) ON CONFLICT (directory_url) DO NOTHING",
328        )
329        .bind(&self.directory_url)
330        .bind(value)
331        .execute(&self.pool)
332        .await?;
333        Ok(())
334    }
335
336    async fn store_challenge(
337        &self,
338        token: &str,
339        domain: &str,
340        key_authorization: &str,
341    ) -> ProxyResult<()> {
342        sqlx::query(
343            "INSERT INTO proxy_acme_challenges (token, domain, key_authorization) \
344             VALUES ($1, $2, $3) \
345             ON CONFLICT (token) DO UPDATE SET \
346                 domain = EXCLUDED.domain, \
347                 key_authorization = EXCLUDED.key_authorization, \
348                 expires_at = NOW() + INTERVAL '1 hour'",
349        )
350        .bind(token)
351        .bind(domain)
352        .bind(key_authorization)
353        .execute(&self.pool)
354        .await?;
355        Ok(())
356    }
357
358    async fn clear_challenges(&self, domain: &str) -> ProxyResult<()> {
359        sqlx::query("DELETE FROM proxy_acme_challenges WHERE domain = $1 OR expires_at < NOW()")
360            .bind(domain)
361            .execute(&self.pool)
362            .await?;
363        Ok(())
364    }
365}
366
367/// A live HTTP-01 challenge response.
368pub struct AcmeChallengeResponse {
369    /// The domain the challenge was issued for.
370    pub domain: String,
371    /// The body to return, verbatim.
372    pub key_authorization: String,
373}
374
375/// Look up a pending HTTP-01 challenge response by token.
376///
377/// Free function rather than a method because the endpoint that serves
378/// challenges has a pool but no reason to hold an issuer.
379pub async fn find_challenge(
380    pool: &PgPool,
381    token: &str,
382) -> ProxyResult<Option<AcmeChallengeResponse>> {
383    let row = sqlx::query(
384        "SELECT domain, key_authorization FROM proxy_acme_challenges \
385         WHERE token = $1 AND expires_at > NOW()",
386    )
387    .bind(token)
388    .fetch_optional(pool)
389    .await?;
390
391    Ok(row.map(|row| AcmeChallengeResponse {
392        domain: row.get("domain"),
393        key_authorization: row.get("key_authorization"),
394    }))
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn known_directories_are_https_urls() {
403        for url in [LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING] {
404            assert!(url.starts_with("https://"), "{url}");
405            assert!(url.ends_with("/directory"), "{url}");
406        }
407        assert_ne!(LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING);
408    }
409}