Oregami
Repositories/oxedyne/fe2o3

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
35use oxedyne_fe2o3_core::prelude::*;
36
37use rcgen::{
38 Certificate,
39 CertificateParams,
40 CustomExtension,
41};
42use 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)]
55pub 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.
67pub 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`].
109fn 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)]
124mod 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}