Skip to main content

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}