acme_proxy_core/cert.rs
1//! Small X.509 helpers shared by the certificate revocation paths: the ACME
2//! `POST /revokeCert` handler (`handlers::post_revoke_cert`) and the `order
3//! revoke` admin CLI command (`admin::revoke_order`). Both need to
4//! pull a certificate's serial/public key out of raw DER, and pull the leaf
5//! back out of a stored `leaf + CA` PEM chain, so the parsing lives here once
6//! rather than twice.
7
8use base64::prelude::*;
9use x509_parser::nom;
10use x509_parser::pem::Pem;
11
12/// RFC 5280 §5.3.1 `CRLReason` codes RFC 8555 §7.6 permits in a revocation
13/// request's `reason` — every value except the reserved `7`. Shared with
14/// `rcgen::RevocationReason`'s own numbering (see
15/// `signer::local_ca`).
16pub const ALLOWED_REVOCATION_REASONS: [u32; 10] = [0, 1, 2, 3, 4, 5, 6, 8, 9, 10];
17
18/// Whether `reason` is one of the `CRLReason` codes RFC 8555 §7.6 permits.
19#[must_use]
20pub fn is_valid_revocation_reason(reason: u32) -> bool {
21 ALLOWED_REVOCATION_REASONS.contains(&reason)
22}
23
24/// The identity a local CA's revocation state is stored under: lowercase hex
25/// SHA-256 of the CA certificate's `SubjectPublicKeyInfo` DER.
26///
27/// The key rather than a path or a profile, because the key is what signs the
28/// CRL: two profiles over one CA are one issuer, and a CA whose files move is
29/// still the same one. The `revocations.issuer`/`crls.issuer` columns declare
30/// exactly this width (`VARCHAR(64)`), pinned by a test beside the migration
31/// runner.
32#[must_use]
33pub fn issuer_id(spki_der: &[u8]) -> String {
34 hex::encode(ring::digest::digest(&ring::digest::SHA256, spki_der).as_ref())
35}
36
37/// A certificate's `SubjectKeyIdentifier` extension, when it carries one.
38///
39/// What a CRL's `authorityKeyIdentifier` must equal: RFC 5280 §5.2.1 has a CRL
40/// name its issuer's key the same way the certificates that issuer signed do,
41/// and OpenSSL-style issuer matching refuses a CRL whose AKI does not match the
42/// CA certificate's own SKI. Deriving it instead — rcgen's default is a
43/// truncated SHA-256 of the SPKI — agrees only by luck, and never for a CA
44/// certificate made out of band (an operator's PEM, or the certificate beside a
45/// PKCS#11 key, which OpenSSL usually stamps with a SHA-1 identifier).
46pub fn subject_key_identifier(der: &[u8]) -> Option<Vec<u8>> {
47 let (_, cert) = x509_parser::parse_x509_certificate(der).ok()?;
48 cert.iter_extensions()
49 .find_map(|extension| match extension.parsed_extension() {
50 x509_parser::extensions::ParsedExtension::SubjectKeyIdentifier(id) => {
51 Some(id.0.to_vec())
52 }
53 _ => None,
54 })
55}
56
57/// Extracts a single certificate's serial (hex-encoded, no separators) and
58/// the DER encoding of its `SubjectPublicKeyInfo` from raw X.509 DER bytes.
59///
60/// The SPKI returned is the certificate's own `SEQUENCE { AlgorithmIdentifier,
61/// BIT STRING }` — the same canonical DER-SPKI shape
62/// `verify_signature_and_get_der` builds for `Account.pubkey`, so it can be
63/// compared byte-for-byte against a JWS-verified `pubkey` with no conversion.
64pub fn cert_serial_and_spki(
65 der: &[u8],
66) -> Result<(String, Vec<u8>), nom::Err<x509_parser::error::X509Error>> {
67 let (_, cert) = x509_parser::parse_x509_certificate(der)?;
68 Ok((
69 hex::encode(cert.tbs_certificate.raw_serial()),
70 cert.tbs_certificate.subject_pki.raw.to_vec(),
71 ))
72}
73
74/// Extracts the leaf certificate's DER bytes from a `leaf + CA` PEM chain
75/// (the shape `SignerBackend::issue` returns and `orders.certificate`
76/// stores) — the first `CERTIFICATE` block, mirroring
77/// [`crate::pemfile::read_certificates`]'s label-agnostic PEM iterator.
78pub fn leaf_der_from_chain(chain_pem: &str) -> anyhow::Result<Vec<u8>> {
79 match Pem::iter_from_buffer(chain_pem.as_bytes()).next() {
80 Some(result) => Ok(result?.contents),
81 None => anyhow::bail!("no CERTIFICATE block found in chain"),
82 }
83}
84
85/// The ACME Renewal Information certificate identifier (RFC 9773 §4.1) for a
86/// DER-encoded certificate: `base64url(AKI keyIdentifier) || "." ||
87/// base64url(serial)`, both unpadded.
88///
89/// Used in both directions: to ask an upstream CA about a certificate
90/// (`signer::relay`) and to check an inbound certID against the
91/// certificate it claims to name (`handlers::get_renewal_info`).
92///
93/// Fails when the certificate carries no Authority Key Identifier extension,
94/// or one with no `keyIdentifier` field — without it there is no certID to
95/// build, and guessing would produce one no one recognizes. Certificates this
96/// server issued before its local CA emitted an AKI are exactly that case, so
97/// the inbound side treats the failure as "cannot check" rather than "reject".
98pub fn ari_cert_id(der: &[u8]) -> anyhow::Result<String> {
99 let (aki, serial) = ari_cert_id_parts(der)?;
100 Ok(format!(
101 "{}.{}",
102 BASE64_URL_SAFE_NO_PAD.encode(aki),
103 BASE64_URL_SAFE_NO_PAD.encode(serial),
104 ))
105}
106
107/// The raw `(AKI keyIdentifier, serial)` bytes behind [`ari_cert_id`], for
108/// callers that want to compare the halves rather than the rendered string.
109pub fn ari_cert_id_parts(der: &[u8]) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
110 use x509_parser::extensions::ParsedExtension;
111 use x509_parser::oid_registry::OID_X509_EXT_AUTHORITY_KEY_IDENTIFIER;
112
113 let (_, cert) = x509_parser::parse_x509_certificate(der)?;
114 let extension = cert
115 .get_extension_unique(&OID_X509_EXT_AUTHORITY_KEY_IDENTIFIER)?
116 .ok_or_else(|| anyhow::anyhow!("certificate has no Authority Key Identifier extension"))?;
117
118 let ParsedExtension::AuthorityKeyIdentifier(aki) = extension.parsed_extension() else {
119 anyhow::bail!("Authority Key Identifier extension could not be parsed");
120 };
121 let key_identifier = aki
122 .key_identifier
123 .as_ref()
124 .ok_or_else(|| anyhow::anyhow!("Authority Key Identifier carries no keyIdentifier"))?;
125
126 Ok((
127 key_identifier.0.to_vec(),
128 cert.tbs_certificate.raw_serial().to_vec(),
129 ))
130}
131
132/// The two halves of an ACME Renewal Information certID (RFC 9773 §4.1),
133/// decoded: `base64url(AKI keyIdentifier) "." base64url(serial)`.
134///
135/// Parsed in one place because both RFC 9773 surfaces consume the same string:
136/// `GET /renewalInfo/{certID}` (§4.1) and a newOrder's `replaces` field (§5),
137/// which "is constructed in the same way as the path component for GET
138/// requests described in Section 4.1".
139#[derive(Debug, PartialEq, Eq)]
140pub struct AriCertId {
141 /// The issuer's Authority Key Identifier `keyIdentifier`, raw bytes.
142 pub aki: Vec<u8>,
143 /// The certificate serial, raw DER integer content bytes (no tag/length).
144 pub serial: Vec<u8>,
145}
146
147impl AriCertId {
148 /// The serial as this codebase stores it: lowercase hex, no separators —
149 /// what `orders.cert_serial` holds and `Order::find_by_cert_serial` takes.
150 #[must_use]
151 pub fn serial_hex(&self) -> String {
152 hex::encode(&self.serial)
153 }
154}
155
156/// Folds an operator-supplied certificate serial into the form the column
157/// holds: lowercase hex, no separators.
158///
159/// Every serial this server stores comes from [`AriCertId::serial_hex`], i.e.
160/// `hex::encode`, which is lowercase and unseparated. Operators do not arrive
161/// with that: `openssl x509 -serial` prints upper case, and an abuse report
162/// quotes whatever its own tooling printed, colons included. Bound raw, such a
163/// value matched nothing and the answer was a silent empty page — the failure
164/// mode `--status` and `--event` are refused *by name* to avoid, since "no
165/// rows" reads exactly like "nothing happened".
166///
167/// Applied at the operator entry points only (`order list --cert-serial`,
168/// `audit list --cert-serial` and their two `?certSerial=` twins), never in the
169/// models: `Order::find_by_cert_serial` is fed by `POST /revokeCert` from a
170/// parsed certificate, where the value is already canonical and widening what
171/// matches would be a change of answer wearing a refactor's clothes.
172///
173/// Only separators an operator would plausibly paste are stripped (`:`, `-`,
174/// whitespace). Anything else is left alone to match nothing, as it should.
175#[must_use]
176pub fn normalize_serial(value: &str) -> String {
177 value
178 .chars()
179 .filter(|character| !matches!(character, ':' | '-') && !character.is_whitespace())
180 .flat_map(char::to_lowercase)
181 .collect()
182}
183
184/// Parses an inbound certID (RFC 9773 §4.1).
185///
186/// Strict on both halves, which is the point of doing it here rather than
187/// splitting inline: exactly one `.`, and both sides unpadded base64url —
188/// `NO_PAD` *rejects* `=` rather than tolerating it, which is the right reading
189/// of "All trailing `=` characters MUST be stripped from both parts". An empty
190/// half is refused too, since neither an empty AKI nor an empty serial can
191/// identify anything.
192pub fn parse_ari_cert_id(cert_id: &str) -> anyhow::Result<AriCertId> {
193 let (aki_b64, serial_b64) = cert_id
194 .split_once('.')
195 .ok_or_else(|| anyhow::anyhow!("certID must be two base64url parts separated by '.'"))?;
196
197 if serial_b64.contains('.') {
198 anyhow::bail!("certID must contain exactly one '.'");
199 }
200 if aki_b64.is_empty() || serial_b64.is_empty() {
201 anyhow::bail!("both halves of a certID must be non-empty");
202 }
203
204 Ok(AriCertId {
205 aki: BASE64_URL_SAFE_NO_PAD
206 .decode(aki_b64)
207 .map_err(|_| anyhow::anyhow!("invalid key identifier encoding in certID"))?,
208 serial: BASE64_URL_SAFE_NO_PAD
209 .decode(serial_b64)
210 .map_err(|_| anyhow::anyhow!("invalid serial number encoding in certID"))?,
211 })
212}
213
214/// Extracts the validity period (notBefore, notAfter) from a DER-encoded certificate
215/// as UNIX timestamps (seconds since epoch).
216pub fn cert_validity(der: &[u8]) -> Result<(i64, i64), nom::Err<x509_parser::error::X509Error>> {
217 let (_, cert) = x509_parser::parse_x509_certificate(der)?;
218 Ok((
219 cert.tbs_certificate.validity.not_before.timestamp(),
220 cert.tbs_certificate.validity.not_after.timestamp(),
221 ))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn make_cert(name: &str) -> rcgen::Certificate {
229 let key_pair = rcgen::KeyPair::generate().unwrap();
230 let params = rcgen::CertificateParams::new(vec![name.to_string()]).unwrap();
231 params.self_signed(&key_pair).unwrap()
232 }
233
234 fn make_cert_der(name: &str) -> Vec<u8> {
235 make_cert(name).der().to_vec()
236 }
237
238 #[test]
239 fn is_valid_revocation_reason_accepts_every_defined_code_but_the_reserved_one() {
240 for code in 0..=10u32 {
241 assert_eq!(is_valid_revocation_reason(code), code != 7, "code {code}");
242 }
243 assert!(!is_valid_revocation_reason(999));
244 }
245
246 #[test]
247 fn cert_serial_and_spki_round_trips_a_real_certificate() {
248 let der = make_cert_der("example.com");
249 let (serial_hex, spki) = cert_serial_and_spki(&der).unwrap();
250 assert!(!serial_hex.is_empty());
251 assert!(hex::decode(&serial_hex).is_ok());
252 assert!(!spki.is_empty());
253
254 // The SPKI must be the certificate's actual public key, re-derivable
255 // via x509-parser's own accessor.
256 let (_, parsed) = x509_parser::parse_x509_certificate(&der).unwrap();
257 assert_eq!(spki, parsed.tbs_certificate.subject_pki.raw);
258 }
259
260 #[test]
261 fn cert_serial_and_spki_rejects_garbage() {
262 assert!(cert_serial_and_spki(&[0xde, 0xad, 0xbe, 0xef]).is_err());
263 }
264
265 #[test]
266 fn leaf_der_from_chain_takes_the_first_block() {
267 let leaf_cert = make_cert("leaf.example.com");
268 let ca_cert = make_cert("ca.example.com");
269 let chain = format!("{}{}", leaf_cert.pem(), ca_cert.pem());
270
271 let leaf = leaf_der_from_chain(&chain).unwrap();
272 assert_eq!(leaf, leaf_cert.der().to_vec());
273 }
274
275 #[test]
276 fn leaf_der_from_chain_rejects_a_chain_with_no_certificate_block() {
277 assert!(leaf_der_from_chain("not a pem file at all").is_err());
278 }
279
280 /// A CA-signed leaf carrying an Authority Key Identifier — the shape ARI
281 /// applies to, whether an upstream or this server's own `LocalCa` issued it.
282 ///
283 /// Built with `rcgen` directly rather than through `LocalCa` only to keep
284 /// this module's tests free of the signer: `LocalCa` does set
285 /// `use_authority_key_identifier_extension`, and
286 /// `local_ca::tests::an_issued_leaf_carries_the_cas_key_identifier` is what
287 /// pins that.
288 fn ca_signed_leaf_with_aki() -> Vec<u8> {
289 let ca_key = rcgen::KeyPair::generate().unwrap();
290 let mut ca_params = rcgen::CertificateParams::new(vec!["ca.example".to_string()]).unwrap();
291 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Constrained(0));
292 let ca_pem = ca_params.self_signed(&ca_key).unwrap().pem();
293 let issuer = rcgen::Issuer::from_ca_cert_pem(&ca_pem, ca_key).unwrap();
294
295 let leaf_key = rcgen::KeyPair::generate().unwrap();
296 let mut leaf_params =
297 rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
298 leaf_params.use_authority_key_identifier_extension = true;
299 leaf_params
300 .signed_by(&leaf_key, &issuer)
301 .unwrap()
302 .der()
303 .to_vec()
304 }
305
306 #[test]
307 fn ari_cert_id_joins_the_aki_and_the_serial() {
308 let leaf = ca_signed_leaf_with_aki();
309
310 let cert_id = ari_cert_id(&leaf).unwrap();
311 let (aki_b64, serial_b64) = cert_id.split_once('.').expect("certID is two parts");
312
313 // The serial half must round-trip to the same serial the revocation
314 // path reads, since both describe the same certificate.
315 let (serial_hex, _) = cert_serial_and_spki(&leaf).unwrap();
316 assert_eq!(
317 hex::encode(BASE64_URL_SAFE_NO_PAD.decode(serial_b64).unwrap()),
318 serial_hex
319 );
320
321 // And the AKI half must be the issuer's key identifier, not empty.
322 let aki = BASE64_URL_SAFE_NO_PAD.decode(aki_b64).unwrap();
323 assert!(!aki.is_empty());
324 // Base64url, so neither half may carry padding a URL path would escape.
325 assert!(!cert_id.contains('=') && !cert_id.contains('+') && !cert_id.contains('/'));
326 }
327
328 /// A certificate with no AKI has no derivable certID; saying so beats
329 /// inventing one the upstream would not recognize. This is also the case
330 /// a `local_ca`-issued leaf falls into today — see
331 /// [`ca_signed_leaf_with_aki`].
332 #[test]
333 fn ari_cert_id_refuses_a_certificate_without_an_aki() {
334 let der = make_cert_der("example.com");
335 let error = ari_cert_id(&der).unwrap_err().to_string();
336 assert!(error.contains("Authority Key Identifier"), "{error}");
337 }
338
339 #[test]
340 fn ari_cert_id_rejects_garbage() {
341 assert!(ari_cert_id(&[0xde, 0xad, 0xbe, 0xef]).is_err());
342 }
343
344 #[test]
345 fn cert_validity_extracts_correct_timestamps() {
346 let cert = make_cert("example.com");
347 let der = cert.der().to_vec();
348
349 let (not_before, not_after) = cert_validity(&der).unwrap();
350
351 // rcgen generates certificates with default validity starting now or recently
352 // and ending some days in the future (rcgen defaults to 365 days)
353 // Just verify they are reasonable unix timestamps
354 assert!(not_before > 0);
355 assert!(not_after > not_before);
356 }
357}
358
359#[cfg(test)]
360mod normalize_serial_tests {
361 use super::normalize_serial;
362
363 /// The shapes an operator actually arrives with. `openssl x509 -serial`
364 /// prints upper case; an abuse report quotes whatever its tooling printed,
365 /// colons and all. The column only ever holds `hex::encode`'s output.
366 #[test]
367 fn the_shapes_an_operator_pastes_all_fold_to_the_stored_form() {
368 for input in [
369 "0a1b2c3d",
370 "0A1B2C3D",
371 "0a:1b:2c:3d",
372 "0A:1B:2C:3D",
373 "0a 1b 2c 3d",
374 "0a-1b-2c-3d",
375 " 0A1B2C3D ",
376 ] {
377 assert_eq!(normalize_serial(input), "0a1b2c3d", "{input}");
378 }
379 }
380
381 /// Only the separators a human pastes are stripped. Anything else is left
382 /// to match nothing, which is the right answer for a value that is not a
383 /// serial — folding it further would be guessing.
384 #[test]
385 fn nothing_else_is_touched() {
386 assert_eq!(normalize_serial(""), "");
387 assert_eq!(normalize_serial("0x0a1b"), "0x0a1b");
388 assert_eq!(normalize_serial("not/a/serial"), "not/a/serial");
389 }
390}