Skip to main content

certmagic/solvers/
mod.rs

1//! Challenge solvers.
2//!
3//! Mirrors the acmez `Solver` contract certmagic wraps:
4//! - [`Solver::present`] publishes the challenge answer (idempotent,
5//!   reference-counted for shared listeners).
6//! - [`Solver::wait`] optionally blocks until propagation (only DNS-01
7//!   implements it; the ACME client polls the order otherwise).
8//! - [`Solver::cleanup`] **always runs**, ignoring cancellation (the
9//!   "Cleanup must always occur").
10
11pub mod distributed;
12pub mod dns;
13pub mod http;
14pub mod tls_alpn;
15
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use tokio_util::sync::CancellationToken;
20
21use crate::error::Result;
22
23/// A challenge being solved: the wire challenge plus the derived key
24/// authorization.
25#[derive(Debug, Clone)]
26pub struct SolvableChallenge {
27    /// Challenge type: `http-01` / `dns-01` / `tls-alpn-01`.
28    pub kind: String,
29    /// Challenge token from the CA.
30    pub token: String,
31    /// The challenge validation URL.
32    pub url: String,
33    /// The identifier being validated (dns name or IP string).
34    pub identifier: String,
35    /// The derived key authorization `token.thumbprint`.
36    pub key_authorization: String,
37}
38
39impl SolvableChallenge {
40    /// Compute the derived fields from the wire challenge.
41    ///
42    /// # Errors
43    /// Propagates thumbprint computation.
44    pub fn new(
45        kind: &str,
46        token: &str,
47        url: &str,
48        identifier: &str,
49        account: &crate::acme::protocol::AccountKey,
50    ) -> Result<Self> {
51        Ok(Self {
52            kind: kind.to_owned(),
53            token: token.to_owned(),
54            url: url.to_owned(),
55            identifier: identifier.to_owned(),
56            key_authorization: crate::acme::key_authorization(token, &account.thumbprint_b64()?)?,
57        })
58    }
59}
60
61/// Publishes and removes challenge answers.
62#[async_trait]
63pub trait Solver: Send + Sync + std::fmt::Debug {
64    /// Publish the challenge answer.
65    async fn present(&self, ct: &CancellationToken, chal: &SolvableChallenge) -> Result<()>;
66
67    /// Wait for propagation; default: nothing to wait for (the ACME client
68    /// polls the order).
69    async fn wait(&self, _ct: &CancellationToken, _chal: &SolvableChallenge) -> Result<()> {
70        Ok(())
71    }
72
73    /// Remove the challenge answer. Implementations must complete even if the
74    /// surrounding operation is cancelled — do not consult cancellation here.
75    async fn cleanup(&self, chal: &SolvableChallenge);
76}
77
78/// Process-wide registry of active challenges:
79/// lets the handshake path answer TLS-ALPN-01 probes and lets the HTTP-01
80/// handler look up key authorizations.
81///
82/// The active-challenges registry.
83#[derive(Debug, Default)]
84pub struct ActiveChallenges {
85    map: std::sync::Mutex<std::collections::HashMap<String, SolvableChallenge>>,
86}
87
88impl ActiveChallenges {
89    fn global() -> &'static ActiveChallenges {
90        static REG: std::sync::OnceLock<ActiveChallenges> = std::sync::OnceLock::new();
91        REG.get_or_init(ActiveChallenges::default)
92    }
93
94    /// Register a challenge under `key`.
95    pub(crate) fn insert(key: &str, chal: &SolvableChallenge) {
96        if let Ok(mut map) = ActiveChallenges::global().map.lock() {
97            map.insert(key.to_owned(), chal.clone());
98        }
99    }
100
101    /// Remove a registered challenge.
102    pub(crate) fn remove(key: &str) {
103        if let Ok(mut map) = ActiveChallenges::global().map.lock() {
104            map.remove(key);
105        }
106    }
107
108    /// Look up a challenge by identifier for challenge `kind`
109    ///`; identifier comparison is
110    /// case-insensitive DNS-rebinding guard).
111    #[must_use]
112    pub fn get(kind: &str, identifier: &str) -> Option<SolvableChallenge> {
113        let needle = identifier.to_lowercase();
114        let map = ActiveChallenges::global().map.lock().ok()?;
115        map.values()
116            .find(|c| c.kind == kind && c.identifier.to_lowercase() == needle)
117            .cloned()
118    }
119}
120
121/// Registry key for a challenge (URL-derived, stable per challenge).
122pub(crate) fn challenge_key(chal: &SolvableChallenge) -> String {
123    crate::crypto::sha256_hex(chal.url.as_bytes())
124}
125
126/// Wraps a solver to maintain the process-wide active-challenges registry
127///.
128#[derive(Debug)]
129pub struct SolverWrapper<S: Solver> {
130    inner: S,
131}
132
133impl<S: Solver> SolverWrapper<S> {
134    /// Wrap `inner`.
135    #[must_use]
136    pub fn new(inner: S) -> Self {
137        Self { inner }
138    }
139}
140
141#[async_trait]
142impl<S: Solver> Solver for SolverWrapper<S> {
143    async fn present(&self, ct: &CancellationToken, chal: &SolvableChallenge) -> Result<()> {
144        self.inner.present(ct, chal).await?;
145        ActiveChallenges::insert(&challenge_key(chal), chal);
146        Ok(())
147    }
148
149    async fn wait(&self, ct: &CancellationToken, chal: &SolvableChallenge) -> Result<()> {
150        self.inner.wait(ct, chal).await
151    }
152
153    async fn cleanup(&self, chal: &SolvableChallenge) {
154        ActiveChallenges::remove(&challenge_key(chal));
155        self.inner.cleanup(chal).await;
156    }
157}
158
159/// Look up an in-process challenge by kind and identifier
160///.
161#[must_use]
162pub fn get_acme_challenge(kind: &str, identifier: &str) -> Option<SolvableChallenge> {
163    ActiveChallenges::get(kind, identifier)
164}
165
166/// Convenience: an `Arc` of a wrapped solver.
167#[must_use]
168pub fn wrapped<S: Solver>(inner: S) -> Arc<SolverWrapper<S>> {
169    Arc::new(SolverWrapper::new(inner))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[derive(Debug, Default)]
177    struct RecordingSolver {
178        present_calls: std::sync::atomic::AtomicUsize,
179        cleanup_calls: std::sync::atomic::AtomicUsize,
180    }
181
182    #[async_trait]
183    impl Solver for RecordingSolver {
184        async fn present(&self, _ct: &CancellationToken, _chal: &SolvableChallenge) -> Result<()> {
185            self.present_calls
186                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
187            Ok(())
188        }
189
190        async fn cleanup(&self, _chal: &SolvableChallenge) {
191            self.cleanup_calls
192                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
193        }
194    }
195
196    fn sample_challenge() -> SolvableChallenge {
197        SolvableChallenge {
198            kind: "dns-01".into(),
199            token: "tok".into(),
200            url: "https://ca/chal/xyz".into(),
201            identifier: "example.com".into(),
202            key_authorization: "tok.thumb".into(),
203        }
204    }
205
206    #[tokio::test]
207    async fn wrapper_registers_and_unregisters() {
208        let solver = wrapped(RecordingSolver::default());
209        let chal = sample_challenge();
210
211        assert!(get_acme_challenge("dns-01", "example.com").is_none());
212        solver
213            .present(&CancellationToken::new(), &chal)
214            .await
215            .unwrap();
216        assert!(get_acme_challenge("dns-01", "example.com").is_some());
217        solver.cleanup(&chal).await;
218        assert!(get_acme_challenge("dns-01", "example.com").is_none());
219    }
220
221    #[tokio::test]
222    async fn wait_defaults_to_ok() {
223        let solver = wrapped(RecordingSolver::default());
224        solver
225            .wait(&CancellationToken::new(), &sample_challenge())
226            .await
227            .unwrap();
228    }
229}