small_acme/
lib.rs

1//! Sync pure-Rust ACME (RFC 8555) client.
2
3#![warn(unreachable_pub)]
4#![warn(missing_docs)]
5
6pub use ureq;
7
8use std::fmt;
9use std::sync::Arc;
10
11use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
12use ring::digest::{digest, SHA256};
13use ring::rand::SystemRandom;
14use ring::signature::{EcdsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING};
15use ring::{hmac, pkcs8};
16use serde::de::DeserializeOwned;
17use serde::Serialize;
18
19mod types;
20pub use types::{
21    AccountCredentials, Authorization, AuthorizationStatus, Challenge, ChallengeType, Error,
22    Identifier, LetsEncrypt, NewAccount, NewOrder, OrderState, OrderStatus, Problem,
23    RevocationReason, RevocationRequest, ZeroSsl,
24};
25use types::{
26    DirectoryUrls, Empty, FinalizeRequest, Header, JoseJson, Jwk, KeyOrKeyId, NewAccountPayload,
27    Signer, SigningAlgorithm,
28};
29use ureq::Response;
30
31/// An ACME order as described in RFC 8555 (section 7.1.3)
32///
33/// An order is created from an [`Account`] by calling [`Account::new_order()`]. The `Order`
34/// type represents the stable identity of an order, while the [`Order::state()`] method
35/// gives you access to the current state of the order according to the server.
36///
37/// <https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.3>
38pub struct Order {
39    account: Arc<AccountInner>,
40    nonce: Option<String>,
41    url: String,
42    state: OrderState,
43}
44
45impl Order {
46    /// Retrieve the authorizations for this order
47    ///
48    /// An order will contain one authorization to complete per identifier in the order.
49    /// After creating an order, you'll need to retrieve the authorizations so that
50    /// you can set up a challenge response for each authorization.
51    ///
52    /// For each authorization, you'll need to:
53    ///
54    /// * Select which [`ChallengeType`] you want to complete
55    /// * Create a [`KeyAuthorization`] for that [`Challenge`]
56    /// * Call [`Order::set_challenge_ready()`] for that challenge
57    ///
58    /// After the challenges have been set up, check the [`Order::state()`] to see
59    /// if the order is ready to be finalized (or becomes invalid). Once it is
60    /// ready, call `Order::finalize()` to get the certificate.
61    pub fn authorizations(&mut self) -> Result<Vec<Authorization>, Error> {
62        let mut authorizations = Vec::with_capacity(self.state.authorizations.len());
63        for url in &self.state.authorizations {
64            authorizations.push(self.account.get(&mut self.nonce, url)?);
65        }
66        Ok(authorizations)
67    }
68
69    /// Create a [`KeyAuthorization`] for the given [`Challenge`]
70    ///
71    /// Signs the challenge's token with the account's private key and use the
72    /// value from [`KeyAuthorization::as_str()`] as the challenge response.
73    pub fn key_authorization(&self, challenge: &Challenge) -> KeyAuthorization {
74        KeyAuthorization::new(challenge, &self.account.key)
75    }
76
77    /// Request a certificate from the given Certificate Signing Request (CSR)
78    ///
79    /// Creating a CSR is outside of the scope of instant-acme. Make sure you pass in a
80    /// DER representation of the CSR in `csr_der`. Call `certificate()` to retrieve the
81    /// certificate chain once the order is in the appropriate state.
82    pub fn finalize(&mut self, csr_der: &[u8]) -> Result<(), Error> {
83        let rsp = self.account.post(
84            Some(&FinalizeRequest::new(csr_der)),
85            self.nonce.take(),
86            &self.state.finalize,
87        )?;
88
89        self.nonce = nonce_from_response(&rsp);
90        self.state = Problem::check::<OrderState>(rsp)?;
91        Ok(())
92    }
93
94    /// Get the certificate for this order
95    ///
96    /// If the cached order state is in `ready` or `processing` state, this will poll the server
97    /// for the latest state. If the order is still in `processing` state after that, this will
98    /// return `Ok(None)`. If the order is in `valid` state, this will attempt to retrieve
99    /// the certificate from the server and return it as a `String`. If the order contains
100    /// an error or ends up in any state other than `valid` or `processing`, return an error.
101    pub fn certificate(&mut self) -> Result<Option<String>, Error> {
102        if matches!(self.state.status, OrderStatus::Processing) {
103            let rsp = self
104                .account
105                .post(None::<&Empty>, self.nonce.take(), &self.url)?;
106            self.nonce = nonce_from_response(&rsp);
107            self.state = Problem::check::<OrderState>(rsp)?;
108        }
109
110        if let Some(error) = &self.state.error {
111            return Err(Error::Api(error.clone()));
112        } else if self.state.status == OrderStatus::Processing {
113            return Ok(None);
114        } else if self.state.status != OrderStatus::Valid {
115            return Err(Error::Str("invalid order state"));
116        }
117
118        let cert_url = match &self.state.certificate {
119            Some(cert_url) => cert_url,
120            None => return Err(Error::Str("no certificate URL found")),
121        };
122
123        let rsp = self
124            .account
125            .post(None::<&Empty>, self.nonce.take(), cert_url)?;
126
127        self.nonce = nonce_from_response(&rsp);
128        let body = Problem::from_response(rsp)?;
129        Ok(Some(
130            String::from_utf8(body.to_vec())
131                .map_err(|_| "unable to decode certificate as UTF-8")?,
132        ))
133    }
134
135    /// Notify the server that the given challenge is ready to be completed
136    ///
137    /// `challenge_url` should be the `Challenge::url` field.
138    pub fn set_challenge_ready(&mut self, challenge_url: &str) -> Result<(), Error> {
139        let rsp = self
140            .account
141            .post(Some(&Empty {}), self.nonce.take(), challenge_url)?;
142
143        self.nonce = nonce_from_response(&rsp);
144        let _ = Problem::check::<Challenge>(rsp)?;
145        Ok(())
146    }
147
148    /// Get the current state of the given challenge
149    pub fn challenge(&mut self, challenge_url: &str) -> Result<Challenge, Error> {
150        self.account.get(&mut self.nonce, challenge_url)
151    }
152
153    /// Refresh the current state of the order
154    pub fn refresh(&mut self) -> Result<&OrderState, Error> {
155        let rsp = self
156            .account
157            .post(None::<&Empty>, self.nonce.take(), &self.url)?;
158
159        self.nonce = nonce_from_response(&rsp);
160        self.state = Problem::check::<OrderState>(rsp)?;
161        Ok(&self.state)
162    }
163
164    /// Get the last known state of the order
165    ///
166    /// Call `refresh()` to get the latest state from the server.
167    pub fn state(&mut self) -> &OrderState {
168        &self.state
169    }
170
171    /// Get the URL of the order
172    pub fn url(&self) -> &str {
173        &self.url
174    }
175}
176
177/// An ACME account as described in RFC 8555 (section 7.1.2)
178///
179/// Create an [`Account`] with [`Account::create()`] or restore it from serialized data
180/// by passing deserialized [`AccountCredentials`] to [`Account::from_credentials()`].
181///
182/// The [`Account`] type is cheap to clone.
183///
184/// <https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.2>
185#[derive(Clone)]
186pub struct Account {
187    inner: Arc<AccountInner>,
188}
189
190impl Account {
191    /// Restore an existing account from the given credentials and HTTP client
192    ///
193    /// The [`AccountCredentials`] type is opaque, but supports deserialization.
194    pub fn from_credentials(credentials: AccountCredentials) -> Result<Self, Error> {
195        Ok(Self {
196            inner: Arc::new(AccountInner::from_credentials(credentials)?),
197        })
198    }
199
200    /// Restore an existing account from the given ID, private key, server URL and HTTP client
201    ///
202    /// The key must be provided in DER-encoded PKCS#8. This is usually how ECDSA keys are
203    /// encoded in PEM files. Use a crate like rustls-pemfile to decode from PEM to DER.
204    pub fn from_parts(
205        id: String,
206        key_pkcs8_der: &[u8],
207        directory_url: &str,
208    ) -> Result<Self, Error> {
209        Ok(Self {
210            inner: Arc::new(AccountInner {
211                id,
212                key: Key::from_pkcs8_der(key_pkcs8_der)?,
213                client: Client::new(directory_url)?,
214            }),
215        })
216    }
217
218    /// Create a new account with a custom HTTP client
219    ///
220    /// The returned [`AccountCredentials`] can be serialized and stored for later use.
221    /// Use [`Account::from_credentials()`] to restore the account from the credentials.
222    pub fn create(
223        account: &NewAccount<'_>,
224        server_url: &str,
225        external_account: Option<&ExternalAccountKey>,
226    ) -> Result<(Account, AccountCredentials), Error> {
227        Self::create_inner(
228            account,
229            external_account,
230            Client::new(server_url)?,
231            server_url,
232        )
233    }
234
235    fn create_inner(
236        account: &NewAccount<'_>,
237        external_account: Option<&ExternalAccountKey>,
238        client: Client,
239        server_url: &str,
240    ) -> Result<(Account, AccountCredentials), Error> {
241        let (key, key_pkcs8) = Key::generate()?;
242        let payload = NewAccountPayload {
243            new_account: account,
244            external_account_binding: external_account
245                .map(|eak| {
246                    JoseJson::new(
247                        Some(&Jwk::new(&key.inner)),
248                        eak.header(None, &client.urls.new_account),
249                        eak,
250                    )
251                })
252                .transpose()?,
253        };
254
255        let rsp = client.post(Some(&payload), None, &key, &client.urls.new_account)?;
256
257        let account_url = rsp.header("LOCATION").map(|s| s.to_owned());
258
259        // The response redirects, we don't need the body
260        let _ = Problem::from_response(rsp)?;
261        let id = account_url.ok_or("failed to get account URL")?;
262        let credentials = AccountCredentials {
263            id: id.clone(),
264            key_pkcs8: key_pkcs8.as_ref().to_vec(),
265            directory: Some(server_url.to_owned()),
266            // We support deserializing URLs for compatibility with versions pre 0.4,
267            // but we prefer to get fresh URLs from the `server_url` for newer credentials.
268            urls: None,
269        };
270
271        let account = AccountInner {
272            client,
273            key,
274            id: id.clone(),
275        };
276
277        Ok((
278            Self {
279                inner: Arc::new(account),
280            },
281            credentials,
282        ))
283    }
284
285    /// Create a new order based on the given [`NewOrder`]
286    ///
287    /// Returns an [`Order`] instance. Use the [`Order::state()`] method to inspect its state.
288    pub fn new_order(&self, order: &NewOrder<'_>) -> Result<Order, Error> {
289        let rsp = self
290            .inner
291            .post(Some(order), None, &self.inner.client.urls.new_order)?;
292
293        let nonce = nonce_from_response(&rsp);
294        let order_url = rsp.header("LOCATION").map(|s| s.to_owned());
295
296        Ok(Order {
297            account: self.inner.clone(),
298            nonce,
299            // Order of fields matters! We return errors from Problem::check
300            // before emitting an error if there is no order url. Or the
301            // simple no url error hides the causing error in `Problem::check`.
302            state: Problem::check::<OrderState>(rsp)?,
303            url: order_url.ok_or("no order URL found")?,
304        })
305    }
306
307    /// Revokes a previously issued certificate
308    pub fn revoke<'a>(&'a self, payload: &RevocationRequest<'a>) -> Result<(), Error> {
309        let rsp = self
310            .inner
311            .post(Some(payload), None, &self.inner.client.urls.revoke_cert)?;
312        // The body is empty if the request was successful
313        let _ = Problem::from_response(rsp)?;
314        Ok(())
315    }
316}
317
318struct AccountInner {
319    client: Client,
320    key: Key,
321    id: String,
322}
323
324impl AccountInner {
325    fn from_credentials(credentials: AccountCredentials) -> Result<Self, Error> {
326        Ok(Self {
327            id: credentials.id,
328            key: Key::from_pkcs8_der(credentials.key_pkcs8.as_ref())?,
329            client: match (credentials.directory, credentials.urls) {
330                (Some(server_url), _) => Client::new(&server_url)?,
331                (None, Some(urls)) => Client {
332                    client: client(),
333                    urls,
334                },
335                (None, None) => return Err("no server URLs found".into()),
336            },
337        })
338    }
339
340    fn get<T: DeserializeOwned>(&self, nonce: &mut Option<String>, url: &str) -> Result<T, Error> {
341        let rsp = self.post(None::<&Empty>, nonce.take(), url)?;
342        *nonce = nonce_from_response(&rsp);
343        Problem::check(rsp)
344    }
345
346    fn post(
347        &self,
348        payload: Option<&impl Serialize>,
349        nonce: Option<String>,
350        url: &str,
351    ) -> Result<Response, Error> {
352        self.client.post(payload, nonce, self, url)
353    }
354}
355
356impl Signer for AccountInner {
357    type Signature = <Key as Signer>::Signature;
358
359    fn header<'n, 'u: 'n, 's: 'u>(&'s self, nonce: Option<&'n str>, url: &'u str) -> Header<'n> {
360        debug_assert!(nonce.is_some());
361        Header {
362            alg: self.key.signing_algorithm,
363            key: KeyOrKeyId::KeyId(&self.id),
364            nonce,
365            url,
366        }
367    }
368
369    fn sign(&self, payload: &[u8]) -> Result<Self::Signature, Error> {
370        self.key.sign(payload)
371    }
372}
373
374struct Client {
375    client: ureq::Agent,
376    urls: DirectoryUrls,
377}
378
379impl Client {
380    fn new(server_url: &str) -> Result<Self, Error> {
381        let client = client();
382        let rsp = client.get(server_url).call()?;
383        let urls = rsp.into_json()?;
384        Ok(Client { client, urls })
385    }
386
387    fn post(
388        &self,
389        payload: Option<&impl Serialize>,
390        nonce: Option<String>,
391        signer: &impl Signer,
392        url: &str,
393    ) -> Result<Response, Error> {
394        let nonce = self.nonce(nonce)?;
395        let body = JoseJson::new(payload, signer.header(Some(&nonce), url), signer)?;
396        let rsp = self
397            .client
398            .request("POST", url)
399            .set("CONTENT-TYPE", JOSE_JSON)
400            .send_json(body)?;
401        Ok(rsp)
402    }
403
404    fn nonce(&self, nonce: Option<String>) -> Result<String, Error> {
405        if let Some(nonce) = nonce {
406            return Ok(nonce);
407        }
408
409        let rsp = self.client.request("HEAD", &self.urls.new_nonce).call()?;
410        // https://datatracker.ietf.org/doc/html/rfc8555#section-7.2
411        // "The server's response MUST include a Replay-Nonce header field containing a fresh
412        // nonce and SHOULD have status code 200 (OK)."
413        if rsp.status() != 200 {
414            return Err("error response from newNonce resource".into());
415        }
416
417        match nonce_from_response(&rsp) {
418            Some(nonce) => Ok(nonce),
419            None => Err("no nonce found in newNonce response".into()),
420        }
421    }
422}
423
424impl fmt::Debug for Client {
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        f.debug_struct("Client")
427            .field("client", &"..")
428            .field("urls", &self.urls)
429            .finish()
430    }
431}
432
433struct Key {
434    rng: SystemRandom,
435    signing_algorithm: SigningAlgorithm,
436    inner: EcdsaKeyPair,
437    thumb: String,
438}
439
440impl Key {
441    fn generate() -> Result<(Self, pkcs8::Document), Error> {
442        let rng = SystemRandom::new();
443        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)?;
444        let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)?;
445        let thumb = BASE64_URL_SAFE_NO_PAD.encode(Jwk::thumb_sha256(&key)?);
446
447        Ok((
448            Self {
449                rng,
450                signing_algorithm: SigningAlgorithm::Es256,
451                inner: key,
452                thumb,
453            },
454            pkcs8,
455        ))
456    }
457
458    fn from_pkcs8_der(pkcs8_der: &[u8]) -> Result<Self, Error> {
459        let rng = SystemRandom::new();
460        let key = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8_der, &rng)?;
461        let thumb = BASE64_URL_SAFE_NO_PAD.encode(Jwk::thumb_sha256(&key)?);
462
463        Ok(Self {
464            rng,
465            signing_algorithm: SigningAlgorithm::Es256,
466            inner: key,
467            thumb,
468        })
469    }
470}
471
472impl Signer for Key {
473    type Signature = ring::signature::Signature;
474
475    fn header<'n, 'u: 'n, 's: 'u>(&'s self, nonce: Option<&'n str>, url: &'u str) -> Header<'n> {
476        debug_assert!(nonce.is_some());
477        Header {
478            alg: self.signing_algorithm,
479            key: KeyOrKeyId::from_key(&self.inner),
480            nonce,
481            url,
482        }
483    }
484
485    fn sign(&self, payload: &[u8]) -> Result<Self::Signature, Error> {
486        Ok(self.inner.sign(&self.rng, payload)?)
487    }
488}
489
490/// The response value to use for challenge responses
491///
492/// Refer to the methods below to see which encoding to use for your challenge type.
493///
494/// <https://datatracker.ietf.org/doc/html/rfc8555#section-8.1>
495pub struct KeyAuthorization(String);
496
497impl KeyAuthorization {
498    fn new(challenge: &Challenge, key: &Key) -> Self {
499        Self(format!("{}.{}", challenge.token, &key.thumb))
500    }
501
502    /// Get the key authorization value
503    ///
504    /// This can be used for HTTP-01 challenge responses.
505    pub fn as_str(&self) -> &str {
506        &self.0
507    }
508
509    /// Get the SHA-256 digest of the key authorization
510    ///
511    /// This can be used for TLS-ALPN-01 challenge responses.
512    ///
513    /// <https://datatracker.ietf.org/doc/html/rfc8737#section-3>
514    pub fn digest(&self) -> impl AsRef<[u8]> {
515        digest(&SHA256, self.0.as_bytes())
516    }
517
518    /// Get the base64-encoded SHA256 digest of the key authorization
519    ///
520    /// This can be used for DNS-01 challenge responses.
521    pub fn dns_value(&self) -> String {
522        BASE64_URL_SAFE_NO_PAD.encode(self.digest())
523    }
524}
525
526impl fmt::Debug for KeyAuthorization {
527    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
528        f.debug_tuple("KeyAuthorization").finish()
529    }
530}
531
532/// A HMAC key used to link account creation requests to an external account
533///
534/// See RFC 8555 section 7.3.4 for more information.
535pub struct ExternalAccountKey {
536    id: String,
537    key: hmac::Key,
538}
539
540impl ExternalAccountKey {
541    /// Create a new external account key
542    pub fn new(id: String, key_value: &[u8]) -> Self {
543        Self {
544            id,
545            key: hmac::Key::new(hmac::HMAC_SHA256, key_value),
546        }
547    }
548}
549
550impl Signer for ExternalAccountKey {
551    type Signature = hmac::Tag;
552
553    fn header<'n, 'u: 'n, 's: 'u>(&'s self, nonce: Option<&'n str>, url: &'u str) -> Header<'n> {
554        debug_assert_eq!(nonce, None);
555        Header {
556            alg: SigningAlgorithm::Hs256,
557            key: KeyOrKeyId::KeyId(&self.id),
558            nonce,
559            url,
560        }
561    }
562
563    fn sign(&self, payload: &[u8]) -> Result<Self::Signature, Error> {
564        Ok(hmac::sign(&self.key, payload))
565    }
566}
567
568fn nonce_from_response(rsp: &Response) -> Option<String> {
569    rsp.header(REPLAY_NONCE).map(ToOwned::to_owned)
570}
571
572fn client() -> ureq::Agent {
573    ureq::builder().https_only(true).build()
574}
575
576const JOSE_JSON: &str = "application/jose+json";
577const REPLAY_NONCE: &str = "Replay-Nonce";
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn deserialize_old_credentials() -> Result<(), Error> {
585        const CREDENTIALS: &str = r#"{"id":"id","key_pkcs8":"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJVWC_QzOTCS5vtsJp2IG-UDc8cdDfeoKtxSZxaznM-mhRANCAAQenCPoGgPFTdPJ7VLLKt56RxPlYT1wNXnHc54PEyBg3LxKaH0-sJkX0mL8LyPEdsfL_Oz4TxHkWLJGrXVtNhfH","urls":{"newNonce":"new-nonce","newAccount":"new-acct","newOrder":"new-order", "revokeCert": "revoke-cert"}}"#;
586        Account::from_credentials(serde_json::from_str::<AccountCredentials>(CREDENTIALS)?)?;
587        Ok(())
588    }
589
590    #[test]
591    fn deserialize_new_credentials() -> Result<(), Error> {
592        const CREDENTIALS: &str = r#"{"id":"id","key_pkcs8":"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgJVWC_QzOTCS5vtsJp2IG-UDc8cdDfeoKtxSZxaznM-mhRANCAAQenCPoGgPFTdPJ7VLLKt56RxPlYT1wNXnHc54PEyBg3LxKaH0-sJkX0mL8LyPEdsfL_Oz4TxHkWLJGrXVtNhfH","directory":"https://acme-staging-v02.api.letsencrypt.org/directory"}"#;
593        Account::from_credentials(serde_json::from_str::<AccountCredentials>(CREDENTIALS)?)?;
594        Ok(())
595    }
596}