Skip to main content

acme_proxy/
cert.rs

1//! Small X.509 helpers shared by the certificate revocation paths: the ACME
2//! `POST /revokeCert` handler ([`crate::handlers::post_revoke_cert`]) and the `order
3//! revoke` admin CLI command ([`crate::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/// [`crate::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/// Extracts a single certificate's serial (hex-encoded, no separators) and
25/// the DER encoding of its `SubjectPublicKeyInfo` from raw X.509 DER bytes.
26///
27/// The SPKI returned is the certificate's own `SEQUENCE { AlgorithmIdentifier,
28/// BIT STRING }` — the same canonical DER-SPKI shape
29/// `verify_signature_and_get_der` builds for `Account.pubkey`, so it can be
30/// compared byte-for-byte against a JWS-verified `pubkey` with no conversion.
31pub fn cert_serial_and_spki(
32    der: &[u8],
33) -> Result<(String, Vec<u8>), nom::Err<x509_parser::error::X509Error>> {
34    let (_, cert) = x509_parser::parse_x509_certificate(der)?;
35    Ok((
36        hex::encode(cert.tbs_certificate.raw_serial()),
37        cert.tbs_certificate.subject_pki.raw.to_vec(),
38    ))
39}
40
41/// Extracts the leaf certificate's DER bytes from a `leaf + CA` PEM chain
42/// (the shape `SignerBackend::issue` returns and `orders.certificate`
43/// stores) — the first `CERTIFICATE` block, mirroring
44/// [`crate::pemfile::read_certificates`]'s label-agnostic PEM iterator.
45pub fn leaf_der_from_chain(chain_pem: &str) -> anyhow::Result<Vec<u8>> {
46    match Pem::iter_from_buffer(chain_pem.as_bytes()).next() {
47        Some(result) => Ok(result?.contents),
48        None => anyhow::bail!("no CERTIFICATE block found in chain"),
49    }
50}
51
52/// The ACME Renewal Information certificate identifier (RFC 9773 §4.1) for a
53/// DER-encoded certificate: `base64url(AKI keyIdentifier) || "." ||
54/// base64url(serial)`, both unpadded.
55///
56/// Used in both directions: to ask an upstream CA about a certificate
57/// ([`crate::signer::relay`]) and to check an inbound certID against the
58/// certificate it claims to name ([`crate::handlers::get_renewal_info`]).
59///
60/// Fails when the certificate carries no Authority Key Identifier extension,
61/// or one with no `keyIdentifier` field — without it there is no certID to
62/// build, and guessing would produce one no one recognizes. Certificates this
63/// server issued before its local CA emitted an AKI are exactly that case, so
64/// the inbound side treats the failure as "cannot check" rather than "reject".
65pub fn ari_cert_id(der: &[u8]) -> anyhow::Result<String> {
66    let (aki, serial) = ari_cert_id_parts(der)?;
67    Ok(format!(
68        "{}.{}",
69        BASE64_URL_SAFE_NO_PAD.encode(aki),
70        BASE64_URL_SAFE_NO_PAD.encode(serial),
71    ))
72}
73
74/// The raw `(AKI keyIdentifier, serial)` bytes behind [`ari_cert_id`], for
75/// callers that want to compare the halves rather than the rendered string.
76pub fn ari_cert_id_parts(der: &[u8]) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
77    use x509_parser::extensions::ParsedExtension;
78    use x509_parser::oid_registry::OID_X509_EXT_AUTHORITY_KEY_IDENTIFIER;
79
80    let (_, cert) = x509_parser::parse_x509_certificate(der)?;
81    let extension = cert
82        .get_extension_unique(&OID_X509_EXT_AUTHORITY_KEY_IDENTIFIER)?
83        .ok_or_else(|| anyhow::anyhow!("certificate has no Authority Key Identifier extension"))?;
84
85    let ParsedExtension::AuthorityKeyIdentifier(aki) = extension.parsed_extension() else {
86        anyhow::bail!("Authority Key Identifier extension could not be parsed");
87    };
88    let key_identifier = aki
89        .key_identifier
90        .as_ref()
91        .ok_or_else(|| anyhow::anyhow!("Authority Key Identifier carries no keyIdentifier"))?;
92
93    Ok((
94        key_identifier.0.to_vec(),
95        cert.tbs_certificate.raw_serial().to_vec(),
96    ))
97}
98
99/// The two halves of an ACME Renewal Information certID (RFC 9773 §4.1),
100/// decoded: `base64url(AKI keyIdentifier) "." base64url(serial)`.
101///
102/// Parsed in one place because both RFC 9773 surfaces consume the same string:
103/// `GET /renewalInfo/{certID}` (§4.1) and a newOrder's `replaces` field (§5),
104/// which "is constructed in the same way as the path component for GET
105/// requests described in Section 4.1".
106#[derive(Debug, PartialEq, Eq)]
107pub struct AriCertId {
108    /// The issuer's Authority Key Identifier `keyIdentifier`, raw bytes.
109    pub aki: Vec<u8>,
110    /// The certificate serial, raw DER integer content bytes (no tag/length).
111    pub serial: Vec<u8>,
112}
113
114impl AriCertId {
115    /// The serial as this codebase stores it: lowercase hex, no separators —
116    /// what `orders.cert_serial` holds and `Order::find_by_cert_serial` takes.
117    #[must_use]
118    pub fn serial_hex(&self) -> String {
119        hex::encode(&self.serial)
120    }
121}
122
123/// Parses an inbound certID (RFC 9773 §4.1).
124///
125/// Strict on both halves, which is the point of doing it here rather than
126/// splitting inline: exactly one `.`, and both sides unpadded base64url —
127/// `NO_PAD` *rejects* `=` rather than tolerating it, which is the right reading
128/// of "All trailing `=` characters MUST be stripped from both parts". An empty
129/// half is refused too, since neither an empty AKI nor an empty serial can
130/// identify anything.
131pub fn parse_ari_cert_id(cert_id: &str) -> anyhow::Result<AriCertId> {
132    let (aki_b64, serial_b64) = cert_id
133        .split_once('.')
134        .ok_or_else(|| anyhow::anyhow!("certID must be two base64url parts separated by '.'"))?;
135
136    if serial_b64.contains('.') {
137        anyhow::bail!("certID must contain exactly one '.'");
138    }
139    if aki_b64.is_empty() || serial_b64.is_empty() {
140        anyhow::bail!("both halves of a certID must be non-empty");
141    }
142
143    Ok(AriCertId {
144        aki: BASE64_URL_SAFE_NO_PAD
145            .decode(aki_b64)
146            .map_err(|_| anyhow::anyhow!("invalid key identifier encoding in certID"))?,
147        serial: BASE64_URL_SAFE_NO_PAD
148            .decode(serial_b64)
149            .map_err(|_| anyhow::anyhow!("invalid serial number encoding in certID"))?,
150    })
151}
152
153/// Extracts the validity period (notBefore, notAfter) from a DER-encoded certificate
154/// as UNIX timestamps (seconds since epoch).
155pub fn cert_validity(der: &[u8]) -> Result<(i64, i64), nom::Err<x509_parser::error::X509Error>> {
156    let (_, cert) = x509_parser::parse_x509_certificate(der)?;
157    Ok((
158        cert.tbs_certificate.validity.not_before.timestamp(),
159        cert.tbs_certificate.validity.not_after.timestamp(),
160    ))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn make_cert(name: &str) -> rcgen::Certificate {
168        let key_pair = rcgen::KeyPair::generate().unwrap();
169        let params = rcgen::CertificateParams::new(vec![name.to_string()]).unwrap();
170        params.self_signed(&key_pair).unwrap()
171    }
172
173    fn make_cert_der(name: &str) -> Vec<u8> {
174        make_cert(name).der().to_vec()
175    }
176
177    #[test]
178    fn is_valid_revocation_reason_accepts_every_defined_code_but_the_reserved_one() {
179        for code in 0..=10u32 {
180            assert_eq!(is_valid_revocation_reason(code), code != 7, "code {code}");
181        }
182        assert!(!is_valid_revocation_reason(999));
183    }
184
185    #[test]
186    fn cert_serial_and_spki_round_trips_a_real_certificate() {
187        let der = make_cert_der("example.com");
188        let (serial_hex, spki) = cert_serial_and_spki(&der).unwrap();
189        assert!(!serial_hex.is_empty());
190        assert!(hex::decode(&serial_hex).is_ok());
191        assert!(!spki.is_empty());
192
193        // The SPKI must be the certificate's actual public key, re-derivable
194        // via x509-parser's own accessor.
195        let (_, parsed) = x509_parser::parse_x509_certificate(&der).unwrap();
196        assert_eq!(spki, parsed.tbs_certificate.subject_pki.raw);
197    }
198
199    #[test]
200    fn cert_serial_and_spki_rejects_garbage() {
201        assert!(cert_serial_and_spki(&[0xde, 0xad, 0xbe, 0xef]).is_err());
202    }
203
204    #[test]
205    fn leaf_der_from_chain_takes_the_first_block() {
206        let leaf_cert = make_cert("leaf.example.com");
207        let ca_cert = make_cert("ca.example.com");
208        let chain = format!("{}{}", leaf_cert.pem(), ca_cert.pem());
209
210        let leaf = leaf_der_from_chain(&chain).unwrap();
211        assert_eq!(leaf, leaf_cert.der().to_vec());
212    }
213
214    #[test]
215    fn leaf_der_from_chain_rejects_a_chain_with_no_certificate_block() {
216        assert!(leaf_der_from_chain("not a pem file at all").is_err());
217    }
218
219    /// A CA-signed leaf carrying an Authority Key Identifier — the shape ARI
220    /// applies to, whether an upstream or this server's own `LocalCa` issued it.
221    ///
222    /// Built with `rcgen` directly rather than through `LocalCa` only to keep
223    /// this module's tests free of the signer: `LocalCa` does set
224    /// `use_authority_key_identifier_extension`, and
225    /// `local_ca::tests::an_issued_leaf_carries_the_cas_key_identifier` is what
226    /// pins that.
227    fn ca_signed_leaf_with_aki() -> Vec<u8> {
228        let ca_key = rcgen::KeyPair::generate().unwrap();
229        let mut ca_params = rcgen::CertificateParams::new(vec!["ca.example".to_string()]).unwrap();
230        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Constrained(0));
231        let ca_pem = ca_params.self_signed(&ca_key).unwrap().pem();
232        let issuer = rcgen::Issuer::from_ca_cert_pem(&ca_pem, ca_key).unwrap();
233
234        let leaf_key = rcgen::KeyPair::generate().unwrap();
235        let mut leaf_params =
236            rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
237        leaf_params.use_authority_key_identifier_extension = true;
238        leaf_params
239            .signed_by(&leaf_key, &issuer)
240            .unwrap()
241            .der()
242            .to_vec()
243    }
244
245    #[test]
246    fn ari_cert_id_joins_the_aki_and_the_serial() {
247        let leaf = ca_signed_leaf_with_aki();
248
249        let cert_id = ari_cert_id(&leaf).unwrap();
250        let (aki_b64, serial_b64) = cert_id.split_once('.').expect("certID is two parts");
251
252        // The serial half must round-trip to the same serial the revocation
253        // path reads, since both describe the same certificate.
254        let (serial_hex, _) = cert_serial_and_spki(&leaf).unwrap();
255        assert_eq!(
256            hex::encode(BASE64_URL_SAFE_NO_PAD.decode(serial_b64).unwrap()),
257            serial_hex
258        );
259
260        // And the AKI half must be the issuer's key identifier, not empty.
261        let aki = BASE64_URL_SAFE_NO_PAD.decode(aki_b64).unwrap();
262        assert!(!aki.is_empty());
263        // Base64url, so neither half may carry padding a URL path would escape.
264        assert!(!cert_id.contains('=') && !cert_id.contains('+') && !cert_id.contains('/'));
265    }
266
267    /// A certificate with no AKI has no derivable certID; saying so beats
268    /// inventing one the upstream would not recognize. This is also the case
269    /// a `local_ca`-issued leaf falls into today — see
270    /// [`ca_signed_leaf_with_aki`].
271    #[test]
272    fn ari_cert_id_refuses_a_certificate_without_an_aki() {
273        let der = make_cert_der("example.com");
274        let error = ari_cert_id(&der).unwrap_err().to_string();
275        assert!(error.contains("Authority Key Identifier"), "{error}");
276    }
277
278    #[test]
279    fn ari_cert_id_rejects_garbage() {
280        assert!(ari_cert_id(&[0xde, 0xad, 0xbe, 0xef]).is_err());
281    }
282
283    #[test]
284    fn cert_validity_extracts_correct_timestamps() {
285        let cert = make_cert("example.com");
286        let der = cert.der().to_vec();
287
288        let (not_before, not_after) = cert_validity(&der).unwrap();
289
290        // rcgen generates certificates with default validity starting now or recently
291        // and ending some days in the future (rcgen defaults to 365 days)
292        // Just verify they are reasonable unix timestamps
293        assert!(not_before > 0);
294        assert!(not_after > not_before);
295    }
296}