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}