yacme_protocol/
request.rs

1//! HTTP requests which adhere to RFC 8885
2//!
3//! [RFC 8885][] requires that most ACME HTTP requests (other than to the
4//! directory endpoint and the new-nonce endpoint) be authenticated with a
5//! JWS token using the flattened JSON format.
6//!
7//! This format is particular to ACME/[RFC 8885][] and so is implemented
8//! here (along with [crate::jose] which implements the JWS portion).
9//!
10//! For example, a request to create a new account might look like:
11//! ```text
12//! POST /acme/new-account HTTP/1.1
13//! Host: example.com
14//! Content-Type: application/jose+json
15//!
16//! {
17//!   "protected": base64url({
18//!     "alg": "ES256",
19//!     "jwk": {...
20//!     },
21//!     "nonce": "6S8IqOGY7eL2lsGoTZYifg",
22//!     "url": "https://example.com/acme/new-account"
23//!   }),
24//!   "payload": base64url({
25//!     "termsOfServiceAgreed": true,
26//!     "contact": [
27//!       "mailto:cert-admin@example.org",
28//!       "mailto:admin@example.org"
29//!     ]
30//!   }),
31//!   "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I"
32//! }
33//! ```
34//!
35//! [RFC 8885]: https://datatracker.ietf.org/doc/html/rfc8555
36
37use std::fmt::Write;
38use std::{ops::Deref, sync::Arc};
39
40use serde::Serialize;
41use yacme_key::{Signature, SigningKey};
42
43use crate::fmt::{self, HttpCase};
44use crate::jose::{AccountKeyIdentifier, Nonce, ProtectedHeader, SignedToken, UnsignedToken};
45use crate::AcmeError;
46use crate::Url;
47
48const CONTENT_JOSE: &str = "application/jose+json";
49
50/// Trait which marks request/response bodies which can be encoded to string
51/// in some fashion.
52///
53/// This is only useful when formatting the response in the ACME-style HTTP
54/// format, as used by [`crate::fmt::AcmeFormat`].
55///
56/// There is a blanket implementation provided for any type which implements
57/// [`serde::Serialize`], as we assume that serializable values would be sent
58/// over the wire as JSON (or at least, it is acceptable to display the value
59/// as JSON when printing the ACME server response). Other types can
60/// implement this to provide a custom representation when showing an ACME
61/// response.
62pub trait Encode {
63    /// Encode the value to a string suitable for an ACME request payload.
64    fn encode(&self) -> Result<String, AcmeError>;
65}
66
67impl<T> Encode for T
68where
69    T: Serialize,
70{
71    fn encode(&self) -> Result<String, AcmeError> {
72        serde_json::to_string_pretty(&self).map_err(AcmeError::ser)
73    }
74}
75
76/// The HTTP request method in use with this ACME request.
77///
78/// All ACME requests use POST under the hood, since they all contain
79/// a JWS-via-JOSE token to validate that the request is coming from
80/// the account holder. However, sometimes the ACME server wants the
81/// request to have GET semantics. In those cases, the payload will
82/// be the empty string.
83#[derive(Debug, Clone, Copy)]
84pub enum Method<T> {
85    /// GET-as-POST request with an empty string payload
86    Get,
87    /// POST request with a specific JSON payload.
88    Post(T),
89}
90
91/// The components requrired to sign an ACME request from an account which
92/// is already registered with the ACME service in question.
93#[derive(Debug, Clone)]
94#[doc(hidden)]
95pub struct Identified {
96    identifier: AccountKeyIdentifier,
97    key: Arc<SigningKey>,
98}
99
100/// The components required to sign an ACME request for a new account,
101/// when the ACME service is not yet aware of the public key being used.
102#[derive(Debug, Clone)]
103#[doc(hidden)]
104pub struct Signed {
105    key: Arc<SigningKey>,
106}
107
108/// The signing key and method for an ACME request.
109///
110/// There are two ways to sign an ACME request: Identified, and unidentifeid.
111/// Identified requests correspond to an account which is already registered
112/// with the ACME provider. In these cases, the request is signed with the account
113/// key, but the JWS will not contain the JWK object for the public key, and
114/// instead will have the `kid` (Key ID) field, which will contain an account
115/// identifier. In ACME, the account identifier is a URL.
116#[derive(Debug, Clone)]
117pub enum Key {
118    /// A signing key which will be identified to the ACME service as a
119    /// known account.
120    Identified(Identified),
121
122    /// A signing key which will have the public component provided as a
123    /// JWK structure inside the signed part of the request.
124    Signed(Signed),
125}
126
127impl Key {
128    /// Create a protected header which can use this [Key] for signing.
129    ///
130    /// ACME Protected headers must contain the target URL for the request, along with a
131    /// [Nonce], which is used for replay protection.
132    pub(crate) fn header(&self, url: Url, nonce: Nonce) -> ProtectedHeader<&AccountKeyIdentifier> {
133        match &self {
134            Key::Identified(Identified { identifier, key: _ }) => {
135                ProtectedHeader::new_acme_account_header(identifier, url, nonce)
136            }
137
138            Key::Signed(Signed { key }) => ProtectedHeader::new_acme_header(key, url, nonce),
139        }
140    }
141
142    /// A reference to the signing key.
143    pub fn key(&self) -> &Arc<SigningKey> {
144        match self {
145            Key::Identified(Identified { identifier: _, key }) => key,
146            Key::Signed(Signed { key }) => key,
147        }
148    }
149}
150
151impl From<(Arc<SigningKey>, Option<AccountKeyIdentifier>)> for Key {
152    fn from((key, id): (Arc<SigningKey>, Option<AccountKeyIdentifier>)) -> Self {
153        match id {
154            Some(identifier) => Key::Identified(Identified { identifier, key }),
155            None => Key::Signed(Signed { key }),
156        }
157    }
158}
159
160impl From<(Arc<SigningKey>, Url)> for Key {
161    fn from((key, id): (Arc<SigningKey>, Url)) -> Self {
162        Key::Identified(Identified {
163            identifier: AccountKeyIdentifier::from(id),
164            key,
165        })
166    }
167}
168
169impl From<Arc<SigningKey>> for Key {
170    fn from(value: Arc<SigningKey>) -> Self {
171        Key::Signed(Signed { key: value })
172    }
173}
174
175impl From<(Arc<SigningKey>, AccountKeyIdentifier)> for Key {
176    fn from((key, identifier): (Arc<SigningKey>, AccountKeyIdentifier)) -> Self {
177        Key::Identified(Identified { identifier, key })
178    }
179}
180
181/// A request which follows the RFC 8885 protocol for HTTP with JWS authentication
182///
183/// ACME prescribes that all requests are POST JWS requests in the flattened
184/// JSON format. This structure contains all of the materials *except* the
185/// anti-replay [nonce][Nonce] which are required to create an appropriate HTTP
186/// request. The [nonce][Nonce] is left out of this object that if the [`crate::Client`]
187/// encounters a bad [nonce][Nonce], it can re-try the same request with a new [nonce][Nonce]
188/// value without having to re-build the request object.
189///
190///
191///
192/// Create a request with either [`Request::post`] or [`Request::get`]
193///
194/// For example, a GET request:
195///
196/// ```
197/// # use std::sync::Arc;
198/// # use yacme_key::{SignatureKind, SigningKey, EcdsaAlgorithm};
199/// # use yacme_protocol::{Url, Request};
200/// # use yacme_protocol::fmt::AcmeFormat;
201///
202/// // ⚠️ **Do not use this key, it is an example used for testing only!**
203/// let private = "-----BEGIN PRIVATE KEY-----
204/// MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgm1tOPOUt86+QgoiJ
205/// kirpEl69+tUxLP848nPw9BbyW1ShRANCAASGWHBM2Lj7uUA4i9/jKSDp1vw4+iyu
206/// hxVHBELXhxaD/LOQKtQAOhumi1uCTg8mMTrFrUM1VOtF8R0+rjrB3UXd
207/// -----END PRIVATE KEY-----";
208///
209/// let key = Arc::new(SigningKey::from_pkcs8_pem(private,
210///    SignatureKind::Ecdsa(yacme_key::EcdsaAlgorithm::P256))
211/// .unwrap());
212///
213/// let url: Url = "https://letsencrypt.test/new-account-plz/".parse().unwrap();
214///
215/// let request = Request::get(url, key);
216/// println!("{}", request.formatted());
217/// ```
218///
219#[derive(Debug, Clone)]
220pub struct Request<T> {
221    method: Method<T>,
222    url: Url,
223    key: Key,
224}
225
226impl<T> Request<T> {
227    fn new(method: Method<T>, url: Url, key: Key) -> Self {
228        Self { method, url, key }
229    }
230
231    /// Create a `POST` request with a given payload.
232    ///
233    /// The payload must implement [`serde::Serialize`] and will be serialized to
234    /// JSON and included in the JWS which is sent to the ACME server. The [`Url`]
235    /// is required as it is a part of the JWS header (to prevent re-using a
236    /// header and signature pair for additional requests). The signing key is
237    /// also required.
238    pub fn post<K: Into<Key>>(payload: T, url: Url, key: K) -> Self {
239        Self::new(Method::Post(payload), url, key.into())
240    }
241
242    /// Alter the URL on this request to a new value.
243    pub fn with_url(mut self, url: Url) -> Self {
244        self.url = url;
245        self
246    }
247
248    /// Alter the [`Key`] on this request to a new value.
249    pub fn with_key<K: Into<Key>>(mut self, key: K) -> Self {
250        self.key = key.into();
251        self
252    }
253}
254impl Request<()> {
255    /// Create a `GET-as-POST` request with an empty payload.
256    ///
257    /// When making an authenticated `GET` request to an ACME server, the client
258    /// sends a `POST` request, with a JWS body where the payload is the empty
259    /// string. This is signed in the same way that a `POST` request is signed.
260    pub fn get<K: Into<Key>>(url: Url, key: K) -> Self {
261        Self::new(Method::Get, url, key.into())
262    }
263}
264
265impl<T> Request<T>
266where
267    T: Serialize,
268{
269    fn token<'t>(
270        &'t self,
271        header: ProtectedHeader<&'t AccountKeyIdentifier>,
272    ) -> UnsignedToken<&'t T, &'t AccountKeyIdentifier> {
273        match &self.method {
274            Method::Get => UnsignedToken::get(header),
275            Method::Post(payload) => UnsignedToken::post(header, payload),
276        }
277    }
278
279    fn signed_token(
280        &self,
281        nonce: Nonce,
282    ) -> Result<SignedToken<&T, &AccountKeyIdentifier, Signature>, AcmeError> {
283        let header = self.key.header(self.url.clone(), nonce);
284        let key = self.key.key();
285
286        let token = self.token(header);
287        Ok(token.sign(key.deref())?)
288    }
289
290    /// Sign and finalize this request so that it can be sent over HTTP.
291    ///
292    /// The resulting [`SignedRequest`] can be converted to a [`reqwest::Request`]
293    /// for transmission. Normally, this method is not necessary - the [`crate::Client`]
294    /// provides [`crate::Client::execute`] for executing [`Request`] objects natively.
295    pub fn sign(&self, nonce: Nonce) -> Result<SignedRequest, AcmeError> {
296        let signed_token = self.signed_token(nonce)?;
297        let mut request = reqwest::Request::new(http::Method::POST, self.url.clone().into());
298        request
299            .headers_mut()
300            .insert(http::header::CONTENT_TYPE, CONTENT_JOSE.parse().unwrap());
301        let body = serde_json::to_vec(&signed_token).map_err(AcmeError::ser)?;
302        *request.body_mut() = Some(body.into());
303
304        Ok(SignedRequest(request))
305    }
306
307    /// Provides a formatting proxy which when formatted will include the
308    /// signature (as a Base64 URL-safe string in the JWS object). The format approximates
309    /// that used by [RFC 8885][].
310    ///
311    /// Note that this format will include a dummy [nonce][Nonce] value, so the signature is
312    /// consistent and repeatable, but may not match what should have been sent to the
313    /// ACME service provider.
314    ///
315    /// Use [`Request::as_signed_with_nonce`] if you have a real [nonce][Nonce] and want to see
316    /// a representation of this request similar to those in [RFC 8885](https://datatracker.ietf.org/doc/html/rfc8555).
317    pub fn as_signed(&self) -> FormatSignedRequest<'_, T> {
318        let nonce = String::from("<nonce>").into();
319        FormatSignedRequest(self, nonce)
320    }
321
322    /// Provides a formatting proxy which when formatted will include the
323    /// signature (as a Base64 URL-safe string in the JWS object). The format approximates
324    /// that used by [RFC 8885][].
325    ///
326    /// Note that this format will include the provided [nonce][Nonce] value, so the signature
327    /// can match what would be sent to the ACME service provider.
328    ///
329    /// Use [`Request::as_signed`] if you do not have a [nonce][Nonce] and want to see
330    /// a representation of this request similar to those in [RFC 8885][].
331    ///
332    /// [RFC 8885]: https://datatracker.ietf.org/doc/html/rfc8555
333    pub fn as_signed_with_nonce(&self, nonce: Nonce) -> FormatSignedRequest<'_, T> {
334        FormatSignedRequest(self, nonce)
335    }
336
337    /// Format the preamble of this request (the HTTP part) in the style of RFC 8885
338    fn acme_format_preamble<W: fmt::Write>(&self, f: &mut fmt::IndentWriter<'_, W>) -> fmt::Result {
339        let method = match &self.method {
340            Method::Get => "POST as GET",
341            Method::Post(_) => "POST",
342        };
343        let path = self.url.path();
344
345        // Request Line
346        writeln!(f, "{method} {path} HTTP/1.1")?;
347
348        // Host: header
349        if let Some(host) = self.url.host() {
350            writeln!(f, "{}: {}", http::header::HOST.titlecase(), host)?;
351        }
352
353        // Content-Type: header
354        writeln!(
355            f,
356            "{}: {}",
357            http::header::CONTENT_TYPE.titlecase(),
358            CONTENT_JOSE
359        )?;
360
361        // Empty line to mark the end of the HTTP headers
362        writeln!(f)?;
363        Ok(())
364    }
365}
366
367/// Formatting proxy to show a request in the style of
368/// [RFC 8885](https://datatracker.ietf.org/doc/html/rfc8555)
369pub struct FormatSignedRequest<'r, T>(&'r Request<T>, Nonce);
370
371impl<'r, T> fmt::AcmeFormat for FormatSignedRequest<'r, T>
372where
373    T: Serialize,
374{
375    fn fmt<W: std::fmt::Write>(&self, f: &mut fmt::IndentWriter<'_, W>) -> std::fmt::Result {
376        self.0.acme_format_preamble(f)?;
377        let signed = self.0.signed_token(self.1.clone()).unwrap();
378        <SignedToken<_, _, _> as fmt::AcmeFormat>::fmt(&signed, f)
379    }
380}
381
382impl<T> fmt::AcmeFormat for Request<T>
383where
384    T: Serialize,
385{
386    fn fmt<W: fmt::Write>(&self, f: &mut fmt::IndentWriter<'_, W>) -> fmt::Result {
387        self.acme_format_preamble(f)?;
388        let nonce = String::from("<nonce>").into();
389        let header = self.key.header(self.url.clone(), nonce);
390        let token = self.token(header);
391
392        <UnsignedToken<_, _> as fmt::AcmeFormat>::fmt(&token, f)
393    }
394}
395
396/// A request which follows the RFC 8885 protocol for HTTP with JWS authentication
397/// and has been signed with a private key.
398///
399/// This request is ready to be transmitted over HTTP.
400pub struct SignedRequest(reqwest::Request);
401
402impl SignedRequest {
403    pub(crate) fn into_inner(self) -> reqwest::Request {
404        self.0
405    }
406}
407
408impl From<SignedRequest> for reqwest::Request {
409    fn from(value: SignedRequest) -> Self {
410        value.0
411    }
412}
413
414#[cfg(test)]
415mod test {
416    use serde_json::json;
417
418    use crate::fmt::AcmeFormat;
419
420    use super::*;
421
422    #[test]
423    fn encode_via_serialize() {
424        let data = json!({
425            "foo": "bar",
426            "baz": ["qux", "gorb"]
427        });
428
429        let expected = serde_json::to_string_pretty(
430            &serde_json::from_str::<serde_json::Value>(crate::example!("json-object.json"))
431                .unwrap(),
432        )
433        .unwrap();
434
435        assert_eq!(data.encode().unwrap(), expected);
436    }
437
438    #[test]
439    fn key_builds_header() {
440        let key = crate::key!("ec-p255");
441
442        let url = "https://letsencrypt.test/new-orderz"
443            .parse::<Url>()
444            .unwrap();
445        let nonce: Nonce = String::from("<nonce>").into();
446
447        let request_key: Key = (key, None).into();
448        let header = request_key.header(url, nonce);
449        assert_eq!(
450            header.formatted().to_string(),
451            crate::example!("header-key.txt").trim()
452        );
453    }
454
455    #[test]
456    fn key_builds_header_with_id() {
457        let key = crate::key!("ec-p255");
458        let identifier = AccountKeyIdentifier::from(
459            "https://letsencrypt.test/account/foo-bar"
460                .parse::<Url>()
461                .unwrap(),
462        );
463        let url = "https://letsencrypt.test/new-orderz"
464            .parse::<Url>()
465            .unwrap();
466        let nonce: Nonce = String::from("<nonce>").into();
467
468        let request_key: Key = (key, Some(identifier)).into();
469        let header = request_key.header(url, nonce);
470
471        eprintln!("{}", header.formatted());
472        assert_eq!(
473            header.formatted().to_string(),
474            crate::example!("header-id.txt").trim()
475        );
476    }
477}