Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/acme/rfc8555.rs

44.0 KiB, 146 runs

created by r1870400018:9525, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Typed message shapes for RFC 8555 (ACME) JSON bodies.
2//!
3//! This module owns the Rust-side representation of every ACME request and
4//! response body Steel actually sends or receives when driving a certificate
5//! through a CA such as Let's Encrypt via `tls-alpn-01`. The goal is narrow
6//! but complete:
7//!
8//! - Response bodies from the CA are parsed into typed structs via the
9//! existing `FromDatMap` derive. Field renames cover the camelCase
10//! convention used in the wire format (`newNonce`, `termsOfServiceAgreed`,
11//! etc.), and `#[optional]` marks every field that may legitimately be
12//! missing. **The `token` and `url` fields on `Challenge` are marked
13//! `#[optional]` specifically** because live Let's Encrypt staging
14//! responses sometimes contain challenge objects that omit them, and
15//! without this marking the derive would fail the whole parse with a
16//! `missing field 'token'` style error -- the exact regression the
17//! vendored `rustls-acme` patch existed to guard against.
18//!
19//! - Request bodies we send to the CA are built via tiny helper functions
20//! that return a `Dat::Map`, so callers get a typed value they can feed
21//! straight into [`crate::acme::jose::JwsSigner::sign_flattened`] as the
22//! JWS payload (after `.json()` and base64url).
23//!
24//! Nested compound fields (e.g. the identifier inside an authorisation, or
25//! the list of challenges) stay as `Dat` / `Vec<Dat>` rather than recursing
26//! through another derive, and the enclosing type exposes a small `typed_*`
27//! helper that parses them on demand. This mirrors the pattern used by
28//! `fe2o3_steel::srv::cfg::ServerConfig` where `vhosts: Dat` is extracted
29//! via a dedicated `get_vhosts()` method.
30//!
31//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
32//! Anthropic Claude
33
34use crate::acme::jose;
35
36use oxedyne_fe2o3_core::prelude::*;
37use oxedyne_fe2o3_jdat::{
38 prelude::*,
39 string::dec::DecoderConfig,
40 usr::{
41 UsrKind,
42 UsrKindCode,
43 UsrKindId,
44 },
45};
46
47use std::collections::BTreeMap;
48
49use ring::digest::{
50 Context,
51 SHA256,
52};
53
54
55// ┌───────────────────────────────────────────────────────────────────────────┐
56// │ HELPERS │
57// └───────────────────────────────────────────────────────────────────────────┘
58
59fn sha256(data: &[u8]) -> [u8; 32] {
60 let mut ctx = Context::new(&SHA256);
61 ctx.update(data);
62 let digest = ctx.finish();
63 let mut out = [0u8; 32];
64 out.copy_from_slice(digest.as_ref());
65 out
66}
67
68
69// ┌───────────────────────────────────────────────────────────────────────────┐
70// │ RESPONSE PARSING │
71// └───────────────────────────────────────────────────────────────────────────┘
72
73/// The decoder runs in strict JSON mode, no comments and no trailing commas, so
74/// only standards-compliant CA output is accepted.
75pub fn parse_json_response<T: FromDatMap>(body: &[u8]) -> Outcome<T> {
76 let s = match std::str::from_utf8(body) {
77 Ok(s) => s.to_string(),
78 Err(e) => return Err(err!(e,
79 "ACME response body is not valid UTF-8.";
80 IO, Network, Decode, Invalid, Input)),
81 };
82 let cfg: DecoderConfig<
83 BTreeMap<UsrKindCode, UsrKind>,
84 BTreeMap<String, UsrKindId>,
85 > = DecoderConfig::json(None);
86 let dat = res!(Dat::decode_string_with_config(s, &cfg));
87 match dat {
88 Dat::Map(m) => T::from_datmap(m),
89 other => Err(err!(
90 "Expected a JSON object at the ACME response root, got {:?}.",
91 other;
92 IO, Network, Invalid, Mismatch, Input)),
93 }
94}
95
96
97// ┌───────────────────────────────────────────────────────────────────────────┐
98// │ STATUS ENUMS (RFC 8555 §7.1.6) │
99// └───────────────────────────────────────────────────────────────────────────┘
100//
101// The wire structs below keep their `status` as a `String`, because the
102// `FromDatMap` derive resolves a field's `Dat` getter from its declared type
103// and knows nothing of our enums. Parsing the string into one of these enums
104// at the point of use lets every decision the client makes be an exhaustive
105// `match` rather than a scatter of `== "valid"` comparisons -- which is how a
106// state the client never considered (an authorisation that arrives already
107// `valid`) came to be handled by falling through into the wrong branch.
108
109/// Order lifecycle status, RFC 8555 §7.1.6.
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
111pub enum OrderStatus {
112 Pending, // authorisations are outstanding
113 Ready, // every authorisation is valid, awaiting a CSR
114 Processing, // the CSR is accepted and the CA is issuing
115 Valid, // the certificate has been issued
116 Invalid, // failed, and cannot be recovered
117}
118
119impl OrderStatus {
120
121 pub fn from_wire(s: &str) -> Outcome<Self> {
122 match s {
123 "pending" => Ok(Self::Pending),
124 "ready" => Ok(Self::Ready),
125 "processing" => Ok(Self::Processing),
126 "valid" => Ok(Self::Valid),
127 "invalid" => Ok(Self::Invalid),
128 other => Err(err!(
129 "Unknown ACME order status {:?}; RFC 8555 §7.1.6 defines \
130 only pending, ready, processing, valid and invalid.", other;
131 IO, Network, Invalid, Mismatch)),
132 }
133 }
134
135 pub fn as_wire(&self) -> &'static str {
136 match self {
137 Self::Pending => "pending",
138 Self::Ready => "ready",
139 Self::Processing => "processing",
140 Self::Valid => "valid",
141 Self::Invalid => "invalid",
142 }
143 }
144}
145
146/// Authorisation status, RFC 8555 §7.1.6.
147///
148/// There is no `processing` state: an authorisation goes straight from
149/// `pending` to `valid` or `invalid` once its challenge is decided. The CA
150/// caches validations -- Let's Encrypt for around 30 days -- so a freshly
151/// created order can legitimately carry authorisations that are already
152/// `valid`, with nothing left for the client to prove.
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub enum AuthorizationStatus {
155 Pending, // a challenge still has to be satisfied
156 Valid, // the CA has validated the identifier
157 Invalid, // a challenge was attempted and failed
158 Deactivated, // deactivated by the client
159 Expired, // past its `expires` time
160 Revoked, // revoked by the CA
161}
162
163impl AuthorizationStatus {
164
165 pub fn from_wire(s: &str) -> Outcome<Self> {
166 match s {
167 "pending" => Ok(Self::Pending),
168 "valid" => Ok(Self::Valid),
169 "invalid" => Ok(Self::Invalid),
170 "deactivated" => Ok(Self::Deactivated),
171 "expired" => Ok(Self::Expired),
172 "revoked" => Ok(Self::Revoked),
173 other => Err(err!(
174 "Unknown ACME authorisation status {:?}; RFC 8555 §7.1.6 \
175 defines only pending, valid, invalid, deactivated, expired \
176 and revoked.", other;
177 IO, Network, Invalid, Mismatch)),
178 }
179 }
180
181 pub fn as_wire(&self) -> &'static str {
182 match self {
183 Self::Pending => "pending",
184 Self::Valid => "valid",
185 Self::Invalid => "invalid",
186 Self::Deactivated => "deactivated",
187 Self::Expired => "expired",
188 Self::Revoked => "revoked",
189 }
190 }
191}
192
193/// Challenge status, RFC 8555 §7.1.6.
194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub enum ChallengeStatus {
196 Pending, // the client has not yet signalled readiness
197 Processing, // readiness signalled, the CA is validating
198 Valid, // the CA validated it
199 Invalid, // the CA could not validate it
200}
201
202impl ChallengeStatus {
203
204 pub fn from_wire(s: &str) -> Outcome<Self> {
205 match s {
206 "pending" => Ok(Self::Pending),
207 "processing" => Ok(Self::Processing),
208 "valid" => Ok(Self::Valid),
209 "invalid" => Ok(Self::Invalid),
210 other => Err(err!(
211 "Unknown ACME challenge status {:?}; RFC 8555 §7.1.6 defines \
212 only pending, processing, valid and invalid.", other;
213 IO, Network, Invalid, Mismatch)),
214 }
215 }
216
217 pub fn as_wire(&self) -> &'static str {
218 match self {
219 Self::Pending => "pending",
220 Self::Processing => "processing",
221 Self::Valid => "valid",
222 Self::Invalid => "invalid",
223 }
224 }
225}
226
227
228// ┌───────────────────────────────────────────────────────────────────────────┐
229// │ DIRECTORY (RFC 8555 §7.1.1) │
230// └───────────────────────────────────────────────────────────────────────────┘
231
232/// The ACME directory document a `GET {directory_url}` returns. Every field but
233/// `meta` is a fully-qualified URL the client uses as the target of a subsequent
234/// request.
235#[derive(Clone, Debug, Default, FromDatMap)]
236pub struct Directory {
237 #[rename(name = "newNonce")]
238 pub new_nonce: String,
239 #[rename(name = "newAccount")]
240 pub new_account: String,
241 #[rename(name = "newOrder")]
242 pub new_order: String,
243 #[rename(name = "revokeCert")]
244 #[optional]
245 pub revoke_cert: String,
246 #[rename(name = "keyChange")]
247 #[optional]
248 pub key_change: String,
249 #[optional]
250 pub meta: Dat, // terms of service URL, external account binding
251}
252
253
254// ┌───────────────────────────────────────────────────────────────────────────┐
255// │ ACCOUNT (RFC 8555 §7.3) │
256// └───────────────────────────────────────────────────────────────────────────┘
257
258/// The account object a `POST {new_account}` and every later account management
259/// request returns. Only `status` is acted on; the rest is kept for logging.
260#[derive(Clone, Debug, Default, FromDatMap)]
261pub struct Account {
262 pub status: String,
263 #[optional]
264 pub contact: Vec<Dat>,
265 #[optional]
266 pub orders: String,
267}
268
269
270// ┌───────────────────────────────────────────────────────────────────────────┐
271// │ ORDER (RFC 8555 §7.1.3) │
272// └───────────────────────────────────────────────────────────────────────────┘
273
274/// Order object returned by `POST {new_order}` and by `POST-as-GET` polls of
275/// an order URL while issuance is in flight.
276#[derive(Clone, Debug, Default, FromDatMap)]
277pub struct Order {
278 pub status: String, // RFC 8555 §7.1.6, see typed_status
279 #[optional]
280 pub expires: String,
281 #[optional]
282 pub identifiers: Vec<Dat>, // `{"type":"dns","value":"<name>"}` maps
283 pub authorizations: Vec<String>, // all to be satisfied before finalising
284 pub finalize: String, // where the CSR is POSTed
285 #[optional]
286 pub certificate: String, // absent until `status` is `valid`
287 #[optional]
288 pub error: Dat, // RFC 7807, set when `status` is `invalid`
289}
290
291impl Order {
292 pub fn typed_status(&self) -> Outcome<OrderStatus> {
293 OrderStatus::from_wire(&self.status)
294 }
295
296 pub fn typed_error(&self) -> Outcome<Option<Problem>> {
297 match &self.error {
298 Dat::Empty => Ok(None),
299 Dat::Map(m) => Ok(Some(res!(Problem::from_datmap(m.clone())))),
300 other => Err(err!(
301 "Order.error is not a JSON object, got {:?}.", other;
302 IO, Network, Invalid, Mismatch)),
303 }
304 }
305}
306
307
308// ┌───────────────────────────────────────────────────────────────────────────┐
309// │ AUTHORISATION (RFC 8555 §7.1.4) │
310// └───────────────────────────────────────────────────────────────────────────┘
311
312/// Authorisation object returned by `POST-as-GET {authz_url}`.
313///
314/// Every authorisation carries a list of challenges; ACME §8 specifies that
315/// the client must satisfy **one** of them. Steel always uses `tls-alpn-01`.
316#[derive(Clone, Debug, Default, FromDatMap)]
317pub struct Authorization {
318 pub status: String,
319 #[optional]
320 pub expires: String,
321 pub identifier: Dat, // `{"type":"dns","value":"<name>"}`
322 pub challenges: Vec<Dat>, // those the CA is willing to accept
323 #[optional]
324 pub wildcard: bool, // set for a wildcard identifier
325}
326
327impl Authorization {
328 pub fn typed_status(&self) -> Outcome<AuthorizationStatus> {
329 AuthorizationStatus::from_wire(&self.status)
330 }
331
332 pub fn typed_challenges(&self) -> Outcome<Vec<Challenge>> {
333 let mut out = Vec::with_capacity(self.challenges.len());
334 for (i, dat) in self.challenges.iter().enumerate() {
335 match dat {
336 Dat::Map(m) => out.push(res!(Challenge::from_datmap(m.clone()))),
337 other => return Err(err!(
338 "Authorization.challenges[{}] is not a JSON object, got {:?}.",
339 i, other;
340 IO, Network, Invalid, Mismatch)),
341 }
342 }
343 Ok(out)
344 }
345
346 pub fn tls_alpn_01_challenge(&self) -> Outcome<Option<Challenge>> {
347 for chall in res!(self.typed_challenges()) {
348 if chall.typ == "tls-alpn-01" {
349 return Ok(Some(chall));
350 }
351 }
352 Ok(None)
353 }
354}
355
356
357// ┌───────────────────────────────────────────────────────────────────────────┐
358// │ CHALLENGE (RFC 8555 §8) │
359// └───────────────────────────────────────────────────────────────────────────┘
360
361/// A single challenge on an authorisation.
362///
363/// `url` and `token` are `#[optional]` because Let's Encrypt's staging responses
364/// sometimes carry challenges with neither, for challenge types this client does
365/// not participate in; without the marking the derive fails the whole
366/// authorisation parse. `token` is only ever read on a `tls-alpn-01` challenge,
367/// so an empty default elsewhere is harmless. This reproduces, in the jdat
368/// derive, what the `#[serde(default)]` in the vendored `rustls-acme` patch did.
369#[derive(Clone, Debug, Default, FromDatMap)]
370pub struct Challenge {
371 #[rename(name = "type")]
372 pub typ: String,
373 pub status: String,
374 #[optional]
375 pub url: String,
376 #[optional]
377 pub token: String,
378 #[optional]
379 pub validated: String,
380 #[optional]
381 pub error: Dat,
382}
383
384impl Challenge {
385 pub fn typed_status(&self) -> Outcome<ChallengeStatus> {
386 ChallengeStatus::from_wire(&self.status)
387 }
388
389 /// RFC 8555 §8.1: `token || '.' || base64url(SHA-256(JWK))`. The account JWK
390 /// thumbprint comes from the caller, normally
391 /// [`crate::acme::jose::JwsSigner::jwk_thumbprint_sha256`].
392 pub fn key_authorization(&self, jwk_thumbprint: &[u8; 32]) -> String {
393 fmt!("{}.{}", self.token, jose::base64url_encode(jwk_thumbprint))
394 }
395
396 /// RFC 8555 §8.4: base64url of the SHA-256 **digest of the key authorisation
397 /// string**, not of the token and not of the raw thumbprint. The digest is
398 /// taken over the key authorisation's UTF-8 bytes and the digest -- not the
399 /// string -- is what gets encoded. Published at `_acme-challenge.<domain>`.
400 pub fn dns_01_txt_value(&self, jwk_thumbprint: &[u8; 32]) -> String {
401 let key_auth = self.key_authorization(jwk_thumbprint);
402 let digest = sha256(key_auth.as_bytes());
403 jose::base64url_encode(&digest)
404 }
405
406 pub fn typed_error(&self) -> Outcome<Option<Problem>> {
407 match &self.error {
408 Dat::Empty => Ok(None),
409 Dat::Map(m) => Ok(Some(res!(Problem::from_datmap(m.clone())))),
410 other => Err(err!(
411 "Challenge.error is not a JSON object, got {:?}.", other;
412 IO, Network, Invalid, Mismatch)),
413 }
414 }
415}
416
417
418// ┌───────────────────────────────────────────────────────────────────────────┐
419// │ PROBLEM (RFC 7807, used by RFC 8555 for errors) │
420// └───────────────────────────────────────────────────────────────────────────┘
421
422/// A CA-supplied problem document describing why a request failed or why an
423/// order or challenge ended up in the `invalid` state.
424#[derive(Clone, Debug, Default, FromDatMap)]
425pub struct Problem {
426 #[rename(name = "type")]
427 #[optional]
428 pub typ: String,
429 #[optional]
430 pub title: String,
431 #[optional]
432 pub detail: String,
433 #[optional]
434 pub status: u32,
435 #[optional]
436 pub subproblems: Vec<Dat>,
437}
438
439
440// ┌───────────────────────────────────────────────────────────────────────────┐
441// │ REQUEST BUILDERS │
442// └───────────────────────────────────────────────────────────────────────────┘
443
444/// `contact_mailto` is a bare email address; the `mailto:` prefix is added here.
445/// `terms_agreed` must be `true`, which every public CA targeted requires.
446pub fn new_account_request(
447 contact_mailto: &str,
448 terms_agreed: bool,
449)
450 -> Dat
451{
452 mapdat!{
453 "termsOfServiceAgreed" => terms_agreed,
454 "contact" => Dat::List(vec![dat!(fmt!("mailto:{}", contact_mailto))]),
455 }
456}
457
458/// Each entry in `dns_names` becomes an RFC 8555 §7.1.3 identifier of type
459/// `"dns"`, and the CA mints one authorisation per distinct identifier.
460pub fn new_order_request(dns_names: &[String]) -> Dat {
461 let identifiers: Vec<Dat> = dns_names
462 .iter()
463 .map(|n| mapdat!{
464 "type" => "dns",
465 "value" => n.clone(),
466 })
467 .collect();
468 mapdat!{
469 "identifiers" => Dat::List(identifiers),
470 }
471}
472
473/// For `POST {finalize_url}`, once every authorisation is satisfied.
474/// `csr_der_b64url` is the CSR's DER, base64url-encoded.
475pub fn finalize_request(csr_der_b64url: &str) -> Dat {
476 mapdat!{
477 "csr" => csr_der_b64url.to_string(),
478 }
479}
480
481
482// ┌───────────────────────────────────────────────────────────────────────────┐
483// │ TESTS │
484// └───────────────────────────────────────────────────────────────────────────┘
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 /// Parse a realistic Directory response modelled on Let's Encrypt's
491 /// current output.
492 #[test]
493 fn test_parse_directory() -> Outcome<()> {
494 let body = br#"{
495 "newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce",
496 "newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct",
497 "newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
498 "revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert",
499 "keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change",
500 "meta": {
501 "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.5-February-24-2025.pdf",
502 "website": "https://letsencrypt.org"
503 }
504 }"#;
505 let dir: Directory = res!(parse_json_response(body));
506 if !dir.new_nonce.ends_with("/new-nonce") {
507 return Err(err!(
508 "newNonce parsed as {:?}", dir.new_nonce;
509 Test, Mismatch));
510 }
511 if !dir.new_account.ends_with("/new-acct") {
512 return Err(err!(
513 "newAccount parsed as {:?}", dir.new_account;
514 Test, Mismatch));
515 }
516 if !dir.new_order.ends_with("/new-order") {
517 return Err(err!(
518 "newOrder parsed as {:?}", dir.new_order;
519 Test, Mismatch));
520 }
521 Ok(())
522 }
523
524 /// Parse an Account response and verify the status round-trips.
525 #[test]
526 fn test_parse_account() -> Outcome<()> {
527 let body = br#"{
528 "status": "valid",
529 "contact": ["mailto:hello@example.test"],
530 "orders": "https://acme-v02.api.letsencrypt.org/acme/acct/1/orders"
531 }"#;
532 let account: Account = res!(parse_json_response(body));
533 if account.status != "valid" {
534 return Err(err!(
535 "account.status parsed as {:?}", account.status;
536 Test, Mismatch));
537 }
538 if account.contact.len() != 1 {
539 return Err(err!(
540 "account.contact has {} entries, expected 1.", account.contact.len();
541 Test, Mismatch));
542 }
543 Ok(())
544 }
545
546 /// Parse an Order in the `pending` state and verify the authorisation
547 /// URLs survive.
548 #[test]
549 fn test_parse_order_pending() -> Outcome<()> {
550 let body = br#"{
551 "status": "pending",
552 "expires": "2026-05-01T12:00:00Z",
553 "identifiers": [
554 {"type":"dns","value":"example.com"},
555 {"type":"dns","value":"www.example.com"}
556 ],
557 "authorizations": [
558 "https://acme-v02.api.letsencrypt.org/acme/authz/1",
559 "https://acme-v02.api.letsencrypt.org/acme/authz/2"
560 ],
561 "finalize": "https://acme-v02.api.letsencrypt.org/acme/finalize/1"
562 }"#;
563 let order: Order = res!(parse_json_response(body));
564 if order.status != "pending" {
565 return Err(err!("order.status parsed as {:?}", order.status;
566 Test, Mismatch));
567 }
568 if order.authorizations.len() != 2 {
569 return Err(err!(
570 "order.authorizations has {} entries, expected 2.",
571 order.authorizations.len();
572 Test, Mismatch));
573 }
574 if !order.finalize.ends_with("/finalize/1") {
575 return Err(err!("order.finalize parsed as {:?}", order.finalize;
576 Test, Mismatch));
577 }
578 if !order.certificate.is_empty() {
579 return Err(err!(
580 "order.certificate should default to empty when absent, got {:?}.",
581 order.certificate;
582 Test, Mismatch));
583 }
584 Ok(())
585 }
586
587 /// Parse an Order in the `valid` state with a certificate URL attached.
588 #[test]
589 fn test_parse_order_valid() -> Outcome<()> {
590 let body = br#"{
591 "status": "valid",
592 "expires": "2026-05-01T12:00:00Z",
593 "identifiers": [{"type":"dns","value":"example.com"}],
594 "authorizations": ["https://acme-v02.api.letsencrypt.org/acme/authz/1"],
595 "finalize": "https://acme-v02.api.letsencrypt.org/acme/finalize/1",
596 "certificate": "https://acme-v02.api.letsencrypt.org/acme/cert/abcdef"
597 }"#;
598 let order: Order = res!(parse_json_response(body));
599 if order.status != "valid" {
600 return Err(err!("order.status = {:?}", order.status; Test, Mismatch));
601 }
602 if !order.certificate.ends_with("/cert/abcdef") {
603 return Err(err!(
604 "order.certificate = {:?}", order.certificate;
605 Test, Mismatch));
606 }
607 Ok(())
608 }
609
610 /// Parse an Authorization response and verify the challenge list comes
611 /// through intact and `typed_challenges` succeeds.
612 #[test]
613 fn test_parse_authorization_happy_path() -> Outcome<()> {
614 let body = br#"{
615 "status": "pending",
616 "expires": "2026-05-01T12:00:00Z",
617 "identifier": {"type":"dns","value":"example.com"},
618 "challenges": [
619 {
620 "type": "http-01",
621 "status": "pending",
622 "url": "https://acme-v02.api.letsencrypt.org/acme/chall/1/a",
623 "token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
624 },
625 {
626 "type": "dns-01",
627 "status": "pending",
628 "url": "https://acme-v02.api.letsencrypt.org/acme/chall/1/b",
629 "token": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
630 },
631 {
632 "type": "tls-alpn-01",
633 "status": "pending",
634 "url": "https://acme-v02.api.letsencrypt.org/acme/chall/1/c",
635 "token": "cccccccccccccccccccccccccccccccc"
636 }
637 ]
638 }"#;
639 let authz: Authorization = res!(parse_json_response(body));
640 if authz.status != "pending" {
641 return Err(err!("authz.status = {:?}", authz.status; Test, Mismatch));
642 }
643 let challenges = res!(authz.typed_challenges());
644 if challenges.len() != 3 {
645 return Err(err!(
646 "typed_challenges returned {} entries, expected 3.", challenges.len();
647 Test, Mismatch));
648 }
649 let tls = match res!(authz.tls_alpn_01_challenge()) {
650 Some(c) => c,
651 None => return Err(err!(
652 "tls_alpn_01_challenge returned None.";
653 Test, Missing)),
654 };
655 if tls.token != "cccccccccccccccccccccccccccccccc" {
656 return Err(err!(
657 "tls-alpn-01 token = {:?}", tls.token;
658 Test, Mismatch));
659 }
660 Ok(())
661 }
662
663 /// **Regression test for the vendor patch.** Parse an authorisation whose
664 /// challenges include one that omits both `token` and `url` entirely --
665 /// the exact shape that broke upstream `rustls-acme 0.15.1` deserialisation
666 /// with `missing field 'token'` against a live ACME staging server.
667 /// With our `#[optional]` markings this must succeed, and the affected
668 /// challenge must deserialise with empty defaults on both fields while
669 /// the `tls-alpn-01` entry is still readable.
670 #[test]
671 fn test_parse_authorization_with_tokenless_challenge() -> Outcome<()> {
672 let body = br#"{
673 "status": "pending",
674 "identifier": {"type":"dns","value":"example.com"},
675 "challenges": [
676 {
677 "type": "dns-persist-01",
678 "status": "pending"
679 },
680 {
681 "type": "tls-alpn-01",
682 "status": "pending",
683 "url": "https://acme-v02.api.letsencrypt.org/acme/chall/1/tls",
684 "token": "reallyatlsalpntoken"
685 }
686 ]
687 }"#;
688 let authz: Authorization = res!(parse_json_response(body));
689 let challenges = res!(authz.typed_challenges());
690 if challenges.len() != 2 {
691 return Err(err!(
692 "typed_challenges returned {} entries, expected 2.", challenges.len();
693 Test, Mismatch));
694 }
695 // The tokenless challenge must parse with empty defaults.
696 let tokenless = &challenges[0];
697 if tokenless.typ != "dns-persist-01" {
698 return Err(err!("tokenless.typ = {:?}", tokenless.typ; Test, Mismatch));
699 }
700 if !tokenless.token.is_empty() {
701 return Err(err!(
702 "Tokenless challenge should default to empty token, got {:?}.",
703 tokenless.token;
704 Test, Mismatch));
705 }
706 if !tokenless.url.is_empty() {
707 return Err(err!(
708 "Tokenless challenge should default to empty url, got {:?}.",
709 tokenless.url;
710 Test, Mismatch));
711 }
712 // The tls-alpn-01 challenge must still be readable.
713 let tls = match res!(authz.tls_alpn_01_challenge()) {
714 Some(c) => c,
715 None => return Err(err!(
716 "tls_alpn_01_challenge returned None despite a tls-alpn-01 entry.";
717 Test, Missing)),
718 };
719 if tls.token != "reallyatlsalpntoken" {
720 return Err(err!(
721 "tls-alpn-01 token = {:?}", tls.token;
722 Test, Mismatch));
723 }
724 Ok(())
725 }
726
727 /// The `new_account_request` helper must emit the exact two-field shape
728 /// RFC 8555 §7.3 mandates, with the contact entry wrapped in the
729 /// `mailto:` URI scheme.
730 #[test]
731 fn test_new_account_request_shape() -> Outcome<()> {
732 let req = new_account_request("hello@example.test", true);
733 match req {
734 Dat::Map(m) => {
735 match m.get(&dat!("termsOfServiceAgreed")) {
736 Some(Dat::Bool(true)) => (),
737 other => return Err(err!(
738 "termsOfServiceAgreed = {:?}", other;
739 Test, Mismatch)),
740 }
741 match m.get(&dat!("contact")) {
742 Some(Dat::List(entries)) => {
743 if entries.len() != 1 {
744 return Err(err!(
745 "contact list has {} entries.", entries.len();
746 Test, Mismatch));
747 }
748 match &entries[0] {
749 Dat::Str(s) => {
750 if s != "mailto:hello@example.test" {
751 return Err(err!(
752 "contact[0] = {:?}", s;
753 Test, Mismatch));
754 }
755 },
756 other => return Err(err!(
757 "contact[0] = {:?}", other;
758 Test, Mismatch)),
759 }
760 },
761 other => return Err(err!(
762 "contact = {:?}", other;
763 Test, Mismatch)),
764 }
765 },
766 other => return Err(err!(
767 "new_account_request did not produce a Dat::Map, got {:?}.",
768 other;
769 Test, Mismatch)),
770 }
771 Ok(())
772 }
773
774 /// The `new_order_request` helper must wrap each DNS name in a
775 /// `{"type":"dns","value":...}` identifier map.
776 #[test]
777 fn test_new_order_request_shape() -> Outcome<()> {
778 let req = new_order_request(&[
779 "example.com".to_string(),
780 "www.example.com".to_string(),
781 ]);
782 match req {
783 Dat::Map(m) => match m.get(&dat!("identifiers")) {
784 Some(Dat::List(list)) => {
785 if list.len() != 2 {
786 return Err(err!(
787 "identifiers list has {} entries.", list.len();
788 Test, Mismatch));
789 }
790 // Spot-check the second identifier is shaped correctly.
791 match &list[1] {
792 Dat::Map(im) => {
793 match im.get(&dat!("type")) {
794 Some(Dat::Str(s)) if s == "dns" => (),
795 other => return Err(err!(
796 "identifiers[1].type = {:?}", other;
797 Test, Mismatch)),
798 }
799 match im.get(&dat!("value")) {
800 Some(Dat::Str(s)) if s == "www.example.com" => (),
801 other => return Err(err!(
802 "identifiers[1].value = {:?}", other;
803 Test, Mismatch)),
804 }
805 },
806 other => return Err(err!(
807 "identifiers[1] = {:?}", other;
808 Test, Mismatch)),
809 }
810 },
811 other => return Err(err!(
812 "identifiers = {:?}", other;
813 Test, Mismatch)),
814 },
815 other => return Err(err!(
816 "new_order_request did not produce a Dat::Map, got {:?}.",
817 other;
818 Test, Mismatch)),
819 }
820 Ok(())
821 }
822
823 /// Regression test for a jdat encoder boolean bug that broke an ACME
824 /// new-account POST against a live staging server: `Dat::Bool(true).json()`
825 /// used to emit the JSON string `"true"` instead of the JSON literal
826 /// `true`, causing Let's Encrypt to reject the request with
827 /// `Error unmarshaling JSON`. After fixing
828 /// `fe2o3_jdat/src/string/enc.rs:633` this test asserts that a
829 /// realistic ACME payload containing a boolean now serialises
830 /// through `.json()` → parses as valid JSON → round-trips via
831 /// `parse_json_response` → yields the correct boolean value.
832 #[test]
833 fn test_new_account_request_json_bool_round_trips() -> Outcome<()> {
834 let req = new_account_request("hello@example.test", true);
835 let bytes = res!(req.json()).into_bytes();
836 let cfg: DecoderConfig<
837 BTreeMap<UsrKindCode, UsrKind>,
838 BTreeMap<String, UsrKindId>,
839 > = DecoderConfig::json(None);
840 let s = match std::str::from_utf8(&bytes) {
841 Ok(s) => s.to_string(),
842 Err(e) => return Err(err!(e,
843 "new_account_request .json() produced invalid UTF-8.";
844 Test, Decode)),
845 };
846 let reparsed = res!(Dat::decode_string_with_config(s, &cfg));
847 match reparsed {
848 Dat::Map(m) => match m.get(&dat!("termsOfServiceAgreed")) {
849 Some(Dat::Bool(true)) => Ok(()),
850 other => Err(err!(
851 "termsOfServiceAgreed round-tripped as {:?}; expected \
852 Dat::Bool(true). Bool-as-string bug regressed.", other;
853 Test, Mismatch)),
854 },
855 other => Err(err!(
856 "new_account_request .json() did not parse back as a \
857 JSON object, got {:?}.", other;
858 Test, Mismatch)),
859 }
860 }
861
862 // ---- status enums (RFC 8555 §7.1.6) ----------------------------------
863
864 /// Every status RFC 8555 §7.1.6 defines must round-trip through its enum,
865 /// and anything else must be refused rather than silently mapped onto a
866 /// status we do know.
867 #[test]
868 fn test_status_enums_round_trip_and_reject_unknown() -> Outcome<()> {
869 for s in ["pending", "ready", "processing", "valid", "invalid"] {
870 let st = res!(OrderStatus::from_wire(s));
871 if st.as_wire() != s {
872 return Err(err!(
873 "OrderStatus {:?} round-tripped as {:?}.", s, st.as_wire();
874 Test, Mismatch));
875 }
876 }
877 for s in ["pending", "valid", "invalid", "deactivated", "expired", "revoked"] {
878 let st = res!(AuthorizationStatus::from_wire(s));
879 if st.as_wire() != s {
880 return Err(err!(
881 "AuthorizationStatus {:?} round-tripped as {:?}.",
882 s, st.as_wire();
883 Test, Mismatch));
884 }
885 }
886 for s in ["pending", "processing", "valid", "invalid"] {
887 let st = res!(ChallengeStatus::from_wire(s));
888 if st.as_wire() != s {
889 return Err(err!(
890 "ChallengeStatus {:?} round-tripped as {:?}.",
891 s, st.as_wire();
892 Test, Mismatch));
893 }
894 }
895 // An authorisation has no `processing` state (§7.1.6), and an order
896 // has no `expired` one. Accepting them would mean the client silently
897 // mishandled a status the CA never sends.
898 if AuthorizationStatus::from_wire("processing").is_ok() {
899 return Err(err!(
900 "AuthorizationStatus accepted 'processing', which RFC 8555 \
901 §7.1.6 does not define for authorisations.";
902 Test, Mismatch));
903 }
904 if OrderStatus::from_wire("expired").is_ok() {
905 return Err(err!(
906 "OrderStatus accepted 'expired', which RFC 8555 §7.1.6 does \
907 not define for orders.";
908 Test, Mismatch));
909 }
910 Ok(())
911 }
912
913 /// An authorisation the CA has already validated must parse as `Valid`;
914 /// this is the state a renewal order carries and the one the client used
915 /// to mishandle.
916 #[test]
917 fn test_parse_valid_authorization_from_cached_validation() -> Outcome<()> {
918 let body = br#"{
919 "status": "valid",
920 "expires": "2026-08-01T12:00:00Z",
921 "identifier": {"type":"dns","value":"example.com"},
922 "challenges": [
923 {
924 "type": "tls-alpn-01",
925 "status": "valid",
926 "url": "https://acme-v02.api.letsencrypt.org/acme/chall/1/c",
927 "token": "cccccccccccccccccccccccccccccccc",
928 "validated": "2026-07-01T12:00:00Z"
929 }
930 ]
931 }"#;
932 let authz: Authorization = res!(parse_json_response(body));
933 if res!(authz.typed_status()) != AuthorizationStatus::Valid {
934 return Err(err!(
935 "A cached-validation authorisation parsed as {:?}.",
936 authz.status;
937 Test, Mismatch));
938 }
939 let chall = match res!(authz.tls_alpn_01_challenge()) {
940 Some(c) => c,
941 None => return Err(err!(
942 "tls_alpn_01_challenge returned None."; Test, Missing)),
943 };
944 if res!(chall.typed_status()) != ChallengeStatus::Valid {
945 return Err(err!(
946 "The challenge on a valid authorisation parsed as {:?}.",
947 chall.status;
948 Test, Mismatch));
949 }
950 Ok(())
951 }
952
953 // ---- key authorisation and dns-01, pinned (RFC 8555 §8.1, §8.4) ------
954
955 /// **External oracle, RFC 8555 §8.1 and §8.4.** Chain the fixed P-256
956 /// account key all the way through to the two values a CA actually
957 /// recomputes: the key authorisation and the dns-01 TXT record.
958 ///
959 /// Both expected values were derived outside this crate from the same key,
960 /// with `openssl` and `python3` (see [`crate::acme::jose::TEST_P256_PKCS8`]
961 /// for the key and its thumbprint derivation):
962 ///
963 /// ```text
964 /// thumbprint = rIV82OX7WtoQ9t9CvXXciOOey0zuRuaonj8p-bQghoA
965 /// key_auth = <token> "." <thumbprint> (§8.1)
966 /// txt = base64url(SHA-256(key_auth)) (§8.4)
967 /// ```
968 ///
969 /// The §8.4 value is the prehash shape that silently broke the ed25519
970 /// DKIM signer: the digest is taken over the key authorisation string and
971 /// the *digest* is what gets encoded -- not the string, and not the raw
972 /// thumbprint.
973 #[test]
974 fn test_key_authorization_and_dns01_against_external_oracle() -> Outcome<()> {
975 let signer = res!(jose::JwsSigner::from_pkcs8(&jose::TEST_P256_PKCS8));
976 let thumbprint = res!(signer.jwk_thumbprint_sha256());
977
978 let chall = Challenge {
979 typ: "dns-01".to_string(),
980 status: "pending".to_string(),
981 url: "https://example.test/chall/1".to_string(),
982 token: "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA".to_string(),
983 validated: String::new(),
984 error: Dat::Empty,
985 };
986
987 let expected_key_auth = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA.\
988 rIV82OX7WtoQ9t9CvXXciOOey0zuRuaonj8p-bQghoA";
989 let key_auth = chall.key_authorization(&thumbprint);
990 if key_auth != expected_key_auth {
991 return Err(err!(
992 "RFC 8555 §8.1 key authorisation was {:?}, externally-derived \
993 value is {:?}.", key_auth, expected_key_auth;
994 Test, Mismatch));
995 }
996
997 let expected_txt = "XS-wSC2L4p8YkHvL-3QvDUnrIrgSwtrSxnq3xi_9R7U";
998 let txt = chall.dns_01_txt_value(&thumbprint);
999 if txt != expected_txt {
1000 return Err(err!(
1001 "RFC 8555 §8.4 dns-01 TXT value was {:?}, externally-derived \
1002 value is {:?}.", txt, expected_txt;
1003 Test, Mismatch));
1004 }
1005 Ok(())
1006 }
1007
1008 /// `Challenge::key_authorization` must produce `<token>.<b64-thumbprint>`
1009 /// as specified by RFC 8555 §8.1.
1010 #[test]
1011 fn test_challenge_key_authorization() -> Outcome<()> {
1012 let chall = Challenge {
1013 typ: "tls-alpn-01".to_string(),
1014 status: "pending".to_string(),
1015 url: "https://example.test/chall/1".to_string(),
1016 token: "TokenVal".to_string(),
1017 validated: String::new(),
1018 error: Dat::Empty,
1019 };
1020 // Thumbprint here is arbitrary for the test; what matters is the
1021 // joining format.
1022 let thumbprint: [u8; 32] = [0u8; 32];
1023 let ka = chall.key_authorization(&thumbprint);
1024 // The all-zero thumbprint encodes to 43 `A` characters unpadded.
1025 let expected_tail = jose::base64url_encode(&thumbprint);
1026 let expected = fmt!("TokenVal.{}", expected_tail);
1027 if ka != expected {
1028 return Err(err!(
1029 "key_authorization = {:?}, expected {:?}.", ka, expected;
1030 Test, Mismatch));
1031 }
1032 Ok(())
1033 }
1034}