acme-proxy 0.3.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
//! A minimal outbound ACME client: the half of RFC 8555 this server does not
//! otherwise implement, since everywhere else it is the *server*.
//!
//! Written by hand on `hyper` rather than pulling in `instant-acme` or
//! `acme-lib`, for the same reason [`crate::challenge::http_01`] uses `hyper`
//! rather than `reqwest`: the whole stack (`hyper`, `hyper-util`, `rustls`,
//! `ring`, `base64`) is already in the tree, and what is needed here is a few
//! hundred lines of JWS assembly, not a framework.
//!
//! ## What this does not do
//!
//! No account key rollover, no `newAuthz` (pre-authorization), no order
//! listing — this client only drives the flow
//! [`super::RelaySigner`] needs: discover, register, order, answer, poll,
//! finalize, download, revoke, and ARI.
//!
//! ## TLS
//!
//! Unlike [`crate::challenge::tls_alpn_01`], which deliberately accepts any
//! server certificate because the certificate is the *proof* rather than an
//! identity, this client validates the upstream normally against the webpki
//! root store. Here the certificate is the only thing establishing that the CA
//! being handed CSRs is the intended one.

use std::sync::Arc;
use std::time::Duration;

use base64::prelude::*;
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::Bytes;
use hyper::header::{HeaderValue, LOCATION, RETRY_AFTER};
use hyper::{Method, Request, StatusCode};
use ring::rand::SystemRandom;
use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair};
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::debug;
use url::Url;

/// The most an upstream response body may be. Generous next to what any ACME
/// resource actually is — the largest is a certificate chain, a few kilobytes —
/// and there purely so a remote CA cannot decide how much memory this process
/// spends on one reply.
use crate::http_client::{MAX_RESPONSE_BYTES, error_excerpt};

/// Everything that can go wrong talking to the upstream. Mapped to
/// [`SignerError`](crate::signer::SignerError) at the trait boundary in
/// [`super`]; kept separate here so this module never mentions `error.rs`,
/// the same split `challenge` and `filter` draw.
#[derive(Debug, thiserror::Error)]
pub enum UpstreamError {
    /// The URL was unusable, or named a scheme/host this client cannot reach.
    #[error("upstream URL invalid: {0}")]
    Url(String),
    /// TCP/TLS/HTTP transport failure.
    #[error("upstream transport failed: {0}")]
    Transport(String),
    /// A response body was not the JSON this client expected.
    #[error("upstream protocol error: {0}")]
    Protocol(String),
    /// The upstream answered with an ACME problem document.
    #[error("upstream returned {status} {typ}: {detail}")]
    Problem {
        status: u16,
        typ: String,
        detail: String,
    },
    /// Signing the outgoing JWS failed (a local key problem).
    #[error("outbound JWS signing failed: {0}")]
    Jws(String),
}

impl UpstreamError {
    /// Whether this is the upstream rejecting the relayed CSR itself, as
    /// opposed to any other failure. That one case is the client's fault, not
    /// this server's, so it must surface as `badCSR` and leave the local order
    /// retryable rather than terminally invalid.
    pub fn is_bad_csr(&self) -> bool {
        matches!(self, UpstreamError::Problem { typ, .. } if typ.ends_with(":badCSR"))
    }

    /// Whether the upstream is telling us the certificate is already revoked.
    /// [`SignerBackend::revoke`](crate::signer::SignerBackend::revoke) is
    /// contractually idempotent, so this reads as success.
    pub fn is_already_revoked(&self) -> bool {
        matches!(self, UpstreamError::Problem { typ, .. } if typ.ends_with(":alreadyRevoked"))
    }

    /// Whether the upstream is refusing to create an account without an
    /// External Account Binding. Distinguished from any other refusal because
    /// it is fixable by a specific operator action, and the message can say so.
    pub fn is_external_account_required(&self) -> bool {
        matches!(self, UpstreamError::Problem { typ, .. } if typ.ends_with(":externalAccountRequired"))
    }

    /// Whether the nonce was stale — the one error worth retrying blindly,
    /// since RFC 8555 §6.5 makes it a normal part of the protocol rather than
    /// a real failure.
    fn is_bad_nonce(&self) -> bool {
        matches!(self, UpstreamError::Problem { typ, .. } if typ.ends_with(":badNonce"))
    }
}

/// The subset of the upstream's directory this client uses. Every member is
/// optional in RFC 8555 except in practice; `renewalInfo` genuinely is (it is
/// RFC 9773, which an upstream may predate).
#[derive(Debug, Clone, Deserialize)]
pub struct Directory {
    #[serde(rename = "newNonce")]
    pub new_nonce: String,
    #[serde(rename = "newAccount")]
    pub new_account: String,
    #[serde(rename = "newOrder")]
    pub new_order: String,
    #[serde(rename = "revokeCert")]
    pub revoke_cert: Option<String>,
    #[serde(rename = "renewalInfo")]
    pub renewal_info: Option<String>,
}

/// One HTTP response, reduced to what the ACME flow reads off it.
#[derive(Debug)]
pub struct AcmeResponse {
    pub status: StatusCode,
    pub body: Bytes,
    pub location: Option<String>,
    pub retry_after: Option<u64>,
    pub nonce: Option<String>,
}

impl AcmeResponse {
    /// Deserializes the body as JSON.
    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, UpstreamError> {
        serde_json::from_slice(&self.body).map_err(|error| {
            UpstreamError::Protocol(format!("response was not the expected JSON: {error}"))
        })
    }

    /// The body as UTF-8 text (the PEM chain, for the certificate endpoint).
    pub fn text(&self) -> Result<String, UpstreamError> {
        String::from_utf8(self.body.to_vec())
            .map_err(|_| UpstreamError::Protocol("response body was not UTF-8".to_string()))
    }
}

/// The account key this proxy holds at the upstream. `ring`'s `EcdsaKeyPair`
/// cannot be cloned or serialized back out, so the PKCS#8 document is kept
/// alongside it — that is what gets written to disk on first provisioning.
pub struct AccountKey {
    pair: EcdsaKeyPair,
    rng: SystemRandom,
    /// DER SubjectPublicKeyInfo, so [`crate::extractors::jwk_thumbprint`] can
    /// be reused rather than reimplementing RFC 7638 here.
    spki_der: Vec<u8>,
}

impl AccountKey {
    /// Wraps a PKCS#8 P-256 document.
    pub fn from_pkcs8(pkcs8: &[u8]) -> Result<Self, UpstreamError> {
        let rng = SystemRandom::new();
        let pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8, &rng)
            .map_err(|error| UpstreamError::Jws(format!("account key unusable: {error}")))?;
        let spki_der = spki_from_p256_public(pair.public_key().as_ref())?;
        Ok(Self {
            pair,
            rng,
            spki_der,
        })
    }

    /// The public key as a JWK, the form `newAccount` embeds and the EAB inner
    /// payload repeats.
    pub fn jwk(&self) -> Value {
        // ring hands back the uncompressed SEC1 point: 0x04 || X(32) || Y(32).
        let point = self.pair.public_key().as_ref();
        json!({
            "crv": "P-256",
            "kty": "EC",
            "x": BASE64_URL_SAFE_NO_PAD.encode(&point[1..33]),
            "y": BASE64_URL_SAFE_NO_PAD.encode(&point[33..65]),
        })
    }

    /// DER SPKI, for [`crate::extractors::jwk_thumbprint`].
    pub fn spki_der(&self) -> &[u8] {
        &self.spki_der
    }

    fn sign(&self, input: &[u8]) -> Result<Vec<u8>, UpstreamError> {
        self.pair
            .sign(&self.rng, input)
            .map(|sig| sig.as_ref().to_vec())
            .map_err(|error| UpstreamError::Jws(format!("signing failed: {error}")))
    }
}

/// Wraps a raw SEC1 P-256 point in a DER SubjectPublicKeyInfo.
///
/// Hand-rolled rather than via `simple_asn1` because every field is fixed for
/// this one curve: the whole prefix is a constant, and only the 65-byte point
/// varies. `src/extractors/signature.rs` builds the same structure the general
/// way, for keys whose parameters are not known in advance.
fn spki_from_p256_public(point: &[u8]) -> Result<Vec<u8>, UpstreamError> {
    if point.len() != 65 || point[0] != 0x04 {
        return Err(UpstreamError::Jws(
            "P-256 public key was not an uncompressed 65-byte point".to_string(),
        ));
    }
    // SEQUENCE { SEQUENCE { id-ecPublicKey, prime256v1 }, BIT STRING(point) }
    const PREFIX: &[u8] = &[
        0x30, 0x59, // SEQUENCE, 89 bytes
        0x30, 0x13, // SEQUENCE, 19 bytes (AlgorithmIdentifier)
        0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, // OID id-ecPublicKey (7 bytes)
        0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01,
        0x07, // OID prime256v1 (8 bytes)
        0x03, 0x42, 0x00, // BIT STRING, 66 bytes, 0 unused bits
    ];
    let mut der = Vec::with_capacity(PREFIX.len() + point.len());
    der.extend_from_slice(PREFIX);
    der.extend_from_slice(point);
    Ok(der)
}

/// How the outgoing JWS names the key: an embedded `jwk` (only `newAccount`
/// may use this, RFC 8555 §6.2) or the account `kid` (everything else).
pub enum Signer<'a> {
    Jwk,
    Kid(&'a str),
}

/// The outbound ACME client. Holds the discovered directory and the TLS
/// config; the account key is passed per-request so the same client can serve
/// registration (before a `kid` exists) and normal operation.
impl std::fmt::Debug for AcmeClient {
    /// `dyn Resolver` is not `Debug`; the directory is what identifies this
    /// client anyway.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("AcmeClient")
            .field("directory", &self.directory)
            .field("timeout", &self.timeout)
            .finish()
    }
}

pub struct AcmeClient {
    directory: Directory,
    tls: Arc<rustls::ClientConfig>,
    /// Where every outbound hop resolves and whether it goes through a
    /// proxy — `dns.resolver` and `[proxy]`, bundled.
    outbound: crate::http_client::Outbound,
    timeout: Duration,
}

impl AcmeClient {
    /// Fetches the upstream directory. This is the one network call made
    /// before anything is signed, so it doubles as the reachability check that
    /// makes a misconfigured `directory_url` a startup failure.
    pub async fn discover(
        directory_url: &str,
        outbound: crate::http_client::Outbound,
        timeout: Duration,
    ) -> Result<Self, UpstreamError> {
        let tls = Arc::new(crate::http_client::webpki_tls_config());
        let url = Url::parse(directory_url)
            .map_err(|error| UpstreamError::Url(format!("{directory_url}: {error}")))?;
        let response = request(&tls, &outbound, Method::GET, &url, None, timeout).await?;
        if !response.status.is_success() {
            return Err(problem_from(&response));
        }
        let directory: Directory = response.json()?;
        debug!(event = "upstream_directory_discovered", outcome = "success", upstream_url = %directory_url);
        Ok(Self {
            directory,
            outbound,
            tls,
            timeout,
        })
    }

    pub fn directory(&self) -> &Directory {
        &self.directory
    }

    /// A fresh nonce (RFC 8555 §7.2). Fetched before every signed request
    /// rather than cached: a nonce is single-use, and a stale one costs a
    /// round-trip to discover anyway.
    async fn nonce(&self) -> Result<String, UpstreamError> {
        let url = self.parse(&self.directory.new_nonce)?;
        let response = request(
            &self.tls,
            &self.outbound,
            Method::HEAD,
            &url,
            None,
            self.timeout,
        )
        .await?;
        response.nonce.ok_or_else(|| {
            UpstreamError::Protocol("newNonce response carried no Replay-Nonce".to_string())
        })
    }

    fn parse(&self, url: &str) -> Result<Url, UpstreamError> {
        Url::parse(url).map_err(|error| UpstreamError::Url(format!("{url}: {error}")))
    }

    /// A signed POST (RFC 8555 §6.2). `payload` of `None` is the POST-as-GET
    /// form (§6.3), whose payload segment is the empty string rather than
    /// `null` or `{}`.
    ///
    /// Retries once on `badNonce`: §6.5 makes a rejected nonce an ordinary
    /// event, and the retry is what keeps it from surfacing as a real failure.
    pub async fn post(
        &self,
        key: &AccountKey,
        signer: &Signer<'_>,
        url: &str,
        payload: Option<&Value>,
    ) -> Result<AcmeResponse, UpstreamError> {
        match self.post_once(key, signer, url, payload).await {
            Err(error) if error.is_bad_nonce() => {
                debug!(event = "upstream_bad_nonce_retry", outcome = "progress", upstream_url = %url);
                self.post_once(key, signer, url, payload).await
            }
            other => other,
        }
    }

    async fn post_once(
        &self,
        key: &AccountKey,
        signer: &Signer<'_>,
        url: &str,
        payload: Option<&Value>,
    ) -> Result<AcmeResponse, UpstreamError> {
        let nonce = self.nonce().await?;
        let parsed = self.parse(url)?;

        let protected = match signer {
            Signer::Jwk => json!({
                "alg": "ES256", "jwk": key.jwk(), "nonce": nonce, "url": url,
            }),
            Signer::Kid(kid) => json!({
                "alg": "ES256", "kid": kid, "nonce": nonce, "url": url,
            }),
        };
        let protected_b64 = BASE64_URL_SAFE_NO_PAD.encode(
            serde_json::to_vec(&protected)
                .map_err(|error| UpstreamError::Jws(error.to_string()))?,
        );
        // POST-as-GET signs over an *empty* payload segment, not "{}".
        let payload_b64 = match payload {
            Some(value) => BASE64_URL_SAFE_NO_PAD.encode(
                serde_json::to_vec(value).map_err(|error| UpstreamError::Jws(error.to_string()))?,
            ),
            None => String::new(),
        };
        let signature = key.sign(format!("{protected_b64}.{payload_b64}").as_bytes())?;

        let body = json!({
            "protected": protected_b64,
            "payload": payload_b64,
            "signature": BASE64_URL_SAFE_NO_PAD.encode(signature),
        });
        let body =
            serde_json::to_vec(&body).map_err(|error| UpstreamError::Jws(error.to_string()))?;

        let response = request(
            &self.tls,
            &self.outbound,
            Method::POST,
            &parsed,
            Some(Bytes::from(body)),
            self.timeout,
        )
        .await?;

        if response.status.is_success() {
            Ok(response)
        } else {
            Err(problem_from(&response))
        }
    }

    /// A POST-as-GET read of `url` (RFC 8555 §6.3).
    pub async fn get(
        &self,
        key: &AccountKey,
        kid: &str,
        url: &str,
    ) -> Result<AcmeResponse, UpstreamError> {
        self.post(key, &Signer::Kid(kid), url, None).await
    }

    /// An unauthenticated GET, for the one endpoint that takes no JWS:
    /// `renewalInfo` (RFC 9773 §4.1).
    pub async fn get_unsigned(&self, url: &str) -> Result<AcmeResponse, UpstreamError> {
        let parsed = self.parse(url)?;
        let response = request(
            &self.tls,
            &self.outbound,
            Method::GET,
            &parsed,
            None,
            self.timeout,
        )
        .await?;
        if response.status.is_success() {
            Ok(response)
        } else {
            Err(problem_from(&response))
        }
    }
}

/// Builds an [`UpstreamError::Problem`] from a non-2xx response, falling back
/// to the raw body when it is not a problem document.
fn problem_from(response: &AcmeResponse) -> UpstreamError {
    #[derive(Deserialize)]
    struct ProblemDoc {
        #[serde(rename = "type")]
        typ: Option<String>,
        detail: Option<String>,
    }
    let status = response.status.as_u16();
    match serde_json::from_slice::<ProblemDoc>(&response.body) {
        Ok(doc) => UpstreamError::Problem {
            status,
            typ: doc.typ.unwrap_or_else(|| "about:blank".to_string()),
            detail: doc.detail.unwrap_or_default(),
        },
        Err(_) => UpstreamError::Problem {
            status,
            typ: "about:blank".to_string(),
            detail: error_excerpt(&response.body),
        },
    }
}

/// One HTTP request over TCP or TLS, under `timeout`.
async fn request(
    tls: &Arc<rustls::ClientConfig>,
    outbound: &crate::http_client::Outbound,
    method: Method,
    url: &Url,
    body: Option<Bytes>,
    timeout: Duration,
) -> Result<AcmeResponse, UpstreamError> {
    tokio::time::timeout(timeout, request_inner(tls, outbound, method, url, body))
        .await
        .map_err(|_| UpstreamError::Transport(format!("timed out after {timeout:?}")))?
}

async fn request_inner(
    tls: &Arc<rustls::ClientConfig>,
    outbound: &crate::http_client::Outbound,
    method: Method,
    url: &Url,
    body: Option<Bytes>,
) -> Result<AcmeResponse, UpstreamError> {
    let endpoint = crate::http_client::Endpoint::from_url(url).map_err(UpstreamError::Url)?;

    let connection = outbound
        .connect(&endpoint, tls)
        .await
        .map_err(UpstreamError::Transport)?;

    let has_body = body.is_some();
    // Origin-form directly, absolute-form when a proxy forwards this hop.
    let request = Request::builder()
        .method(method)
        .uri(connection.request_target(url))
        .header(hyper::header::HOST, endpoint.authority())
        .header(hyper::header::USER_AGENT, "acme-proxy")
        .header(
            hyper::header::CONTENT_TYPE,
            if has_body {
                "application/jose+json"
            } else {
                "application/json"
            },
        )
        .body(Full::new(body.unwrap_or_default()))
        .map_err(|error| UpstreamError::Transport(error.to_string()))?;

    send(connection, request).await
}

async fn send(
    mut connection: crate::http_client::Connection<Full<Bytes>>,
    request: Request<Full<Bytes>>,
) -> Result<AcmeResponse, UpstreamError> {
    let response = connection
        .send_request(request)
        .await
        .map_err(|error| UpstreamError::Transport(error.to_string()))?;

    let status = response.status();
    let location = header_string(response.headers().get(LOCATION));
    let nonce = header_string(response.headers().get("replay-nonce"));
    let retry_after = header_string(response.headers().get(RETRY_AFTER))
        .and_then(|value| value.trim().parse::<u64>().ok());

    // Capped, like every other outbound client here (`challenge::http_01`,
    // `filter::netbox::client`). The upstream is a remote CA reached over the
    // network; how much it chooses to send back is not this process's memory
    // to spend. A certificate chain is kilobytes, so the ceiling only ever
    // trips on something that has already gone wrong.
    let body = Limited::new(response.into_body(), MAX_RESPONSE_BYTES)
        .collect()
        .await
        .map_err(|error| UpstreamError::Transport(format!("response body: {error}")))?
        .to_bytes();

    Ok(AcmeResponse {
        status,
        body,
        location,
        retry_after,
        nonce,
    })
}

fn header_string(value: Option<&HeaderValue>) -> Option<String> {
    value
        .and_then(|value| value.to_str().ok())
        .map(str::to_string)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The shared resolver `Profile::build_all` supplies at startup. These
    /// tests reach loopback by IP literal, which `dns::connect` short-circuits.
    fn test_resolver() -> std::sync::Arc<dyn crate::dns::Resolver> {
        std::sync::Arc::new(crate::dns::HickoryResolver::from_system_uncached().unwrap())
    }
    use crate::signer::relay::testsrv::{self, Script};

    /// A P-256 PKCS#8 document, via rcgen (already a normal dependency).
    fn pkcs8() -> Vec<u8> {
        rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
            .unwrap()
            .serialize_der()
    }

    fn key() -> AccountKey {
        AccountKey::from_pkcs8(&pkcs8()).unwrap()
    }

    const TIMEOUT: Duration = Duration::from_secs(5);

    #[test]
    fn an_account_key_exposes_a_jwk_and_a_matching_spki() {
        let key = key();
        let jwk = key.jwk();
        assert_eq!(jwk["kty"], "EC");
        assert_eq!(jwk["crv"], "P-256");

        // The SPKI must describe the *same* key as the JWK — the cheapest
        // proof being that the crate's own thumbprint helper accepts it and
        // agrees with a thumbprint computed from the JWK members directly.
        let thumbprint = crate::extractors::acme::jwk_thumbprint(key.spki_der()).unwrap();
        let canonical = format!(
            r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
            jwk["x"].as_str().unwrap(),
            jwk["y"].as_str().unwrap()
        );
        let expected = BASE64_URL_SAFE_NO_PAD
            .encode(ring::digest::digest(&ring::digest::SHA256, canonical.as_bytes()).as_ref());
        assert_eq!(thumbprint, expected);
    }

    #[test]
    fn a_non_p256_key_is_refused() {
        let ed25519 = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519)
            .unwrap()
            .serialize_der();
        assert!(matches!(
            AccountKey::from_pkcs8(&ed25519),
            Err(UpstreamError::Jws(_))
        ));
    }

    #[test]
    fn spki_encoding_rejects_a_malformed_point() {
        assert!(spki_from_p256_public(&[0x04, 0x01]).is_err());
        // A compressed point is well-formed EC but not what ring hands back.
        assert!(spki_from_p256_public(&[0x02; 65]).is_err());
    }

    #[test]
    fn upstream_errors_classify_the_three_cases_the_caller_branches_on() {
        let bad_csr = UpstreamError::Problem {
            status: 403,
            typ: "urn:ietf:params:acme:error:badCSR".to_string(),
            detail: String::new(),
        };
        assert!(bad_csr.is_bad_csr());
        assert!(!bad_csr.is_already_revoked());
        assert!(!bad_csr.is_bad_nonce());

        let revoked = UpstreamError::Problem {
            status: 400,
            typ: "urn:ietf:params:acme:error:alreadyRevoked".to_string(),
            detail: String::new(),
        };
        assert!(revoked.is_already_revoked());
        assert!(!revoked.is_bad_csr());

        let nonce = UpstreamError::Problem {
            status: 400,
            typ: "urn:ietf:params:acme:error:badNonce".to_string(),
            detail: String::new(),
        };
        assert!(nonce.is_bad_nonce());

        // A transport failure is none of them.
        let transport = UpstreamError::Transport("boom".to_string());
        assert!(!transport.is_bad_csr() && !transport.is_already_revoked());
    }

    /// Every variant must render something an operator can act on; the
    /// `Problem` one in particular has to keep the upstream's own wording.
    #[test]
    fn errors_display_their_detail() {
        assert!(UpstreamError::Url("bad".into()).to_string().contains("bad"));
        assert!(
            UpstreamError::Transport("refused".into())
                .to_string()
                .contains("refused")
        );
        assert!(
            UpstreamError::Protocol("garbage".into())
                .to_string()
                .contains("garbage")
        );
        assert!(
            UpstreamError::Jws("nope".into())
                .to_string()
                .contains("nope")
        );
        let rendered = UpstreamError::Problem {
            status: 429,
            typ: "urn:ietf:params:acme:error:rateLimited".to_string(),
            detail: "slow down".to_string(),
        }
        .to_string();
        assert!(
            rendered.contains("429") && rendered.contains("slow down"),
            "{rendered}"
        );
    }

    #[tokio::test]
    async fn discover_reads_the_directory() {
        let upstream = testsrv::start(Script::default()).await;
        let client = AcmeClient::discover(
            &upstream.directory_url(),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap();
        assert_eq!(
            client.directory().new_order,
            format!("{}/newOrder", upstream.base)
        );
        assert!(client.directory().revoke_cert.is_some());
        assert!(client.directory().renewal_info.is_some());
    }

    #[tokio::test]
    async fn discover_fails_on_an_unusable_url() {
        assert!(matches!(
            AcmeClient::discover(
                "not a url",
                crate::testutil::outbound_with(test_resolver()),
                TIMEOUT
            )
            .await,
            Err(UpstreamError::Url(_))
        ));
        assert!(matches!(
            AcmeClient::discover(
                "ftp://example.invalid/dir",
                crate::testutil::outbound_with(test_resolver()),
                TIMEOUT
            )
            .await,
            Err(UpstreamError::Url(_))
        ));
    }

    /// A closed port must surface as a transport error rather than hanging.
    #[tokio::test]
    async fn discover_fails_when_nothing_is_listening() {
        let port = {
            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
            listener.local_addr().unwrap().port()
        };
        let error = AcmeClient::discover(
            &format!("http://127.0.0.1:{port}/directory"),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap_err();
        assert!(matches!(error, UpstreamError::Transport(_)), "{error:?}");
    }

    /// Each signed POST must fetch its own nonce: they are single-use, so
    /// reusing one would fail every request after the first.
    #[tokio::test]
    async fn every_signed_post_fetches_a_fresh_nonce() {
        let upstream = testsrv::start(Script::default()).await;
        let client = AcmeClient::discover(
            &upstream.directory_url(),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap();
        let key = key();

        for _ in 0..3 {
            client
                .post(
                    &key,
                    &Signer::Jwk,
                    &client.directory().new_account.clone(),
                    Some(&json!({})),
                )
                .await
                .unwrap();
        }
        assert_eq!(upstream.nonce_fetches(), 3);
    }

    /// RFC 8555 §6.5 treats a rejected nonce as routine, so one retry must
    /// absorb it rather than surfacing a failure.
    #[tokio::test]
    async fn a_bad_nonce_is_retried_once() {
        let upstream = testsrv::start(Script {
            bad_nonce_once: true,
            ..Script::default()
        })
        .await;
        let client = AcmeClient::discover(
            &upstream.directory_url(),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap();

        let response = client
            .post(
                &key(),
                &Signer::Jwk,
                &client.directory().new_account.clone(),
                Some(&json!({})),
            )
            .await
            .expect("the retry must absorb a single badNonce");
        assert_eq!(response.status, 201);
        // Two nonces: the rejected one and the retry's.
        assert_eq!(upstream.nonce_fetches(), 2);
    }

    #[tokio::test]
    async fn a_created_response_carries_its_location() {
        let upstream = testsrv::start(Script::default()).await;
        let client = AcmeClient::discover(
            &upstream.directory_url(),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap();
        let response = client
            .post(
                &key(),
                &Signer::Jwk,
                &client.directory().new_account.clone(),
                Some(&json!({})),
            )
            .await
            .unwrap();
        assert_eq!(
            response.location.as_deref(),
            Some(format!("{}/acct/1", upstream.base).as_str())
        );
    }

    /// A POST-as-GET signs over an *empty* payload segment, not `{}` — the
    /// distinction RFC 8555 §6.3 draws, and one a server checks.
    #[tokio::test]
    async fn post_as_get_sends_an_empty_payload() {
        let upstream = testsrv::start(Script::default()).await;
        let client = AcmeClient::discover(
            &upstream.directory_url(),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap();
        let response = client
            .get(&key(), "kid-1", &format!("{}/order/1", upstream.base))
            .await
            .unwrap();
        assert_eq!(response.status, 200);
        assert_eq!(upstream.order_polls(), 1);
    }

    /// An upstream problem document must keep its `type` and `detail`, since
    /// those are what the caller branches on and what an operator reads.
    #[tokio::test]
    async fn an_error_response_becomes_a_problem() {
        let upstream = testsrv::start(Script::default()).await;
        let client = AcmeClient::discover(
            &upstream.directory_url(),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap();
        let error = client
            .get(&key(), "kid-1", &format!("{}/nope", upstream.base))
            .await
            .unwrap_err();
        match error {
            UpstreamError::Problem { status, typ, .. } => {
                assert_eq!(status, 404);
                assert!(typ.ends_with(":malformed"), "{typ}");
            }
            other => panic!("expected a problem document, got {other:?}"),
        }
    }

    /// A non-JSON error body must still produce a usable error rather than a
    /// parse failure that loses the status entirely.
    #[test]
    fn a_non_problem_error_body_still_carries_the_status() {
        let response = AcmeResponse {
            status: StatusCode::BAD_GATEWAY,
            body: Bytes::from_static(b"<html>proxy error</html>"),
            location: None,
            retry_after: None,
            nonce: None,
        };
        match problem_from(&response) {
            UpstreamError::Problem {
                status,
                typ,
                detail,
            } => {
                assert_eq!(status, 502);
                assert_eq!(typ, "about:blank");
                assert!(detail.contains("proxy error"), "{detail}");
            }
            other => panic!("expected a problem, got {other:?}"),
        }
    }

    #[test]
    fn response_helpers_decode_json_and_text() {
        let response = AcmeResponse {
            status: StatusCode::OK,
            body: Bytes::from_static(br#"{"status":"valid"}"#),
            location: None,
            retry_after: None,
            nonce: None,
        };
        let value: Value = response.json().unwrap();
        assert_eq!(value["status"], "valid");
        assert_eq!(response.text().unwrap(), r#"{"status":"valid"}"#);

        let invalid = AcmeResponse {
            status: StatusCode::OK,
            body: Bytes::from_static(b"not json"),
            location: None,
            retry_after: None,
            nonce: None,
        };
        assert!(invalid.json::<Value>().is_err());
        // Invalid UTF-8 must be reported, not silently replaced.
        let binary = AcmeResponse {
            status: StatusCode::OK,
            body: Bytes::from_static(&[0xff, 0xfe]),
            location: None,
            retry_after: None,
            nonce: None,
        };
        assert!(binary.text().is_err());
    }

    /// The whole point of the timeout: a server that accepts but never answers
    /// must not wedge the caller.
    #[tokio::test]
    async fn a_silent_server_times_out() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        tokio::spawn(async move {
            let (stream, _) = listener.accept().await.unwrap();
            // Hold the connection open, answering nothing.
            std::mem::forget(stream);
        });

        let error = AcmeClient::discover(
            &format!("http://127.0.0.1:{port}/directory"),
            crate::testutil::outbound_with(test_resolver()),
            Duration::from_millis(150),
        )
        .await
        .unwrap_err();
        assert!(
            matches!(&error, UpstreamError::Transport(detail) if detail.contains("timed out")),
            "{error:?}"
        );
    }

    #[tokio::test]
    async fn get_unsigned_reaches_an_endpoint_that_takes_no_jws() {
        let upstream = testsrv::start(Script::default()).await;
        let client = AcmeClient::discover(
            &upstream.directory_url(),
            crate::testutil::outbound_with(test_resolver()),
            TIMEOUT,
        )
        .await
        .unwrap();
        // The directory itself is the one unsigned GET target the fake serves.
        let response = client
            .get_unsigned(&upstream.directory_url())
            .await
            .unwrap();
        assert_eq!(response.status, 200);
        assert!(
            client
                .get_unsigned(&format!("{}/nope", upstream.base))
                .await
                .is_err()
        );
    }
}