oxedyne/fe2o3/fe2o3_net/src/acme/challenge.rs
10.6 KiB, 30 runs
created by r1870400018:9517, 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 | //! TLS-ALPN-01 challenge cert generation (RFC 8737). |
| 2 | //! |
| 3 | //! When the ACME client has to satisfy a `tls-alpn-01` challenge for a given |
| 4 | //! hostname, it must be able to answer an incoming TLS handshake from the CA |
| 5 | //! with a self-signed certificate that: |
| 6 | //! |
| 7 | //! - Lists the hostname as its single subject alternative name. |
| 8 | //! - Carries a critical extension with OID `1.3.6.1.5.5.7.1.31` |
| 9 | //! (id-pe-acmeIdentifier) whose content is an ASN.1 `OCTET STRING` holding |
| 10 | //! the SHA-256 of the ACME key authorisation string |
| 11 | //! (`<challenge-token>.<account-JWK-thumbprint>`). RFC 8737 §3. |
| 12 | //! |
| 13 | //! This module hands both the certificate DER bytes and the matching PKCS#8 |
| 14 | //! private key DER bytes back to the caller as plain `Vec<u8>` values. It |
| 15 | //! deliberately does not construct a `rustls::sign::CertifiedKey` itself -- |
| 16 | //! the rustls-side wrapping lives in the cert resolver module that runs |
| 17 | //! inside Steel's accept path, keeping `fe2o3_net::acme::challenge` free of |
| 18 | //! rustls types and therefore testable without pulling rustls into the |
| 19 | //! test loop. |
| 20 | //! |
| 21 | //! The cert is produced with `rcgen` using P-256 (the default). The |
| 22 | //! [`CustomExtension::new_acme_identifier`] helper inside `rcgen` already |
| 23 | //! encodes the OID, the `OCTET STRING` wrapper and the `critical = true` |
| 24 | //! flag, so we only have to supply the 32-byte SHA-256 digest. |
| 25 | //! |
| 26 | //! Validity period is left at the `rcgen` default (a multi-century span). |
| 27 | //! The CA never inspects `notBefore` / `notAfter` on a challenge cert -- |
| 28 | //! it just executes the TLS handshake, checks the `acmeIdentifier` |
| 29 | //! extension, tears down and moves on -- and the resulting artefact is |
| 30 | //! never persisted to disk. |
| 31 | //! |
| 32 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 33 | //! Anthropic Claude |
| 34 | |
| 35 | use oxedyne_fe2o3_core::prelude::*; |
| 36 | |
| 37 | use rcgen::{ |
| 38 | Certificate, |
| 39 | CertificateParams, |
| 40 | CustomExtension, |
| 41 | }; |
| 42 | use ring::digest::{ |
| 43 | Context, |
| 44 | SHA256, |
| 45 | }; |
| 46 | |
| 47 | |
| 48 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 49 | // │ CHALLENGE CERT │ |
| 50 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 51 | |
| 52 | /// A self-signed cert plus its private key, both DER-encoded, ready to hand |
| 53 | /// to a rustls cert resolver when the CA opens a `tls-alpn-01` handshake. |
| 54 | #[derive(Clone, Debug)] |
| 55 | pub struct ChallengeCert { |
| 56 | pub cert_der: Vec<u8>, // self-signed, one entry in the chain |
| 57 | pub key_der: Vec<u8>, // PKCS#8 |
| 58 | } |
| 59 | |
| 60 | /// `hostname` must match the SNI the CA sends on its validation handshake, and |
| 61 | /// becomes the cert's single `dNSName` subject alternative name. |
| 62 | /// |
| 63 | /// `key_authorization` is `<challenge-token>.<base64url(SHA-256(JWK))>`, as |
| 64 | /// [`crate::acme::rfc8555::Challenge::key_authorization`] builds it. RFC 8737 §3 |
| 65 | /// specifies the extension in terms of that string's SHA-256, so the string is |
| 66 | /// taken here rather than a digest already computed elsewhere. |
| 67 | pub fn build_tls_alpn_01_cert( |
| 68 | hostname: &str, |
| 69 | key_authorization: &str, |
| 70 | ) |
| 71 | -> Outcome<ChallengeCert> |
| 72 | { |
| 73 | let digest = sha256(key_authorization.as_bytes()); |
| 74 | let acme_ext = CustomExtension::new_acme_identifier(&digest); |
| 75 | |
| 76 | let mut params = CertificateParams::new(vec![hostname.to_string()]); |
| 77 | params.custom_extensions = vec![acme_ext]; |
| 78 | |
| 79 | let cert = match Certificate::from_params(params) { |
| 80 | Ok(c) => c, |
| 81 | Err(e) => return Err(err!(e, |
| 82 | "rcgen::Certificate::from_params failed while building a \ |
| 83 | tls-alpn-01 challenge cert for {:?}.", hostname; |
| 84 | Init, Invalid)), |
| 85 | }; |
| 86 | |
| 87 | let cert_der = match cert.serialize_der() { |
| 88 | Ok(b) => b, |
| 89 | Err(e) => return Err(err!(e, |
| 90 | "rcgen::Certificate::serialize_der failed while serialising a \ |
| 91 | tls-alpn-01 challenge cert for {:?}.", hostname; |
| 92 | Init, Invalid)), |
| 93 | }; |
| 94 | let key_der = cert.serialize_private_key_der(); |
| 95 | |
| 96 | Ok(ChallengeCert { |
| 97 | cert_der, |
| 98 | key_der, |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | |
| 103 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 104 | // │ HELPERS │ |
| 105 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 106 | |
| 107 | /// Local to this module so `challenge` stays independent of |
| 108 | /// [`crate::acme::jose`]. |
| 109 | fn sha256(data: &[u8]) -> [u8; 32] { |
| 110 | let mut ctx = Context::new(&SHA256); |
| 111 | ctx.update(data); |
| 112 | let digest = ctx.finish(); |
| 113 | let mut out = [0u8; 32]; |
| 114 | out.copy_from_slice(digest.as_ref()); |
| 115 | out |
| 116 | } |
| 117 | |
| 118 | |
| 119 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 120 | // │ TESTS │ |
| 121 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 122 | |
| 123 | #[cfg(test)] |
| 124 | mod tests { |
| 125 | use super::*; |
| 126 | |
| 127 | use crate::acme::rfc8555::Challenge; |
| 128 | use oxedyne_fe2o3_jdat::prelude::*; |
| 129 | |
| 130 | // OID for id-pe-acmeIdentifier, RFC 8737 §3: the component sequence |
| 131 | // 1.3.6.1.5.5.7.1.31, which in DER is `06` OBJECT IDENTIFIER tag, `08` |
| 132 | // length, `2B` = 40*1 + 3, then 6, 1, 5, 5, 7, 1, 31 as base-128 varints. |
| 133 | const ACME_OID_DER: [u8; 10] = [ |
| 134 | 0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x01, 0x1f, |
| 135 | ]; |
| 136 | |
| 137 | /// Build a challenge cert from a plausible key authorisation string |
| 138 | /// and verify we got non-empty DER for both the cert and the key. |
| 139 | #[test] |
| 140 | fn test_build_challenge_cert_returns_non_empty_der() -> Outcome<()> { |
| 141 | let cc = res!(build_tls_alpn_01_cert( |
| 142 | "example.com", |
| 143 | "token.thumbprint", |
| 144 | )); |
| 145 | if cc.cert_der.is_empty() { |
| 146 | return Err(err!("cert_der was empty"; Test, Mismatch)); |
| 147 | } |
| 148 | if cc.key_der.is_empty() { |
| 149 | return Err(err!("key_der was empty"; Test, Mismatch)); |
| 150 | } |
| 151 | Ok(()) |
| 152 | } |
| 153 | |
| 154 | /// The generated cert must carry the id-pe-acmeIdentifier OID in its |
| 155 | /// DER bytes. The most robust check that does not depend on a full |
| 156 | /// ASN.1 parser is a byte-subsequence search for the OID's DER |
| 157 | /// encoding -- if the extension is present at all, this sequence |
| 158 | /// appears verbatim. |
| 159 | #[test] |
| 160 | fn test_challenge_cert_contains_acme_oid() -> Outcome<()> { |
| 161 | let cc = res!(build_tls_alpn_01_cert( |
| 162 | "example.com", |
| 163 | "some.keyauth", |
| 164 | )); |
| 165 | let found = cc.cert_der |
| 166 | .windows(ACME_OID_DER.len()) |
| 167 | .any(|w| w == ACME_OID_DER); |
| 168 | if !found { |
| 169 | return Err(err!( |
| 170 | "Challenge cert DER does not contain the id-pe-acmeIdentifier \ |
| 171 | OID sequence {:02x?}.", ACME_OID_DER; |
| 172 | Test, Missing)); |
| 173 | } |
| 174 | Ok(()) |
| 175 | } |
| 176 | |
| 177 | /// The 32-byte digest inside the `acmeIdentifier` extension must match |
| 178 | /// `SHA-256(key_authorization)` byte-for-byte. We verify this directly |
| 179 | /// by scanning the DER output for the digest bytes as a subsequence. |
| 180 | /// If this assertion ever breaks it means either the extension is not |
| 181 | /// being written or the digest we're computing diverged from the one |
| 182 | /// rcgen embedded. |
| 183 | #[test] |
| 184 | fn test_challenge_cert_embeds_correct_digest() -> Outcome<()> { |
| 185 | let key_auth = "abcdefghijklmnop.qrstuvwxyz0123456789AB"; |
| 186 | let cc = res!(build_tls_alpn_01_cert("example.test", key_auth)); |
| 187 | let digest = sha256(key_auth.as_bytes()); |
| 188 | let found = cc.cert_der |
| 189 | .windows(digest.len()) |
| 190 | .any(|w| w == digest); |
| 191 | if !found { |
| 192 | return Err(err!( |
| 193 | "Challenge cert DER does not contain the expected SHA-256 \ |
| 194 | digest of the key authorisation."; |
| 195 | Test, Missing)); |
| 196 | } |
| 197 | Ok(()) |
| 198 | } |
| 199 | |
| 200 | /// The hostname must appear in the cert (it is placed as a dNSName SAN). |
| 201 | /// Simple substring check over the DER bytes -- the name is encoded as |
| 202 | /// IA5String so it appears verbatim. |
| 203 | #[test] |
| 204 | fn test_challenge_cert_contains_hostname() -> Outcome<()> { |
| 205 | let host = "example.com"; |
| 206 | let cc = res!(build_tls_alpn_01_cert(host, "tok.auth")); |
| 207 | let needle = host.as_bytes(); |
| 208 | let found = cc.cert_der |
| 209 | .windows(needle.len()) |
| 210 | .any(|w| w == needle); |
| 211 | if !found { |
| 212 | return Err(err!( |
| 213 | "Challenge cert DER does not contain the requested \ |
| 214 | hostname {:?} as a subject alternative name.", host; |
| 215 | Test, Missing)); |
| 216 | } |
| 217 | Ok(()) |
| 218 | } |
| 219 | |
| 220 | /// End-to-end integration: given a typed RFC 8555 `Challenge` and a |
| 221 | /// faked JWK thumbprint, use `Challenge::key_authorization` to build |
| 222 | /// the authorisation string, hand it to `build_tls_alpn_01_cert`, and |
| 223 | /// verify the digest shows up inside the resulting cert. This exercises |
| 224 | /// the full data flow the ACME client will drive at runtime. |
| 225 | #[test] |
| 226 | fn test_integration_challenge_to_cert() -> Outcome<()> { |
| 227 | let chall = Challenge { |
| 228 | typ: "tls-alpn-01".to_string(), |
| 229 | status: "pending".to_string(), |
| 230 | url: "https://acme-v02.api.letsencrypt.org/acme/chall/1".to_string(), |
| 231 | token: "RealLiveToken".to_string(), |
| 232 | validated: String::new(), |
| 233 | error: Dat::Empty, |
| 234 | }; |
| 235 | let thumbprint = [0x11u8; 32]; |
| 236 | let key_auth = chall.key_authorization(&thumbprint); |
| 237 | let cc = res!(build_tls_alpn_01_cert("example.com", &key_auth)); |
| 238 | |
| 239 | let expected_digest = sha256(key_auth.as_bytes()); |
| 240 | let found = cc.cert_der |
| 241 | .windows(expected_digest.len()) |
| 242 | .any(|w| w == expected_digest); |
| 243 | if !found { |
| 244 | return Err(err!( |
| 245 | "Integration: cert DER does not contain SHA-256 of the \ |
| 246 | Challenge's key_authorization."; |
| 247 | Test, Missing)); |
| 248 | } |
| 249 | Ok(()) |
| 250 | } |
| 251 | } |