Skip to content

Commit

Permalink
lib: add cert and CRL distribution point extension support. (#136)
Browse files Browse the repository at this point in the history
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] rustls/webpki#121
[1] https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13
  • Loading branch information
cpu committed Aug 22, 2023
1 parent 83e548a commit 041d4cb
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 7 deletions.
123 changes: 122 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -159,6 +163,10 @@ const OID_CRL_REASONS :&[u64] = &[2, 5, 29, 21];
// https://www.rfc-editor.org/rfc/rfc5280#section-5.3.2
const OID_CRL_INVALIDITY_DATE :&[u64] = &[2, 5, 29, 24];

// id-ce-issuingDistributionPoint
// https://datatracker.ietf.org/doc/html/rfc5280#section-5.2.5
const OID_CRL_ISSUING_DISTRIBUTION_POINT :&[u64] = &[2, 5, 29, 28];

#[cfg(feature = "pem")]
const ENCODE_CONFIG: pem::EncodeConfig = match cfg!(target_family = "windows") {
true => pem::EncodeConfig { line_ending: pem::LineEnding::CRLF },
Expand Down Expand Up @@ -676,6 +684,7 @@ impl CertificateSigningRequest {
/// this_update: date_time_ymd(2023, 06, 17),
/// next_update: date_time_ymd(2024, 06, 17),
/// crl_number: SerialNumber::from(1234),
/// issuing_distribution_point: None,
/// revoked_certs: vec![revoked_cert],
/// alg: &PKCS_ECDSA_P256_SHA256,
/// key_identifier_method: KeyIdMethod::Sha256,
Expand Down Expand Up @@ -733,6 +742,12 @@ pub struct CertificateParams {
pub key_usages :Vec<KeyUsagePurpose>,
pub extended_key_usages :Vec<ExtendedKeyUsagePurpose>,
pub name_constraints :Option<NameConstraints>,
/// 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]: <https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13>
pub crl_distribution_points :Vec<CrlDistributionPoint>,
pub custom_extensions :Vec<CustomExtension>,
/// The certificate's key pair, a new random key pair will be generated if this is `None`
pub key_pair :Option<KeyPair>,
Expand Down Expand Up @@ -762,6 +777,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,
Expand Down Expand Up @@ -1020,6 +1036,7 @@ impl CertificateParams {
key_usages,
extended_key_usages,
name_constraints,
crl_distribution_points,
custom_extensions,
key_pair,
use_authority_key_identifier_extension,
Expand All @@ -1037,6 +1054,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);
Expand Down Expand Up @@ -1230,6 +1248,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
Expand Down Expand Up @@ -1381,6 +1408,45 @@ 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) or
/// a CRL's [issuing distribution point extension](https://datatracker.ietf.org/doc/html/rfc5280#section-5.2.5)
#[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<String>,
}

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<Item = &'a String>) {
// 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 {
Expand Down Expand Up @@ -1536,6 +1602,11 @@ pub struct CertificateRevocationListParams {
pub next_update :OffsetDateTime,
/// A monotonically increasing sequence number for a given CRL scope and issuer.
pub crl_number :SerialNumber,
/// An optional CRL extension identifying the CRL distribution point and scope for a
/// particular CRL as described in RFC 5280 Section 5.2.5[^1].
///
/// [^1]: <https://datatracker.ietf.org/doc/html/rfc5280#section-5.2.5>
pub issuing_distribution_point :Option<CrlIssuingDistributionPoint>,
/// A list of zero or more parameters describing revoked certificates included in the CRL.
pub revoked_certs :Vec<RevokedCertParams>,
/// Signature algorithm to use when signing the serialized CRL.
Expand Down Expand Up @@ -1633,6 +1704,13 @@ impl CertificateRevocationListParams {
write_x509_extension(writer.next(), OID_CRL_NUMBER, false, |writer| {
writer.write_bigint_bytes(self.crl_number.as_ref(), true);
});

// Write issuing distribution point (if present).
if let Some(issuing_distribution_point) = &self.issuing_distribution_point {
write_x509_extension(writer.next(), OID_CRL_ISSUING_DISTRIBUTION_POINT, true, |writer| {
issuing_distribution_point.write_der(writer);
});
}
});
});

Expand All @@ -1641,6 +1719,49 @@ impl CertificateRevocationListParams {
}
}

/// A certificate revocation list (CRL) issuing distribution point, to be included in a CRL's
/// [issuing distribution point extension](https://datatracker.ietf.org/doc/html/rfc5280#section-5.2.5).
pub struct CrlIssuingDistributionPoint {
/// The CRL's distribution point, containing a sequence of URIs the CRL can be retrieved from.
pub distribution_point :CrlDistributionPoint,
/// An optional description of the CRL's scope. If omitted, the CRL may contain
/// both user certs and CA certs.
pub scope :Option<CrlScope>,
}

impl CrlIssuingDistributionPoint {
fn write_der(&self, writer :DERWriter) {
// IssuingDistributionPoint SEQUENCE
writer.write_sequence(|writer| {
// distributionPoint [0] DistributionPointName OPTIONAL
write_distribution_point_name_uris(writer.next(), &self.distribution_point.uris);

// -- at most one of onlyContainsUserCerts, onlyContainsCACerts,
// -- and onlyContainsAttributeCerts may be set to TRUE.
if let Some(scope) = self.scope {
let tag = match scope {
// onlyContainsUserCerts [1] BOOLEAN DEFAULT FALSE,
CrlScope::UserCertsOnly => Tag::context(1),
// onlyContainsCACerts [2] BOOLEAN DEFAULT FALSE,
CrlScope::CaCertsOnly => Tag::context(2),
};
writer.next().write_tagged_implicit(tag, |writer| {
writer.write_bool(true);
});
}
});
}
}

/// Describes the scope of a CRL for an issuing distribution point extension.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum CrlScope {
/// The CRL contains only end-entity user certificates.
UserCertsOnly,
/// The CRL contains only CA certificates.
CaCertsOnly,
}

/// Parameters used for describing a revoked certificate included in a [`CertificateRevocationList`].
pub struct RevokedCertParams {
/// Serial number identifying the revoked certificate.
Expand Down
1 change: 1 addition & 0 deletions tests/botan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ fn test_botan_crl_parse() {
this_update: now,
next_update: now + Duration::weeks(1),
crl_number: rcgen::SerialNumber::from(1234),
issuing_distribution_point: None,
revoked_certs: vec![RevokedCertParams{
serial_number: ee.get_params().serial_number.clone().unwrap(),
revocation_time: now,
Expand Down
65 changes: 63 additions & 2 deletions tests/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ mod test_x509_parser_crl {
crl.get_params().this_update.unix_timestamp());
assert_eq!(x509_crl.next_update().unwrap().to_datetime().unix_timestamp(),
crl.get_params().next_update.unix_timestamp());
// TODO(XXX): Waiting on https://github.com/rusticata/x509-parser/pull/144
// TODO: Waiting on x509-parser 0.15.1 to be released.
// let crl_number = BigUint::from_bytes_be(crl.get_params().crl_number.as_ref());
// assert_eq!(x509_crl.crl_number().unwrap(), &crl_number);

Expand All @@ -120,7 +120,68 @@ mod test_x509_parser_crl {
let (_, reason_code) = x509_revoked_cert.reason_code().unwrap();
assert_eq!(reason_code.0, revoked_cert.reason_code.unwrap() as u8);

// The issuing distribution point extension should be present and marked critical.
let issuing_dp_ext = x509_crl.extensions().iter()
.find(|ext| ext.oid == x509_parser::oid_registry::OID_X509_EXT_ISSUER_DISTRIBUTION_POINT)
.expect("failed to find issuing distribution point extension");
assert!(issuing_dp_ext.critical);
// TODO: x509-parser does not yet parse the CRL issuing DP extension for further examination.

// We should be able to verify the CRL signature with the issuer.
assert!(x509_crl.verify_signature(&x509_issuer.public_key()).is_ok());
}
}
}

#[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::<Vec<_>>();

// 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::<Vec<_>>();

// We should find the expected URIs.
assert_eq!(uris, &["http://example.com/crl.der", "http://crls.example.com/1234", "ldap://example.com/crl.der"]);
}
}
36 changes: 33 additions & 3 deletions tests/openssl.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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"));
}
}

#[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::<Vec<_>>();

// 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::<Vec<_>>();

// We should find the expected URIs.
assert_eq!(uris, &["http://example.com/crl.der", "http://crls.example.com/1234", "ldap://example.com/crl.der"]);
}
23 changes: 22 additions & 1 deletion tests/util.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use time::{Duration, OffsetDateTime};
use rcgen::{BasicConstraints, Certificate, CertificateParams, CertificateRevocationList};
use rcgen::{BasicConstraints, Certificate, CertificateParams};
use rcgen::{CertificateRevocationList, CrlDistributionPoint, CrlIssuingDistributionPoint, CrlScope};
use rcgen::{CertificateRevocationListParams, DnType, IsCa, KeyIdMethod};
use rcgen::{KeyUsagePurpose, PKCS_ECDSA_P256_SHA256, RevocationReason, RevokedCertParams, SerialNumber};

Expand Down Expand Up @@ -91,6 +92,10 @@ pub fn test_crl() -> (CertificateRevocationList, Certificate) {
this_update: now,
next_update: next_week,
crl_number: SerialNumber::from(1234),
issuing_distribution_point: Some(CrlIssuingDistributionPoint{
distribution_point: CrlDistributionPoint { uris: vec!["http://example.com/crl".to_string()] },
scope: Some(CrlScope::UserCertsOnly),
}),
revoked_certs: vec![revoked_cert],
alg: &PKCS_ECDSA_P256_SHA256,
key_identifier_method: KeyIdMethod::Sha256,
Expand All @@ -99,3 +104,19 @@ pub fn test_crl() -> (CertificateRevocationList, Certificate) {

(crl, issuer)
}

#[allow(unused)] // Used by openssl + x509-parser features.
pub fn cert_with_crl_dps() -> Vec<u8> {
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()
}
Loading

0 comments on commit 041d4cb

Please sign in to comment.