From bdb78741f63c60a20bb67e590ea5b1e54517e5a6 Mon Sep 17 00:00:00 2001 From: Geoffroy Couprie Date: Thu, 28 Dec 2017 23:44:36 +0100 Subject: [PATCH] add a method to collect the DNS names from a cert. This commit adds an `EndEntityCert::dns_names` method, which returns a list of the DNS names provided in the subject alternative names extension of the certificate. Authored-by: Geoffroy Couprie geo.couprie@gmail.com Co-authored-by: Sean McArthur sean@seanmonstar.com Co-authored-by: Eliza Weisman eliza@buoyant.io Co-authored-by: Daniel McCarney daniel@binaryparadox.net Signed-off-by: Daniel McCarney daniel@binaryparadox.net --- src/end_entity.rs | 15 ++ src/subject_name/dns_name.rs | 134 ++++++++++++++---- src/subject_name/mod.rs | 4 + src/subject_name/verify.rs | 52 ++++++- tests/integration.rs | 115 +++++++++++++++ tests/misc/dns_names_and_wildcards.der | Bin 0 -> 1204 bytes .../misc/invalid_subject_alternative_name.der | Bin 0 -> 1772 bytes tests/misc/no_subject_alternative_name.der | Bin 0 -> 797 bytes 8 files changed, 286 insertions(+), 34 deletions(-) create mode 100644 tests/misc/dns_names_and_wildcards.der create mode 100644 tests/misc/invalid_subject_alternative_name.der create mode 100644 tests/misc/no_subject_alternative_name.der diff --git a/src/end_entity.rs b/src/end_entity.rs index fb3a402a..cf74fe8e 100644 --- a/src/end_entity.rs +++ b/src/end_entity.rs @@ -12,6 +12,8 @@ // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +#[cfg(feature = "alloc")] +use crate::subject_name::GeneralDnsNameRef; use crate::{ cert, signed_data, subject_name, verify_cert, Error, SignatureAlgorithm, SubjectNameRef, Time, TlsClientTrustAnchors, TlsServerTrustAnchors, @@ -174,4 +176,17 @@ impl<'a> EndEntityCert<'a> { untrusted::Input::from(signature), ) } + + /// Returns a list of the DNS names provided in the subject alternative names extension + /// + /// This function must not be used to implement custom DNS name verification. + /// Verification functions are already provided as `verify_is_valid_for_dns_name` + /// and `verify_is_valid_for_at_least_one_dns_name`. + /// + /// Requires the `alloc` default feature; i.e. this isn't available in + /// `#![no_std]` configurations. + #[cfg(feature = "alloc")] + pub fn dns_names(&'a self) -> Result>, Error> { + subject_name::list_cert_dns_names(self) + } } diff --git a/src/subject_name/dns_name.rs b/src/subject_name/dns_name.rs index 54e3c979..d28c42fe 100644 --- a/src/subject_name/dns_name.rs +++ b/src/subject_name/dns_name.rs @@ -14,6 +14,7 @@ #[cfg(feature = "alloc")] use alloc::string::String; +use core::fmt::Write; /// A DNS Name suitable for use in the TLS Server Name Indication (SNI) /// extension and/or for use as the reference hostname for which to verify a @@ -21,14 +22,10 @@ use alloc::string::String; /// /// A `DnsName` is guaranteed to be syntactically valid. The validity rules are /// specified in [RFC 5280 Section 7.2], except that underscores are also -/// allowed. +/// allowed. `DnsName`s do not include wildcard labels. /// /// `DnsName` stores a copy of the input it was constructed from in a `String` -/// and so it is only available when the `std` default feature is enabled. -/// -/// `Eq`, `PartialEq`, etc. are not implemented because name comparison -/// frequently should be done case-insensitively and/or with other caveats that -/// depend on the specific circumstances in which the comparison is done. +/// and so it is only available when the `alloc` default feature is enabled. /// /// [RFC 5280 Section 7.2]: https://tools.ietf.org/html/rfc5280#section-7.2 /// @@ -69,14 +66,10 @@ impl From> for DnsName { /// /// A `DnsNameRef` is guaranteed to be syntactically valid. The validity rules /// are specified in [RFC 5280 Section 7.2], except that underscores are also -/// allowed. -/// -/// `Eq`, `PartialEq`, etc. are not implemented because name comparison -/// frequently should be done case-insensitively and/or with other caveats that -/// depend on the specific circumstances in which the comparison is done. +/// allowed. `DnsNameRef`s do not include wildcard labels. /// /// [RFC 5280 Section 7.2]: https://tools.ietf.org/html/rfc5280#section-7.2 -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Eq, PartialEq, Hash)] pub struct DnsNameRef<'a>(pub(crate) &'a [u8]); impl AsRef<[u8]> for DnsNameRef<'_> { @@ -105,7 +98,11 @@ impl<'a> DnsNameRef<'a> { /// Constructs a `DnsNameRef` from the given input if the input is a /// syntactically-valid DNS name. pub fn try_from_ascii(dns_name: &'a [u8]) -> Result { - if !is_valid_reference_dns_id(untrusted::Input::from(dns_name)) { + if !is_valid_dns_id( + untrusted::Input::from(dns_name), + IdRole::Reference, + AllowWildcards::No, + ) { return Err(InvalidDnsNameError); } @@ -130,19 +127,18 @@ impl<'a> DnsNameRef<'a> { } } -/// Requires the `alloc` feature. -#[cfg(feature = "alloc")] impl core::fmt::Debug for DnsNameRef<'_> { fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { - let lowercase = self.clone().to_owned(); - f.debug_tuple("DnsNameRef").field(&lowercase.0).finish() - } -} + f.write_str("DnsNameRef(\"")?; -#[cfg(not(feature = "alloc"))] -impl core::fmt::Debug for DnsNameRef<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { - f.debug_tuple("DnsNameRef").field(&self.0).finish() + // Convert each byte of the underlying ASCII string to a `char` and + // downcase it prior to formatting it. We avoid self.clone().to_owned() + // since it requires allocation. + for &ch in self.0 { + f.write_char(char::from(ch).to_ascii_lowercase())?; + } + + f.write_str("\")") } } @@ -154,6 +150,94 @@ impl<'a> From> for &'a str { } } +/// A DNS name that may be either a DNS name identifier presented by a server (which may include +/// wildcards), or a DNS name identifier referenced by a client for matching purposes (wildcards +/// not permitted). +pub enum GeneralDnsNameRef<'name> { + /// a reference to a DNS name that may be used for matching purposes. + DnsName(DnsNameRef<'name>), + /// a reference to a presented DNS name that may include a wildcard. + Wildcard(WildcardDnsNameRef<'name>), +} + +impl<'a> From> for &'a str { + fn from(d: GeneralDnsNameRef<'a>) -> Self { + match d { + GeneralDnsNameRef::DnsName(name) => name.into(), + GeneralDnsNameRef::Wildcard(name) => name.into(), + } + } +} + +/// A reference to a DNS Name presented by a server that may include a wildcard. +/// +/// A `WildcardDnsNameRef` is guaranteed to be syntactically valid. The validity rules +/// are specified in [RFC 5280 Section 7.2], except that underscores are also +/// allowed. +/// +/// Additionally, while [RFC6125 Section 4.1] says that a wildcard label may be of the form +/// `*.`, where `` and/or `` may be empty, we follow a stricter policy common +/// to most validation libraries (e.g. NSS) and only accept wildcard labels that are exactly `*`. +/// +/// [RFC 5280 Section 7.2]: https://tools.ietf.org/html/rfc5280#section-7.2 +/// [RFC 6125 Section 4.1]: https://www.rfc-editor.org/rfc/rfc6125#section-4.1 +#[derive(Clone, Copy, Eq, PartialEq, Hash)] +pub struct WildcardDnsNameRef<'a>(&'a [u8]); + +impl<'a> WildcardDnsNameRef<'a> { + /// Constructs a `WildcardDnsNameRef` from the given input if the input is a + /// syntactically-valid DNS name. + pub fn try_from_ascii(dns_name: &'a [u8]) -> Result { + if !is_valid_dns_id( + untrusted::Input::from(dns_name), + IdRole::Reference, + AllowWildcards::Yes, + ) { + return Err(InvalidDnsNameError); + } + + Ok(Self(dns_name)) + } + + /// Constructs a `WildcardDnsNameRef` from the given input if the input is a + /// syntactically-valid DNS name. + pub fn try_from_ascii_str(dns_name: &'a str) -> Result { + Self::try_from_ascii(dns_name.as_bytes()) + } +} + +impl core::fmt::Debug for WildcardDnsNameRef<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { + f.write_str("WildcardDnsNameRef(\"")?; + + // Convert each byte of the underlying ASCII string to a `char` and + // downcase it prior to formatting it. We avoid self.to_owned() since + // it requires allocation. + for &ch in self.0 { + f.write_char(char::from(ch).to_ascii_lowercase())?; + } + + f.write_str("\")") + } +} + +impl<'a> From> for &'a str { + fn from(WildcardDnsNameRef(d): WildcardDnsNameRef<'a>) -> Self { + // The unwrap won't fail because WildcardDnsNameRef are guaranteed to be ASCII + // and ASCII is a subset of UTF-8. + core::str::from_utf8(d).unwrap() + } +} + +impl AsRef for WildcardDnsNameRef<'_> { + #[inline] + fn as_ref(&self) -> &str { + // The unwrap won't fail because WildcardDnsNameRef are guaranteed to be ASCII + // and ASCII is a subset of UTF-8. + core::str::from_utf8(self.0).unwrap() + } +} + pub(super) fn presented_id_matches_reference_id( presented_dns_id: untrusted::Input, reference_dns_id: untrusted::Input, @@ -445,10 +529,6 @@ enum IdRole { NameConstraint, } -fn is_valid_reference_dns_id(hostname: untrusted::Input) -> bool { - is_valid_dns_id(hostname, IdRole::Reference, AllowWildcards::No) -} - // https://tools.ietf.org/html/rfc5280#section-4.2.1.6: // // When the subjectAltName extension contains a domain name system diff --git a/src/subject_name/mod.rs b/src/subject_name/mod.rs index a27d5920..33d64c66 100644 --- a/src/subject_name/mod.rs +++ b/src/subject_name/mod.rs @@ -13,6 +13,8 @@ // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. mod dns_name; +#[cfg(feature = "alloc")] +pub(crate) use dns_name::GeneralDnsNameRef; pub use dns_name::{DnsNameRef, InvalidDnsNameError}; /// Requires the `alloc` feature. @@ -29,6 +31,8 @@ pub use ip_address::{AddrParseError, IpAddrRef}; pub use ip_address::IpAddr; mod verify; +#[cfg(feature = "alloc")] +pub(super) use verify::list_cert_dns_names; pub(super) use verify::{ check_name_constraints, verify_cert_subject_name, SubjectCommonNameContents, }; diff --git a/src/subject_name/verify.rs b/src/subject_name/verify.rs index 0f50e8a0..a2bfc7b5 100644 --- a/src/subject_name/verify.rs +++ b/src/subject_name/verify.rs @@ -21,6 +21,11 @@ use crate::{ cert::{Cert, EndEntityOrCa}, der, Error, }; +#[cfg(feature = "alloc")] +use { + alloc::vec::Vec, + dns_name::{GeneralDnsNameRef, WildcardDnsNameRef}, +}; pub(crate) fn verify_cert_dns_name( cert: &crate::EndEntityCert, @@ -33,7 +38,7 @@ pub(crate) fn verify_cert_dns_name( cert.subject_alt_name, SubjectCommonNameContents::Ignore, Err(Error::CertNotValidForName), - &|name| { + &mut |name| { if let GeneralName::DnsName(presented_id) = name { match dns_name::presented_id_matches_reference_id(presented_id, dns_name) { Some(true) => return NameIteration::Stop(Ok(())), @@ -67,7 +72,7 @@ pub(crate) fn verify_cert_subject_name( cert.inner().subject_alt_name, SubjectCommonNameContents::Ignore, Err(Error::CertNotValidForName), - &|name| { + &mut |name| { if let GeneralName::IpAddress(presented_id) = name { match ip_address::presented_id_matches_reference_id(presented_id, ip_address) { Ok(true) => return NameIteration::Stop(Ok(())), @@ -115,7 +120,7 @@ pub(crate) fn check_name_constraints( child.subject_alt_name, subject_common_name_contents, Ok(()), - &|name| { + &mut |name| { check_presented_id_conforms_to_constraints( name, permitted_subtrees, @@ -302,12 +307,12 @@ pub(crate) enum SubjectCommonNameContents { Ignore, } -fn iterate_names( - subject: Option, - subject_alt_name: Option, +fn iterate_names<'names>( + subject: Option>, + subject_alt_name: Option>, subject_common_name_contents: SubjectCommonNameContents, result_if_never_stopped_early: Result<(), Error>, - f: &dyn Fn(GeneralName) -> NameIteration, + f: &mut impl FnMut(GeneralName<'names>) -> NameIteration, ) -> Result<(), Error> { if let Some(subject_alt_name) = subject_alt_name { let mut subject_alt_name = untrusted::Reader::new(subject_alt_name); @@ -351,6 +356,39 @@ fn iterate_names( } } +#[cfg(feature = "alloc")] +pub(crate) fn list_cert_dns_names<'names>( + cert: &'names crate::EndEntityCert<'names>, +) -> Result>, Error> { + let cert = &cert.inner(); + let mut names = Vec::new(); + + iterate_names( + Some(cert.subject), + cert.subject_alt_name, + SubjectCommonNameContents::DnsName, + Ok(()), + &mut |name| { + if let GeneralName::DnsName(presented_id) = name { + let dns_name = DnsNameRef::try_from_ascii(presented_id.as_slice_less_safe()) + .map(GeneralDnsNameRef::DnsName) + .or_else(|_| { + WildcardDnsNameRef::try_from_ascii(presented_id.as_slice_less_safe()) + .map(GeneralDnsNameRef::Wildcard) + }); + + // if the name could be converted to a DNS name, add it; otherwise, + // keep going. + if let Ok(name) = dns_name { + names.push(name) + } + } + NameIteration::KeepGoing + }, + ) + .map(|_| names.into_iter()) +} + // It is *not* valid to derive `Eq`, `PartialEq, etc. for this type. In // particular, for the types of `GeneralName`s that we don't understand, we // don't even store the value. Also, the meaning of a `GeneralName` in a name diff --git a/tests/integration.rs b/tests/integration.rs index 713cef20..ae88b8c4 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -206,3 +206,118 @@ fn read_ee_with_large_pos_serial() { fn time_constructor() { let _ = webpki::Time::try_from(std::time::SystemTime::now()).unwrap(); } + +#[cfg(feature = "alloc")] +#[test] +pub fn list_netflix_names() { + let ee = include_bytes!("netflix/ee.der"); + + expect_cert_dns_names( + ee, + &[ + "account.netflix.com", + "ca.netflix.com", + "netflix.ca", + "netflix.com", + "signup.netflix.com", + "www.netflix.ca", + "www1.netflix.com", + "www2.netflix.com", + "www3.netflix.com", + "develop-stage.netflix.com", + "release-stage.netflix.com", + "www.netflix.com", + ], + ); +} + +#[cfg(feature = "alloc")] +#[test] +pub fn invalid_subject_alt_names() { + // same as netflix ee certificate, but with the last name in the list + // changed to 'www.netflix:com' + let data = include_bytes!("misc/invalid_subject_alternative_name.der"); + + expect_cert_dns_names( + data, + &[ + "account.netflix.com", + "ca.netflix.com", + "netflix.ca", + "netflix.com", + "signup.netflix.com", + "www.netflix.ca", + "www1.netflix.com", + "www2.netflix.com", + "www3.netflix.com", + "develop-stage.netflix.com", + "release-stage.netflix.com", + // NOT 'www.netflix:com' + ], + ); +} + +#[cfg(feature = "alloc")] +#[test] +pub fn wildcard_subject_alternative_names() { + // same as netflix ee certificate, but with the last name in the list + // changed to 'ww*.netflix:com' + let data = include_bytes!("misc/dns_names_and_wildcards.der"); + + expect_cert_dns_names( + data, + &[ + "account.netflix.com", + "*.netflix.com", + "netflix.ca", + "netflix.com", + "signup.netflix.com", + "www.netflix.ca", + "www1.netflix.com", + "www2.netflix.com", + "www3.netflix.com", + "develop-stage.netflix.com", + "release-stage.netflix.com", + "www.netflix.com", + ], + ); +} + +#[cfg(feature = "alloc")] +fn expect_cert_dns_names(data: &[u8], expected_names: &[&str]) { + use std::collections::HashSet; + + let cert = webpki::EndEntityCert::try_from(data) + .expect("should parse end entity certificate correctly"); + + let expected_names: HashSet<_> = expected_names.iter().cloned().collect(); + + let mut actual_names = cert + .dns_names() + .expect("should get all DNS names correctly for end entity cert") + .collect::>(); + + // Ensure that converting the list to a set doesn't throw away + // any duplicates that aren't supposed to be there + assert_eq!(actual_names.len(), expected_names.len()); + + let actual_names: std::collections::HashSet<&str> = + actual_names.drain(..).map(|name| name.into()).collect(); + + assert_eq!(actual_names, expected_names); +} + +#[cfg(feature = "alloc")] +#[test] +pub fn no_subject_alt_names() { + let data = include_bytes!("misc/no_subject_alternative_name.der"); + + let cert = webpki::EndEntityCert::try_from(&data[..]) + .expect("should parse end entity certificate correctly"); + + let names = cert + .dns_names() + .expect("we should get a result even without subjectAltNames"); + + assert!(names.collect::>().is_empty()); +} diff --git a/tests/misc/dns_names_and_wildcards.der b/tests/misc/dns_names_and_wildcards.der new file mode 100644 index 0000000000000000000000000000000000000000..c41e2d615ad05f5f3118de8796c2ced68937e118 GIT binary patch literal 1204 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 NI)mDO*4otA`2b#TM-~78 literal 0 HcmV?d00001