acme_lite/order/
auth.rs

1use std::{sync::Arc, time::Duration};
2
3use base64::prelude::*;
4use sha2::{Digest as _, Sha256};
5
6use crate::{
7    acc::{AccountInner, AcmeKey},
8    api::{ApiAuth, ApiChallenge, ApiEmptyObject, ApiEmptyString},
9    jws::{Jwk, JwkThumb},
10};
11
12/// An authorization ([ownership proof]) for a domain name.
13///
14/// Each authorization for an order much be progressed to a valid state before the ACME API
15/// will issue a certificate.
16///
17/// Authorizations may or may not be required depending on previous orders against the same
18/// ACME account. The ACME API decides if the authorization is needed.
19///
20/// Currently there are two ways of providing the authorization.
21///
22/// * In a text file served using [HTTP] from a web server of the domain being authorized.
23/// * A `TXT` [DNS] record under the domain being authorized.
24///
25/// [ownership proof]: ../index.html#domain-ownership
26/// [HTTP]: #method.http_challenge
27/// [DNS]: #method.dns_challenge
28#[derive(Debug)]
29pub struct Auth {
30    inner: Arc<AccountInner>,
31    api_auth: ApiAuth,
32    auth_url: String,
33}
34
35impl Auth {
36    pub(crate) fn new(inner: &Arc<AccountInner>, api_auth: ApiAuth, auth_url: &str) -> Self {
37        Auth {
38            inner: inner.clone(),
39            api_auth,
40            auth_url: auth_url.to_owned(),
41        }
42    }
43
44    /// Domain name for this authorization.
45    pub fn domain_name(&self) -> &str {
46        &self.api_auth.identifier.value
47    }
48
49    /// Whether we actually need to do the authorization. This might not be needed if we have
50    /// proven ownership of the domain recently in a previous order.
51    pub fn need_challenge(&self) -> bool {
52        !self.api_auth.is_status_valid()
53    }
54
55    /// Get the http challenge.
56    ///
57    /// The http challenge must be placed so it is accessible under:
58    ///
59    /// ```text
60    /// http://<domain-to-be-proven>/.well-known/acme-challenge/<token>
61    /// ```
62    ///
63    /// The challenge will be accessed over HTTP (not HTTPS), for obvious reasons.
64    ///
65    /// ```no_run
66    /// use std::fs::File;
67    /// use std::io::Write;
68    /// use std::time::Duration;
69    ///
70    /// use acme_lite::order::Auth;
71    ///
72    /// async fn web_authorize(auth: &Auth) -> eyre::Result<()> {
73    ///   let challenge = auth.http_challenge().unwrap();
74    ///
75    ///   // Assuming our web server's root is under /var/www
76    ///   let path = {
77    ///     let token = challenge.http_token();
78    ///     format!("/var/www/.well-known/acme-challenge/{}", token)
79    ///   };
80    ///
81    ///   let mut file = File::create(&path)?;
82    ///   file.write_all(challenge.http_proof()?.as_bytes())?;
83    ///   challenge.validate(Duration::from_millis(5000)).await?;
84    ///
85    ///   Ok(())
86    /// }
87    /// ```
88    pub fn http_challenge(&self) -> Option<Challenge<Http>> {
89        self.api_auth
90            .http_challenge()
91            .map(|c| Challenge::new(&self.inner, c.clone(), &self.auth_url))
92    }
93
94    /// Get the dns challenge.
95    ///
96    /// The dns challenge is a `TXT` record that must put created under:
97    ///
98    /// ```text
99    /// _acme-challenge.<domain-to-be-proven>.  TXT  <proof>
100    /// ```
101    ///
102    /// The <proof> contains the signed token proving this account update it.
103    ///
104    /// ```no_run
105    /// use std::time::Duration;
106    ///
107    /// use acme_lite::order::Auth;
108    ///
109    /// async fn dns_authorize(auth: &Auth) -> eyre::Result<()> {
110    ///   let challenge = auth.dns_challenge().unwrap();
111    ///   let record = format!("_acme-challenge.{}.", auth.domain_name());
112    ///   // route_53_set_record(&record, "TXT", challenge.dns_proof());
113    ///   challenge.validate(Duration::from_millis(5000)).await?;
114    ///   Ok(())
115    /// }
116    /// ```
117    ///
118    /// The dns proof is not the same as the http proof.
119    pub fn dns_challenge(&self) -> Option<Challenge<Dns>> {
120        self.api_auth
121            .dns_challenge()
122            .map(|c| Challenge::new(&self.inner, c.clone(), &self.auth_url))
123    }
124
125    /// Returns the TLS ALPN challenge.
126    ///
127    /// The TLS ALPN challenge is a certificate that must be served when a request is made for the
128    /// ALPN protocol "tls-alpn-01". The certificate must contain a single DNSName SAN containing
129    /// the domain being validated, as well as an ACME extension containing the SHA256 of the key
130    /// authorization.
131    pub fn tls_alpn_challenge(&self) -> Option<Challenge<TlsAlpn>> {
132        self.api_auth
133            .tls_alpn_challenge()
134            .map(|c| Challenge::new(&self.inner, c.clone(), &self.auth_url))
135    }
136
137    /// Access the underlying JSON object for debugging. We don't
138    /// refresh the authorization when the corresponding challenge is validated,
139    /// so there will be no changes to see here.
140    pub fn api_auth(&self) -> &ApiAuth {
141        &self.api_auth
142    }
143}
144
145/// Marker type for http challenges.
146#[doc(hidden)]
147pub struct Http;
148
149/// Marker type for dns challenges.
150#[doc(hidden)]
151pub struct Dns;
152
153/// Marker type for tls alpn challenges.
154#[doc(hidden)]
155pub struct TlsAlpn;
156
157/// A DNS, HTTP, or TLS-ALPN challenge as obtained from the [`Auth`].
158///
159/// [`Auth`]: struct.Auth.html
160pub struct Challenge<A> {
161    inner: Arc<AccountInner>,
162    api_challenge: ApiChallenge,
163    auth_url: String,
164    _ph: std::marker::PhantomData<A>,
165}
166
167impl Challenge<Http> {
168    /// The `token` is a unique identifier of the challenge. It is the file name in the
169    /// http challenge like so:
170    ///
171    /// ```text
172    /// http://<domain-to-be-proven>/.well-known/acme-challenge/<token>
173    /// ```
174    pub fn http_token(&self) -> &str {
175        &self.api_challenge.token
176    }
177
178    /// The `proof` is some text content that is placed in the file named by `token`.
179    pub fn http_proof(&self) -> eyre::Result<String> {
180        let acme_key = self.inner.transport.acme_key();
181        let proof = key_authorization(&self.api_challenge.token, acme_key, false)?;
182        Ok(proof)
183    }
184}
185
186impl Challenge<Dns> {
187    /// The `proof` is the `TXT` record placed under:
188    ///
189    /// ```text
190    /// _acme-challenge.<domain-to-be-proven>.  TXT  <proof>
191    /// ```
192    pub fn dns_proof(&self) -> eyre::Result<String> {
193        let acme_key = self.inner.transport.acme_key();
194        let proof = key_authorization(&self.api_challenge.token, acme_key, true)?;
195        Ok(proof)
196    }
197}
198
199impl Challenge<TlsAlpn> {
200    /// The `proof` is the contents of the ACME extension to be placed in the
201    /// certificate used for validation.
202    pub fn tls_alpn_proof(&self) -> eyre::Result<[u8; 32]> {
203        let acme_key = self.inner.transport.acme_key();
204        let proof = key_authorization(&self.api_challenge.token, acme_key, false)?;
205        Ok(Sha256::digest(proof).try_into().unwrap())
206    }
207}
208
209impl<A> Challenge<A> {
210    fn new(inner: &Arc<AccountInner>, api_challenge: ApiChallenge, auth_url: &str) -> Self {
211        Challenge {
212            inner: inner.clone(),
213            api_challenge,
214            auth_url: auth_url.to_owned(),
215            _ph: std::marker::PhantomData,
216        }
217    }
218
219    /// Check whether this challenge really need validation. It might already been
220    /// done in a previous order for the same account.
221    pub fn need_validate(&self) -> bool {
222        self.api_challenge.is_status_pending()
223    }
224
225    /// Tell the ACME API to attempt validating the proof of this challenge.
226    ///
227    /// The user must first update the DNS record or HTTP web server depending
228    /// on the type challenge being validated.
229    pub async fn validate(&self, delay: Duration) -> eyre::Result<()> {
230        let res = self
231            .inner
232            .transport
233            .call(&self.api_challenge.url, &ApiEmptyObject)
234            .await?;
235
236        let _api_challenge = res.json::<ApiChallenge>().await?;
237
238        let auth = wait_for_auth_status(&self.inner, &self.auth_url, delay).await?;
239
240        if !auth.is_status_valid() {
241            let error = auth
242                .challenges
243                .iter()
244                .filter_map(|c| c.error.as_ref())
245                .next();
246
247            let reason = match error {
248                Some(error) => error.detail.clone().unwrap_or_else(|| error._type.clone()),
249                None => "Validation failed and no error found".to_owned(),
250            };
251
252            return Err(eyre::eyre!("Validation failed: {reason}"));
253        }
254
255        Ok(())
256    }
257
258    /// Access the underlying JSON object for debugging.
259    pub fn api_challenge(&self) -> &ApiChallenge {
260        &self.api_challenge
261    }
262}
263
264fn key_authorization(token: &str, key: &AcmeKey, extra_sha256: bool) -> eyre::Result<String> {
265    let jwk = Jwk::try_from(key)?;
266    let jwk_thumb = JwkThumb::from(&jwk);
267    let jwk_json = serde_json::to_string(&jwk_thumb)?;
268
269    let digest = BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(jwk_json));
270    let key_auth = format!("{token}.{digest}");
271
272    let res = if extra_sha256 {
273        BASE64_URL_SAFE_NO_PAD.encode(Sha256::digest(key_auth))
274    } else {
275        key_auth
276    };
277
278    Ok(res)
279}
280
281async fn wait_for_auth_status(
282    inner: &Arc<AccountInner>,
283    auth_url: &str,
284    delay: Duration,
285) -> eyre::Result<ApiAuth> {
286    let auth = loop {
287        let res = inner.transport.call(auth_url, &ApiEmptyString).await?;
288        let auth = res.json::<ApiAuth>().await?;
289
290        if !auth.is_status_pending() {
291            break auth;
292        }
293
294        tokio::time::sleep(delay).await;
295    };
296
297    Ok(auth)
298}
299
300#[cfg(test)]
301mod tests {
302    use crate::*;
303
304    #[tokio::test]
305    async fn test_get_challenges() {
306        let server = crate::test::with_directory_server();
307        let url = DirectoryUrl::Other(&server.dir_url);
308        let dir = Directory::from_url(url).await.unwrap();
309        let acc = dir
310            .register_account(Some(vec!["mailto:foo@bar.com".to_owned()]))
311            .await
312            .unwrap();
313        let ord = acc.new_order("acmetest.example.com", &[]).await.unwrap();
314        let authz = ord.authorizations().await.unwrap();
315        assert!(authz.len() == 1);
316        let auth = &authz[0];
317
318        let http = auth.http_challenge().unwrap();
319        assert!(http.need_validate());
320
321        let dns = auth.dns_challenge().unwrap();
322        assert!(dns.need_validate());
323    }
324}