From 799618d8b98cccdfa58a6853f4ab57dab1cfa1bb Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Wed, 16 Aug 2023 18:18:14 -0400 Subject: [PATCH] lib: add cert CRL distribution points ext. support. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit extends rcgen to allow generating certificates that contain an RFC 5280 certificate revocation list (CRL) distribution points extension. This is a useful mechanism for helping ensure CRL coverage when performing revocation checks, and is newly supported by rustls/webpki. See this upstream webpki issue[0] and RFC 5280 ยง4.2.1.13[1] for more background. Using the new `crl_distribution_points` field of the `CertificateParams` struct it's possible to encode one or more distribution points specifying URI general names where up-to-date CRL information for the certificate can be found. Similar to existing rcgen CRL generation, the support for this extension is not extensive, but instead tailored towards usage in the web PKI with a RFC 5280 profile. Notably this means: * There's no support for specifying the 'reasons' flag - RFC 5280 "RECOMMENDS against segmenting CRLs by reason code". * There's no support for specifying a 'cRLIssuer' in the DP - this is specific to indirect CRLs, and neither rcgen's CRL generation code or webpki's parsing/validation support these. * There's no support for specifying a 'nameRelativeToCrlIssuer' in the DP name instead of a sequence of general names for similar reasons as above: 5280 says: "Conforming CAs SHOULD NOT use nameRelativeToCRLIssuer to specify distribution point names." * There's no support for specifying general names of type other than URI within a DP name's full name. Other name types either don't make sense in the context of this extension, or are rarely useful in practice (e.g. directory name). Test coverage is mixed based on the support of the relevant third party libraries. OpenSSL (openssl-rs) and x509-parser both parse this extension well, and so the `openssl.rs` and `generic.rs` test coverage is the most extensive. Webpki (v/0.102.0-alpha.0) recognizes this extension for use during revocation checking, but doesn't expose it externally so a simple parse test is added. Botan's rust bindings do not recognize the extension or offer a way to pull out arbitrary extensions, so no test coverage is added there. [0] https://github.com/rustls/webpki/issues/121 [1] https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13 --- src/lib.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++- tests/generic.rs | 56 ++++++++++++++++++++++++++++++++++++++++++- tests/openssl.rs | 36 +++++++++++++++++++++++++--- tests/util.rs | 18 +++++++++++++- tests/webpki.rs | 8 +++++++ 5 files changed, 174 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0813ff3c..a5e73387 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,9 +140,13 @@ const OID_AUTHORITY_KEY_IDENTIFIER :&[u64] = &[2, 5, 29, 35]; const OID_EXT_KEY_USAGE :&[u64] = &[2, 5, 29, 37]; // id-ce-nameConstraints in -/// https://tools.ietf.org/html/rfc5280#section-4.2.1.10 +// https://tools.ietf.org/html/rfc5280#section-4.2.1.10 const OID_NAME_CONSTRAINTS :&[u64] = &[2, 5, 29, 30]; +// id-ce-cRLDistributionPoints in +// https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13 +const OID_CRL_DISTRIBUTION_POINTS :&[u64] = &[2, 5, 29, 31]; + // id-pe-acmeIdentifier in // https://www.iana.org/assignments/smi-numbers/smi-numbers.xhtml#smi-numbers-1.3.6.1.5.5.7.1 const OID_PE_ACME :&[u64] = &[1, 3, 6, 1, 5, 5, 7, 1, 31]; @@ -733,6 +737,12 @@ pub struct CertificateParams { pub key_usages :Vec, pub extended_key_usages :Vec, pub name_constraints :Option, + /// An optional list of certificate revocation list (CRL) distribution points as described + /// in RFC 5280 Section 4.2.1.13[^1]. Each distribution point contains one or more URIs where + /// an up-to-date CRL with scope including this certificate can be retrieved. + /// + /// [^1]: + pub crl_distribution_points :Vec, pub custom_extensions :Vec, /// The certificate's key pair, a new random key pair will be generated if this is `None` pub key_pair :Option, @@ -762,6 +772,7 @@ impl Default for CertificateParams { key_usages : Vec::new(), extended_key_usages : Vec::new(), name_constraints : None, + crl_distribution_points : Vec::new(), custom_extensions : Vec::new(), key_pair : None, use_authority_key_identifier_extension : false, @@ -1020,6 +1031,7 @@ impl CertificateParams { key_usages, extended_key_usages, name_constraints, + crl_distribution_points, custom_extensions, key_pair, use_authority_key_identifier_extension, @@ -1037,6 +1049,7 @@ impl CertificateParams { || !key_usages.is_empty() || !extended_key_usages.is_empty() || name_constraints.is_some() + || !crl_distribution_points.is_empty() || *use_authority_key_identifier_extension { return Err(RcgenError::UnsupportedInCsr); @@ -1230,6 +1243,15 @@ impl CertificateParams { }); } } + if !self.crl_distribution_points.is_empty() { + write_x509_extension(writer.next(), OID_CRL_DISTRIBUTION_POINTS, false, |writer| { + writer.write_sequence(|writer| { + for distribution_point in &self.crl_distribution_points { + distribution_point.write_der(writer.next()); + } + }) + }); + } match self.is_ca { IsCa::Ca(ref constraint) => { // Write subject_key_identifier @@ -1381,6 +1403,44 @@ impl NameConstraints { } } +/// A certificate revocation list (CRL) distribution point, to be included in a certificate's +/// [distribution points extension](https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13). +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct CrlDistributionPoint { + /// One or more URI distribution point names, indicating a place the current CRL can + /// be retrieved. When present, SHOULD include at least one LDAP or HTTP URI. + pub uris :Vec, +} + +impl CrlDistributionPoint { + fn write_der(&self, writer :DERWriter) { + // DistributionPoint SEQUENCE + writer.write_sequence(|writer| { + write_distribution_point_name_uris(writer.next(), &self.uris); + }); + } +} + +fn write_distribution_point_name_uris<'a>(writer :DERWriter, uris: impl IntoIterator) { + // distributionPoint DistributionPointName + writer.write_tagged_implicit(Tag::context(0), |writer| { + writer.write_sequence(|writer| { + // fullName GeneralNames + writer.next().write_tagged_implicit(Tag::context(0), | writer| { + // GeneralNames + writer.write_sequence(|writer| { + for uri in uris.into_iter() { + // uniformResourceIdentifier [6] IA5String, + writer.next().write_tagged_implicit(Tag::context(6), |writer| { + writer.write_ia5_string(uri) + }); + } + }) + }); + }); + }); +} + /// One of the purposes contained in the [key usage](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.3) extension #[derive(Debug, PartialEq, Eq, Hash, Clone)] pub enum KeyUsagePurpose { diff --git a/tests/generic.rs b/tests/generic.rs index 042a11e2..6a549904 100644 --- a/tests/generic.rs +++ b/tests/generic.rs @@ -123,4 +123,58 @@ mod test_x509_parser_crl { // We should be able to verify the CRL signature with the issuer. assert!(x509_crl.verify_signature(&x509_issuer.public_key()).is_ok()); } -} \ No newline at end of file +} + +#[cfg(feature = "x509-parser")] +mod test_parse_crl_dps { + use x509_parser::extensions::{DistributionPointName, ParsedExtension}; + use crate::util; + + #[test] + fn parse_crl_dps() { + // Generate and parse a certificate that includes two CRL distribution points. + let der = util::cert_with_crl_dps(); + let (_, parsed_cert) = x509_parser::parse_x509_certificate(&der).unwrap(); + + // We should find a CRL DP extension was parsed. + let crl_dps = parsed_cert.get_extension_unique(&x509_parser::oid_registry::OID_X509_EXT_CRL_DISTRIBUTION_POINTS) + .expect("malformed CRL distribution points extension") + .expect("missing CRL distribution points extension"); + + // The extension should not be critical. + assert!(!crl_dps.critical); + + // We should be able to parse the definition. + let crl_dps = match crl_dps.parsed_extension() { + ParsedExtension::CRLDistributionPoints(crl_dps) => crl_dps, + _ => panic!("unexpected parsed extension type") + }; + + // There should be two DPs. + assert_eq!(crl_dps.points.len(), 2); + + // Each distribution point should only include a distribution point name holding a sequence + // of general names. + let general_names = crl_dps.points.iter().flat_map(|dp| { + // We shouldn't find a cRLIssuer or onlySomeReasons field. + assert!(dp.crl_issuer.is_none()); + assert!(dp.reasons.is_none()); + + match dp.distribution_point.as_ref().expect("missing distribution point name") { + DistributionPointName::FullName(general_names) => general_names.iter(), + DistributionPointName::NameRelativeToCRLIssuer(_) => panic!("unexpected name relative to cRL issuer") + } + }).collect::>(); + + // All of the general names should be URIs. + let uris = general_names.iter().map(|general_name| { + match general_name { + x509_parser::extensions::GeneralName::URI(uri) => *uri, + _ => panic!("unexpected general name type") + } + }).collect::>(); + + // We should find the expected URIs. + assert_eq!(uris, &["http://example.com/crl.der", "http://crls.example.com/1234", "ldap://example.com/crl.der"]); + } +} diff --git a/tests/openssl.rs b/tests/openssl.rs index eda91f82..203e70a6 100644 --- a/tests/openssl.rs +++ b/tests/openssl.rs @@ -1,5 +1,4 @@ -use rcgen::{Certificate, NameConstraints, GeneralSubtree, IsCa, - BasicConstraints, CertificateParams, DnType, DnValue}; +use rcgen::{Certificate, NameConstraints, GeneralSubtree, IsCa, BasicConstraints, CertificateParams, DnType, DnValue}; use openssl::pkey::PKey; use openssl::x509::{CrlStatus, X509, X509Crl, X509Req, X509StoreContext}; use openssl::x509::store::{X509StoreBuilder, X509Store}; @@ -426,4 +425,35 @@ fn test_openssl_crl_parse() { // We should be able to verify the CRL signature with the issuer's public key. let issuer_pkey = openssl_issuer.public_key().unwrap(); assert!(openssl_crl.verify(&issuer_pkey).expect("failed to verify CRL signature")); -} \ No newline at end of file +} + +#[test] +fn test_openssl_crl_dps_parse() { + // Generate and parse a certificate that includes two CRL distribution points. + let der = util::cert_with_crl_dps(); + let cert = X509::from_der(&der).expect("failed to parse cert DER"); + + // We should find the CRL DPs extension. + let dps = cert.crl_distribution_points().expect("missing crl distribution points extension"); + assert!(!dps.is_empty()); + + // We should find two distribution points, each with a distribution point name containing + // a full name sequence of general names. + let general_names = dps.iter().flat_map(|dp| + dp.distpoint() + .expect("distribution point missing distribution point name") + .fullname() + .expect("distribution point name missing general names") + .iter() + ) + .collect::>(); + + // Each general name should be a URI name. + let uris = general_names.iter().map(|general_name| + general_name.uri().expect("general name is not a directory name") + ) + .collect::>(); + + // We should find the expected URIs. + assert_eq!(uris, &["http://example.com/crl.der", "http://crls.example.com/1234", "ldap://example.com/crl.der"]); +} diff --git a/tests/util.rs b/tests/util.rs index 4dcf3438..4143d6ea 100644 --- a/tests/util.rs +++ b/tests/util.rs @@ -1,5 +1,5 @@ use time::{Duration, OffsetDateTime}; -use rcgen::{BasicConstraints, Certificate, CertificateParams, CertificateRevocationList}; +use rcgen::{BasicConstraints, Certificate, CertificateParams, CertificateRevocationList, CrlDistributionPoint}; use rcgen::{CertificateRevocationListParams, DnType, IsCa, KeyIdMethod}; use rcgen::{KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, RevocationReason, RevokedCertParams, SerialNumber}; @@ -99,3 +99,19 @@ pub fn test_crl() -> (CertificateRevocationList, Certificate) { (crl, issuer) } + +#[allow(unused)] // Used by openssl + x509-parser features. +pub fn cert_with_crl_dps() -> Vec { + let mut params = default_params(); + params.crl_distribution_points = vec![ + CrlDistributionPoint{ + uris: vec!["http://example.com/crl.der".to_string(), "http://crls.example.com/1234".to_string()], + }, + CrlDistributionPoint{ + uris: vec!["ldap://example.com/crl.der".to_string()], + } + ]; + + let cert = Certificate::from_params(params).unwrap(); + cert.serialize_der().unwrap() +} diff --git a/tests/webpki.rs b/tests/webpki.rs index 8f4c41df..ad0fd5fe 100644 --- a/tests/webpki.rs +++ b/tests/webpki.rs @@ -526,3 +526,11 @@ fn test_webpki_crl_revoke() { ); assert!(matches!(result, Err(webpki::Error::CertRevoked))); } + +#[test] +fn test_webpki_cert_crl_dps() { + let der = util::cert_with_crl_dps(); + webpki::EndEntityCert::try_from(der.as_ref()).expect("failed to parse cert with CRL DPs ext"); + // Webpki doesn't expose the parsed CRL distribution extension, so we can't interrogate that + // it matches the expected form. See `openssl.rs` for more extensive coverage. +}