diff --git a/Cargo.lock b/Cargo.lock index 7d4869c8..a6ba6dd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dfdb4953a096c551ce9ace855a604d702e6e62d77fac690575ae347571717f5" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -450,8 +459,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rcgen" version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +source = "git+https://github.com/sfackler/rcgen.git?branch=explicit-dn#2073ba59f66fceba930189b2a910cc80a5860ae9" dependencies = [ "aws-lc-rs", "rustls-pki-types", @@ -1042,10 +1050,11 @@ dependencies = [ [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec", "time", ] diff --git a/Cargo.toml b/Cargo.toml index ad2cfecf..ce5181e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" x509-parser = "0.18" +[patch.crates-io] +rcgen = { git = "https://github.com/sfackler/rcgen.git", branch = "explicit-dn" } + [profile.bench] opt-level = 3 debug = false diff --git a/src/der.rs b/src/der.rs index 72fae967..274e9aff 100644 --- a/src/der.rs +++ b/src/der.rs @@ -65,9 +65,15 @@ pub(crate) enum Tag { OctetString = 0x04, OID = 0x06, Enum = 0x0A, + UTF8String = 0x0C, Sequence = CONSTRUCTED | 0x10, // 0x30 + Set = CONSTRUCTED | 0x11, // 0x31 + PrintableString = 0x13, + IA5String = 0x16, UTCTime = 0x17, GeneralizedTime = 0x18, + UniversalString = 0x1C, + BMPString = 0x1E, #[expect(clippy::identity_op)] ContextSpecificConstructed0 = CONTEXT_SPECIFIC | CONSTRUCTED | 0, @@ -95,6 +101,15 @@ impl From for u8 { } // XXX: narrowing conversion. } +impl Tag { + /// Equivalent to `u8::from(self)` but usable in `const` context, since + /// `From` is not const. + #[expect(clippy::as_conversions)] + pub(crate) const fn as_u8(self) -> u8 { + self as u8 + } +} + #[inline(always)] pub(crate) fn expect_tag_and_get_value_limited<'a>( input: &mut untrusted::Reader<'a>, diff --git a/src/subject_name/directory_name.rs b/src/subject_name/directory_name.rs new file mode 100644 index 00000000..09c64775 --- /dev/null +++ b/src/subject_name/directory_name.rs @@ -0,0 +1,642 @@ +// Copyright 2026 webpki Authors. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use crate::der::{self, Tag}; +use crate::error::Error; + +// Real DNs hold one AVA per RDN, occasionally a few. 16 is well above any sane +// observed value while still fitting a `u32` matched-bitmap. +const MAX_AVAS: usize = 16; + +const UTF8_STRING_TAG: u8 = Tag::UTF8String.as_u8(); +const PRINTABLE_STRING_TAG: u8 = Tag::PrintableString.as_u8(); +const IA5_STRING_TAG: u8 = Tag::IA5String.as_u8(); +const UNIVERSAL_STRING_TAG: u8 = Tag::UniversalString.as_u8(); +const BMP_STRING_TAG: u8 = Tag::BMPString.as_u8(); + +// X.680 §31.2.7 promotes tags on untagged CHOICE types to EXPLICIT, so the +// `[4]` content of a directoryName GeneralName is a SEQUENCE wrapping the +// RDNSequence. Strip the wrapper to expose the RDNSequence content. +pub(super) fn strip_explicit_sequence( + value: untrusted::Input<'_>, +) -> Result, Error> { + let mut reader = untrusted::Reader::new(value); + let rdn_sequence = der::expect_tag(&mut reader, Tag::Sequence)?; + if !reader.at_end() { + return Err(Error::BadDer); + } + Ok(rdn_sequence) +} + +// https://tools.ietf.org/html/rfc5280#section-7.1 says: +// A distinguished name DN1 is within the subtree defined by the +// distinguished name DN2 if DN1 contains at least as many RDNs as DN2, +// and DN1 and DN2 are a match when trailing RDNs in DN1 are ignored. +// +// Both inputs are RDNSequence content (no outer SEQUENCE tag). Callers +// holding `[4]` directoryName GeneralName bytes should run them through +// `strip_explicit_sequence` first; `Cert::subject` is already RDNSequence +// content. +pub(super) fn presented_directory_name_matches_constraint( + presented: untrusted::Input<'_>, + constraint: untrusted::Input<'_>, +) -> Result { + if constraint.is_empty() { + return Ok(true); + } + if presented.is_empty() { + return Ok(false); + } + let mut presented = untrusted::Reader::new(presented); + let mut constraint = untrusted::Reader::new(constraint); + while !constraint.at_end() { + if presented.at_end() { + return Ok(false); + } + let p_rdn = der::expect_tag(&mut presented, Tag::Set)?; + let c_rdn = der::expect_tag(&mut constraint, Tag::Set)?; + if !rdn_eq(p_rdn, c_rdn)? { + return Ok(false); + } + } + Ok(true) +} + +// https://tools.ietf.org/html/rfc5280#section-7.1 says: +// Two relative distinguished names RDN1 and RDN2 match if they have the +// same number of naming attributes and for each naming attribute in RDN1 +// there is a matching naming attribute in RDN2. +// +// The bitmap tracks which `b` AVAs have already been claimed so that +// duplicate AVAs in `a` cannot match the same `b` AVA twice. +fn rdn_eq(a: untrusted::Input<'_>, b: untrusted::Input<'_>) -> Result { + let mut a_avas: [Option>; MAX_AVAS] = [None; MAX_AVAS]; + let mut b_avas: [Option>; MAX_AVAS] = [None; MAX_AVAS]; + let a_n = collect_avas(a, &mut a_avas)?; + let b_n = collect_avas(b, &mut b_avas)?; + if a_n != b_n { + return Ok(false); + } + + let mut matched: u32 = 0; + for a_ava in a_avas.iter().take(a_n).map(|opt| opt.unwrap()) { + let mut found = false; + for (j, b_ava) in b_avas.iter().take(b_n).map(|opt| opt.unwrap()).enumerate() { + if matched & (1 << j) != 0 { + continue; + } + if ava_eq(a_ava, b_ava)? { + matched |= 1 << j; + found = true; + break; + } + } + if !found { + return Ok(false); + } + } + Ok(true) +} + +fn collect_avas<'a>( + rdn: untrusted::Input<'a>, + out: &mut [Option>; MAX_AVAS], +) -> Result { + let mut reader = untrusted::Reader::new(rdn); + let mut count = 0; + while !reader.at_end() { + if count >= MAX_AVAS { + return Err(Error::BadDer); + } + out[count] = Some(der::expect_tag(&mut reader, Tag::Sequence)?); + count += 1; + } + Ok(count) +} + +// And https://tools.ietf.org/html/rfc5280#section-7.1 says: +// Two naming attributes match if the attribute types are the same and +// the values of the attributes are an exact match after processing with +// the string preparation algorithm. +fn ava_eq(a: untrusted::Input<'_>, b: untrusted::Input<'_>) -> Result { + let mut a = untrusted::Reader::new(a); + let mut b = untrusted::Reader::new(b); + let a_oid = der::expect_tag(&mut a, Tag::OID)?; + let b_oid = der::expect_tag(&mut b, Tag::OID)?; + if a_oid.as_slice_less_safe() != b_oid.as_slice_less_safe() { + return Ok(false); + } + let (a_tag, a_value) = der::read_tag_and_get_value(&mut a)?; + let (b_tag, b_value) = der::read_tag_and_get_value(&mut b)?; + if !a.at_end() || !b.at_end() { + return Err(Error::BadDer); + } + Ok(ava_value_eq( + a_tag, + a_value.as_slice_less_safe(), + b_tag, + b_value.as_slice_less_safe(), + )) +} + +fn ava_value_eq(a_tag: u8, a: &[u8], b_tag: u8, b: &[u8]) -> bool { + if is_normalizable_string(a_tag) && is_normalizable_string(b_tag) { + return normalized_string_eq(a_tag, a, b_tag, b); + } + a_tag == b_tag && a == b +} + +fn is_normalizable_string(tag: u8) -> bool { + matches!( + tag, + UTF8_STRING_TAG + | PRINTABLE_STRING_TAG + | IA5_STRING_TAG + | UNIVERSAL_STRING_TAG + | BMP_STRING_TAG + ) +} + +// https://tools.ietf.org/html/rfc5280#section-7.1 says: +// Conforming implementations MUST use the LDAP StringPrep profile +// (including insignificant space handling), as specified in [RFC4518], +// as the basis for comparison of distinguished name attributes encoded +// in either PrintableString or UTF8String. +// +// We do not implement RFC 4518 in full — Unicode case folding (RFC 3454 +// Appendix B.2), NFKC normalization, the full RFC 3454 prohibit list, and +// a BiDi check would each pull in substantial Unicode tables. The inputs +// that exercise those steps don't appear in WebPKI in practice: +// directoryName name constraints are rare, and non-ASCII DN values inside +// them are rarer still. Mirroring BoringSSL pki, we write just enough to +// handle the inputs that exist in the wild and stop there: ASCII-only +// case folding and insignificant-space handling, plus a small subset of +// step 1 (Transcode) and step 4 (Prohibit) checks (see `CodePoints` and +// `decode_unicode_scalar`). Where we report a match for a given pair of +// valid Unicode strings, RFC 4518 applied to the same content would too. +fn normalized_string_eq(a_tag: u8, a: &[u8], b_tag: u8, b: &[u8]) -> bool { + let (Some(a_iter), Some(b_iter)) = (CodePoints::new(a_tag, a), CodePoints::new(b_tag, b)) + else { + return false; + }; + let mut a = Normalizer::new(a_iter); + let mut b = Normalizer::new(b_iter); + loop { + match (a.next(), b.next()) { + (None, None) => return true, + (Some(Ok(x)), Some(Ok(y))) if x == y => continue, + _ => return false, + } + } +} + +// Streaming code-point iterator over a string-typed AVA value. `new` +// validates the encoding (https://tools.ietf.org/html/rfc4518#section-2.1 +// "Transcode"); `next` rejects surrogates (via `char::from_u32`) and the +// Unicode noncharacters listed in RFC 3454 Table C.4 — the slice of RFC +// 4518 step 4 ("Prohibit") we implement. +enum CodePoints<'a> { + /// PrintableString or IA5String: each byte is one code point. + Latin1(&'a [u8]), + /// UTF8String: pre-validated; iterate decoded chars. + Utf8(core::str::Chars<'a>), + /// BMPString: 2-byte big-endian units. + Bmp(&'a [u8]), + /// UniversalString: 4-byte big-endian units. + Universal(&'a [u8]), +} + +impl<'a> CodePoints<'a> { + fn new(tag: u8, bytes: &'a [u8]) -> Option { + match tag { + PRINTABLE_STRING_TAG => bytes + .iter() + .all(|&b| is_printable_string_byte(b)) + .then_some(Self::Latin1(bytes)), + IA5_STRING_TAG => bytes.is_ascii().then_some(Self::Latin1(bytes)), + UTF8_STRING_TAG => Some(Self::Utf8(core::str::from_utf8(bytes).ok()?.chars())), + BMP_STRING_TAG => (bytes.len() % 2 == 0).then_some(Self::Bmp(bytes)), + UNIVERSAL_STRING_TAG => (bytes.len() % 4 == 0).then_some(Self::Universal(bytes)), + _ => None, + } + } +} + +impl Iterator for CodePoints<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + match self { + Self::Latin1(bytes) => { + let (head, rest) = bytes.split_first()?; + *bytes = rest; + Some(Ok(char::from(*head))) + } + Self::Utf8(chars) => chars.next().map(Ok), + Self::Bmp(bytes) => { + let (head, rest) = bytes.split_first_chunk::<2>()?; + *bytes = rest; + Some(decode_unicode_scalar(u32::from(u16::from_be_bytes(*head))).ok_or(())) + } + Self::Universal(bytes) => { + let (head, rest) = bytes.split_first_chunk::<4>()?; + *bytes = rest; + Some(decode_unicode_scalar(u32::from_be_bytes(*head)).ok_or(())) + } + } + } +} + +// X.680 PrintableString charset: alphanumerics, space, and the symbols +// below. `*` is *not* in the set. +fn is_printable_string_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() + || matches!( + b, + b' ' | b'\'' | b'(' | b')' | b'+' | b',' | b'-' | b'.' | b'/' | b':' | b'=' | b'?' + ) +} + +// Decode a raw scalar from BMPString/UniversalString. Rejects surrogates +// and out-of-range values (via `char::from_u32`) plus the Unicode +// noncharacters listed in RFC 3454 Table C.4. +fn decode_unicode_scalar(cp: u32) -> Option { + let c = char::from_u32(cp)?; + let v = u32::from(c); + if (v & 0xFFFE) == 0xFFFE || (0xFDD0..=0xFDEF).contains(&v) { + return None; + } + Some(c) +} + +// https://tools.ietf.org/html/rfc4518#section-2.6.1 ("Insignificant Space +// Handling") describes prep as: leading/trailing space collapsed to one +// SPACE and inner runs of spaces collapsed to two SPACEs. We implement an +// equivalent matching rule — trim leading/trailing whitespace and collapse +// internal runs to one SPACE — which yields the same answer when both +// sides go through the same normalization. +// +// Combined with ASCII A–Z to a–z folding, this is the ASCII-only +// approximation of caseIgnoreMatch + insignificant-space handling. +struct Normalizer<'a> { + inner: CodePoints<'a>, + state: NormState, + buffered: Option, +} + +#[derive(PartialEq)] +enum NormState { + Leading, + Content, + PendingWs, + Done, +} + +impl<'a> Normalizer<'a> { + fn new(inner: CodePoints<'a>) -> Self { + Self { + inner, + state: NormState::Leading, + buffered: None, + } + } +} + +impl Iterator for Normalizer<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + if let Some(c) = self.buffered.take() { + self.state = NormState::Content; + return Some(Ok(c)); + } + loop { + if self.state == NormState::Done { + return None; + } + let c = match self.inner.next() { + None => { + self.state = NormState::Done; + return None; + } + Some(Err(e)) => { + self.state = NormState::Done; + return Some(Err(e)); + } + Some(Ok(c)) => c, + }; + let is_ws = is_ascii_ws(c); + match (&self.state, is_ws) { + (NormState::Leading, true) => continue, + (NormState::Leading, false) => { + self.state = NormState::Content; + return Some(Ok(c.to_ascii_lowercase())); + } + (NormState::Content, true) => { + self.state = NormState::PendingWs; + continue; + } + (NormState::Content, false) => return Some(Ok(c.to_ascii_lowercase())), + (NormState::PendingWs, true) => continue, + (NormState::PendingWs, false) => { + // Emit a single space now; defer the just-read code point + // until the next call. + self.buffered = Some(c.to_ascii_lowercase()); + return Some(Ok(' ')); + } + (NormState::Done, _) => return None, + } + } + } +} + +// Wider than `char::is_ascii_whitespace` (which excludes U+000B); covers +// the C0 whitespace family that RFC 4518 §2.2 ("Map") folds to SPACE +// before the insignificant-space step. +fn is_ascii_ws(c: char) -> bool { + matches!(c, '\t'..='\r' | ' ') +} + +#[cfg(all(test, feature = "alloc"))] +mod tests { + use alloc::vec; + use alloc::vec::Vec; + + use super::*; + + // countryName = 2.5.4.6 + const OID_C: &[u8] = &[0x55, 0x04, 0x06]; + // organizationName = 2.5.4.10 + const OID_O: &[u8] = &[0x55, 0x04, 0x0A]; + + fn ava(oid: &[u8], val_tag: u8, val: &[u8]) -> Vec { + let mut inner = Vec::new(); + inner.push(0x06); + inner.push(u8::try_from(oid.len()).unwrap()); + inner.extend_from_slice(oid); + inner.push(val_tag); + inner.push(u8::try_from(val.len()).unwrap()); + inner.extend_from_slice(val); + let mut out = vec![0x30, u8::try_from(inner.len()).unwrap()]; + out.extend(inner); + out + } + + fn rdn(avas: &[Vec]) -> Vec { + let mut content = Vec::new(); + for ava in avas { + content.extend_from_slice(ava); + } + let mut out = vec![0x31, u8::try_from(content.len()).unwrap()]; + out.extend(content); + out + } + + fn dn(rdns: &[Vec]) -> Vec { + let mut out = Vec::new(); + for r in rdns { + out.extend_from_slice(r); + } + out + } + + fn matches(presented: &[u8], constraint: &[u8]) -> Result { + presented_directory_name_matches_constraint( + untrusted::Input::from(presented), + untrusted::Input::from(constraint), + ) + } + + #[test] + fn empty_constraint_matches_anything() { + assert_eq!(matches(&[], &[]), Ok(true)); + let dn = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + assert_eq!(matches(&dn, &[]), Ok(true)); + } + + #[test] + fn empty_presented_does_not_match_non_empty_constraint() { + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + assert_eq!(matches(&[], &constraint), Ok(false)); + } + + #[test] + fn exact_match() { + let d = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + assert_eq!(matches(&d, &d), Ok(true)); + } + + #[test] + fn proper_prefix_match() { + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + let presented = dn(&[ + rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")]), + rdn(&[ava(OID_O, UTF8_STRING_TAG, b"Foo")]), + ]); + assert_eq!(matches(&presented, &constraint), Ok(true)); + } + + #[test] + fn presented_shorter_than_constraint_does_not_match() { + let constraint = dn(&[ + rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")]), + rdn(&[ava(OID_O, UTF8_STRING_TAG, b"Foo")]), + ]); + let presented = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn different_value_does_not_match() { + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + let presented = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"DE")])]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn cross_type_string_match() { + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + let presented = dn(&[rdn(&[ava(OID_C, UTF8_STRING_TAG, b"US")])]); + assert_eq!(matches(&presented, &constraint), Ok(true)); + } + + #[test] + fn case_fold_match() { + let constraint = dn(&[rdn(&[ava(OID_O, UTF8_STRING_TAG, b"Foo")])]); + let presented = dn(&[rdn(&[ava(OID_O, PRINTABLE_STRING_TAG, b"FOO")])]); + assert_eq!(matches(&presented, &constraint), Ok(true)); + } + + #[test] + fn whitespace_normalization_match() { + let constraint = dn(&[rdn(&[ava(OID_O, UTF8_STRING_TAG, b"foo bar")])]); + let presented = dn(&[rdn(&[ava(OID_O, UTF8_STRING_TAG, b" Foo Bar ")])]); + assert_eq!(matches(&presented, &constraint), Ok(true)); + } + + #[test] + fn multivalued_rdn_reordered_match() { + let a = ava(OID_C, PRINTABLE_STRING_TAG, b"US"); + let b = ava(OID_O, UTF8_STRING_TAG, b"Foo"); + let constraint = dn(&[rdn(&[a.clone(), b.clone()])]); + let presented = dn(&[rdn(&[b, a])]); + assert_eq!(matches(&presented, &constraint), Ok(true)); + } + + #[test] + fn multivalued_rdn_count_mismatch_does_not_match() { + let a = ava(OID_C, PRINTABLE_STRING_TAG, b"US"); + let b = ava(OID_O, UTF8_STRING_TAG, b"Foo"); + let constraint = dn(&[rdn(core::slice::from_ref(&a))]); + let presented = dn(&[rdn(&[a, b])]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn bmp_string_matches_printable_string() { + // BMPString "US" = 00 55 00 53 + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + let presented = dn(&[rdn(&[ava( + OID_C, + BMP_STRING_TAG, + &[0x00, 0x55, 0x00, 0x53], + )])]); + assert_eq!(matches(&presented, &constraint), Ok(true)); + } + + #[test] + fn bmp_string_surrogate_does_not_match() { + // 0xD800 is a high surrogate — illegal in BMPString. + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + let presented = dn(&[rdn(&[ava(OID_C, BMP_STRING_TAG, &[0xD8, 0x00])])]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn universal_string_match() { + // UniversalString "US" = 00 00 00 55 00 00 00 53 + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"US")])]); + let presented = dn(&[rdn(&[ava( + OID_C, + UNIVERSAL_STRING_TAG, + &[0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x53], + )])]); + assert_eq!(matches(&presented, &constraint), Ok(true)); + } + + #[test] + fn teletex_string_binary_only() { + // TeletexString matches only against TeletexString with identical bytes. + let teletex_us = ava(OID_C, 0x14, b"US"); + let printable_us = ava(OID_C, PRINTABLE_STRING_TAG, b"US"); + assert_eq!( + matches( + &dn(&[rdn(core::slice::from_ref(&teletex_us))]), + &dn(&[rdn(core::slice::from_ref(&teletex_us))]), + ), + Ok(true) + ); + assert_eq!( + matches(&dn(&[rdn(&[teletex_us])]), &dn(&[rdn(&[printable_us])])), + Ok(false) + ); + } + + #[test] + fn different_oid_does_not_match() { + let constraint = dn(&[rdn(&[ava(OID_C, PRINTABLE_STRING_TAG, b"Foo")])]); + let presented = dn(&[rdn(&[ava(OID_O, PRINTABLE_STRING_TAG, b"Foo")])]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn malformed_rdn_is_bad_der() { + // SET tag with truncated content. + let bad = vec![0x31, 0x05, 0x30, 0x0A, 0x00, 0x00]; + assert_eq!(matches(&bad, &bad), Err(Error::BadDer)); + } + + #[test] + fn malformed_printable_string_does_not_match() { + // `!` is not in the X.680 PrintableString charset. + let bad = ava(OID_O, PRINTABLE_STRING_TAG, b"foo!"); + let good = ava(OID_O, UTF8_STRING_TAG, b"foo!"); + let constraint = dn(&[rdn(core::slice::from_ref(&bad))]); + let presented = dn(&[rdn(core::slice::from_ref(&good))]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + // And it's not equal to itself either: validation rejects the value + // before normalization runs. + assert_eq!(matches(&dn(&[rdn(&[bad])]), &constraint), Ok(false)); + } + + #[test] + fn ia5_string_with_high_byte_does_not_match() { + let bad = ava(OID_O, IA5_STRING_TAG, b"foo\xC3\x9F"); // 'ß' as UTF-8 + let other = ava(OID_O, UTF8_STRING_TAG, b"foo\xC3\x9F"); + let constraint = dn(&[rdn(core::slice::from_ref(&bad))]); + let presented = dn(&[rdn(core::slice::from_ref(&other))]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn invalid_utf8_string_does_not_match() { + // 0xC3 0x28 is an invalid UTF-8 sequence (lone lead byte). + let bad = ava(OID_O, UTF8_STRING_TAG, b"\xC3\x28"); + let constraint = dn(&[rdn(core::slice::from_ref(&bad))]); + let presented = dn(&[rdn(core::slice::from_ref(&bad))]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn bmp_string_odd_length_does_not_match() { + // BMPString must be a multiple of 2 bytes. + let bad = ava(OID_O, BMP_STRING_TAG, &[0x00, 0x55, 0x00]); + let constraint = dn(&[rdn(core::slice::from_ref(&bad))]); + let presented = dn(&[rdn(core::slice::from_ref(&bad))]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn bmp_string_noncharacter_does_not_match() { + // U+FFFE is a Unicode noncharacter (RFC 3454 Table C.4). + let constraint = dn(&[rdn(&[ava(OID_O, UTF8_STRING_TAG, b"foo")])]); + let presented = dn(&[rdn(&[ava(OID_O, BMP_STRING_TAG, &[0xFF, 0xFE])])]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn universal_string_noncharacter_does_not_match() { + // U+FDD0 is a Unicode noncharacter (RFC 3454 Table C.4). + let constraint = dn(&[rdn(&[ava(OID_O, UTF8_STRING_TAG, b"foo")])]); + let presented = dn(&[rdn(&[ava( + OID_O, + UNIVERSAL_STRING_TAG, + &[0x00, 0x00, 0xFD, 0xD0], + )])]); + assert_eq!(matches(&presented, &constraint), Ok(false)); + } + + #[test] + fn normalizer_drops_leading_trailing_collapses_internal() { + let inner = CodePoints::new(UTF8_STRING_TAG, b" hello World ").unwrap(); + let out: alloc::string::String = Normalizer::new(inner).map(|r| r.unwrap()).collect(); + assert_eq!(out, "hello world"); + } + + #[test] + fn normalizer_only_whitespace_yields_empty() { + let inner = CodePoints::new(UTF8_STRING_TAG, b" \t\n ").unwrap(); + assert_eq!(Normalizer::new(inner).count(), 0); + } +} diff --git a/src/subject_name/mod.rs b/src/subject_name/mod.rs index 4adae5bf..7b63902b 100644 --- a/src/subject_name/mod.rs +++ b/src/subject_name/mod.rs @@ -21,6 +21,8 @@ use crate::der::{self, FromDer}; use crate::error::{DerTypeId, Error}; use crate::verify_cert::{Budget, PathNode}; +mod directory_name; + mod dns_name; use dns_name::IdRole; pub(crate) use dns_name::{WildcardDnsNameRef, verify_dns_names}; @@ -70,8 +72,8 @@ pub(crate) fn check_name_constraints( return Err(err); } - let result = check_presented_id_conforms_to_constraints( - GeneralName::DirectoryName, + let result = check_subject_dn_against_constraints( + path.cert.subject, permitted_subtrees, excluded_subtrees, budget, @@ -91,76 +93,31 @@ fn check_presented_id_conforms_to_constraints( excluded_subtrees: Option>, budget: &mut Budget, ) -> Option> { - let subtrees = [ - (Subtrees::Permitted, permitted_subtrees), - (Subtrees::Excluded, excluded_subtrees), - ]; - - fn general_subtree<'b>(input: &mut untrusted::Reader<'b>) -> Result, Error> { - der::read_all(der::expect_tag(input, der::Tag::Sequence)?) - } - - for (subtrees, constraints) in subtrees { - let mut constraints = match constraints { - Some(constraints) => untrusted::Reader::new(constraints), - None => continue, - }; - - let mut has_permitted_subtrees_match = false; - let mut has_permitted_subtrees_mismatch = false; - while !constraints.at_end() { - if let Err(e) = budget.consume_name_constraint_comparison() { - return Some(Err(e)); - } - - // http://tools.ietf.org/html/rfc5280#section-4.2.1.10: "Within this - // profile, the minimum and maximum fields are not used with any name - // forms, thus, the minimum MUST be zero, and maximum MUST be absent." - // - // Since the default value isn't allowed to be encoded according to the - // DER encoding rules for DEFAULT, this is equivalent to saying that - // neither minimum or maximum must be encoded. - let base = match general_subtree(&mut constraints) { - Ok(base) => base, - Err(err) => return Some(Err(err)), - }; - + walk_constraints( + permitted_subtrees, + excluded_subtrees, + budget, + |base, kind| { // Avoid having a catch-all branch here which might fail open on new variants - let matches = match (name, base) { + match (name, base) { (GeneralName::DnsName(name), GeneralName::DnsName(base)) => { - dns_name::presented_id_matches_reference_id( + Some(dns_name::presented_id_matches_reference_id( name, - IdRole::NameConstraint(subtrees), + IdRole::NameConstraint(kind), base, - ) + )) } - (GeneralName::DnsName(_), _) => continue, - - (GeneralName::DirectoryName, GeneralName::DirectoryName) => Ok( - // Reject any uses of directory name constraints; we don't implement this. - // - // Rejecting everything technically confirms to RFC5280: - // - // "If a name constraints extension that is marked as critical imposes constraints - // on a particular name form, and an instance of that name form appears in the - // subject field or subjectAltName extension of a subsequent certificate, then - // the application MUST either process the constraint or _reject the certificate_." - // - // TODO: rustls/webpki#19 - // - // Rejection is achieved by not matching any PermittedSubtrees, and matching all - // ExcludedSubtrees. - match subtrees { - Subtrees::Permitted => false, - Subtrees::Excluded => true, - }, - ), - (GeneralName::DirectoryName, _) => continue, + (GeneralName::DnsName(_), _) => None, + + (GeneralName::DirectoryName(presented), GeneralName::DirectoryName(base)) => { + Some(directory_name_match(presented, base)) + } + (GeneralName::DirectoryName(_), _) => None, (GeneralName::IpAddress(name), GeneralName::IpAddress(base)) => { - ip_address::presented_id_matches_constraint(name, base) + Some(ip_address::presented_id_matches_constraint(name, base)) } - (GeneralName::IpAddress(_), _) => continue, + (GeneralName::IpAddress(_), _) => None, // We currently don't support URI constraints -- fail closed for now. // @@ -169,11 +126,11 @@ fn check_presented_id_conforms_to_constraints( ( GeneralName::UniformResourceIdentifier(_), GeneralName::UniformResourceIdentifier(_), - ) => Ok(match subtrees { + ) => Some(Ok(match kind { Subtrees::Permitted => false, Subtrees::Excluded => true, - }), - (GeneralName::UniformResourceIdentifier(_), _) => continue, + })), + (GeneralName::UniformResourceIdentifier(_), _) => None, // RFC 4280 says "If a name constraints extension that is marked as // critical imposes constraints on a particular name form, and an @@ -186,12 +143,109 @@ fn check_presented_id_conforms_to_constraints( (GeneralName::Unsupported(name_tag), GeneralName::Unsupported(base_tag)) if name_tag == base_tag => { - Err(Error::NameConstraintViolation) + Some(Err(Error::NameConstraintViolation)) } - (GeneralName::Unsupported(_), _) => continue, + (GeneralName::Unsupported(_), _) => None, + } + }, + ) +} + +// Both inputs are `[4]` directoryName GeneralName values (EXPLICIT-form bytes, +// SEQUENCE-wrapped); strip the wrapper before handing the RDNSequence content +// to the matcher. +fn directory_name_match( + presented: untrusted::Input<'_>, + base: untrusted::Input<'_>, +) -> Result { + let presented = directory_name::strip_explicit_sequence(presented)?; + let base = directory_name::strip_explicit_sequence(base)?; + directory_name::presented_directory_name_matches_constraint(presented, base) +} + +// Subject DN matching against name constraints. Distinct from +// `check_presented_id_conforms_to_constraints` because the cert's subject DN +// is naturally stored as RDNSequence content (no SEQUENCE wrapper) and only +// needs to be checked against directoryName constraints. +fn check_subject_dn_against_constraints( + subject_rdn_sequence: untrusted::Input<'_>, + permitted_subtrees: Option>, + excluded_subtrees: Option>, + budget: &mut Budget, +) -> Option> { + walk_constraints( + permitted_subtrees, + excluded_subtrees, + budget, + |base, _kind| { + let GeneralName::DirectoryName(base_value) = base else { + return None; }; + Some( + directory_name::strip_explicit_sequence(base_value).and_then(|base_rdn| { + directory_name::presented_directory_name_matches_constraint( + subject_rdn_sequence, + base_rdn, + ) + }), + ) + }, + ) +} + +// Iterates over the `permittedSubtrees` and `excludedSubtrees` GeneralSubtrees +// of a NameConstraints extension, enforcing the per-subtree match-state rules +// from RFC 5280 §4.2.1.10. The `match_constraint` callback decides, for each +// constraint, whether it is applicable to the presented name (returns `Some`) +// and if so whether it is satisfied (`Ok(true)`). +fn walk_constraints( + permitted_subtrees: Option>, + excluded_subtrees: Option>, + budget: &mut Budget, + mut match_constraint: F, +) -> Option> +where + F: FnMut(GeneralName<'_>, Subtrees) -> Option>, +{ + fn general_subtree<'b>(input: &mut untrusted::Reader<'b>) -> Result, Error> { + der::read_all(der::expect_tag(input, der::Tag::Sequence)?) + } + + let subtrees = [ + (Subtrees::Permitted, permitted_subtrees), + (Subtrees::Excluded, excluded_subtrees), + ]; + + for (kind, constraints) in subtrees { + let mut constraints = match constraints { + Some(constraints) => untrusted::Reader::new(constraints), + None => continue, + }; - match (subtrees, matches) { + let mut has_permitted_subtrees_match = false; + let mut has_permitted_subtrees_mismatch = false; + while !constraints.at_end() { + if let Err(e) = budget.consume_name_constraint_comparison() { + return Some(Err(e)); + } + + // http://tools.ietf.org/html/rfc5280#section-4.2.1.10: "Within this + // profile, the minimum and maximum fields are not used with any name + // forms, thus, the minimum MUST be zero, and maximum MUST be absent." + // + // Since the default value isn't allowed to be encoded according to the + // DER encoding rules for DEFAULT, this is equivalent to saying that + // neither minimum or maximum must be encoded. + let base = match general_subtree(&mut constraints) { + Ok(base) => base, + Err(err) => return Some(Err(err)), + }; + + let Some(matches) = match_constraint(base, kind) else { + continue; + }; + + match (kind, matches) { (Subtrees::Permitted, Ok(true)) => { has_permitted_subtrees_match = true; } @@ -274,7 +328,11 @@ impl<'a> Iterator for NameIterator<'a> { #[derive(Clone, Copy)] pub(crate) enum GeneralName<'a> { DnsName(untrusted::Input<'a>), - DirectoryName, + /// `[4]` directoryName GeneralName content. Per X.680 §31.2.7 this is + /// EXPLICIT-tagged, so the bytes are a SEQUENCE TLV wrapping the + /// RDNSequence. Use [`directory_name::strip_explicit_sequence`] to obtain + /// the inner RDNSequence content. + DirectoryName(untrusted::Input<'a>), IpAddress(untrusted::Input<'a>), UniformResourceIdentifier(untrusted::Input<'a>), @@ -303,7 +361,7 @@ impl<'a> FromDer<'a> for GeneralName<'a> { let (tag, value) = der::read_tag_and_get_value(reader)?; Ok(match tag { DNS_NAME_TAG => DnsName(value), - DIRECTORY_NAME_TAG => DirectoryName, + DIRECTORY_NAME_TAG => DirectoryName(value), IP_ADDRESS_TAG => IpAddress(value), UNIFORM_RESOURCE_IDENTIFIER_TAG => UniformResourceIdentifier(value), @@ -326,7 +384,7 @@ impl fmt::Debug for GeneralName<'_> { "DnsName(\"{}\")", String::from_utf8_lossy(name.as_slice_less_safe()) ), - GeneralName::DirectoryName => write!(f, "DirectoryName"), + GeneralName::DirectoryName(_) => write!(f, "DirectoryName"), GeneralName::IpAddress(ip) => { write!(f, "IpAddress({:?})", IpAddrSlice(ip.as_slice_less_safe())) } @@ -417,7 +475,13 @@ mod tests { "DnsName(\"example.com\")" ); - assert_eq!(format!("{:?}", GeneralName::DirectoryName), "DirectoryName"); + assert_eq!( + format!( + "{:?}", + GeneralName::DirectoryName(untrusted::Input::from(b"")) + ), + "DirectoryName" + ); assert_eq!( format!( diff --git a/tests/tls_server_certs.rs b/tests/tls_server_certs.rs index 3c04cca8..81a5fe24 100644 --- a/tests/tls_server_certs.rs +++ b/tests/tls_server_certs.rs @@ -686,14 +686,27 @@ fn uri_name_constraints(uri: &[u8], subtrees_tag: u8) -> CustomExtension { } #[test] -fn permit_directory_name_not_implemented() { +fn permit_directory_name_match() { + // Constraint = [CN] is a prefix of EE = [CN, O=test]. let mut dn = DistinguishedName::new(); - dn.push(DnType::CountryName, "CN"); + dn.push(DnType::CommonName, "subject.example.com"); let issuer = make_issuer(Some(NameConstraints { permitted_subtrees: vec![GeneralSubtree::DirectoryName(dn)], excluded_subtrees: vec![], })); - let ee = generate_cert(vec![], &issuer); + let ee = generate_cert_with_names(Some("subject.example.com"), None, vec![], &issuer); + assert_eq!(check_cert(ee.der(), issuer.der(), &[], &[], &[]), Ok(())); +} + +#[test] +fn permit_directory_name_no_match() { + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "different value"); + let issuer = make_issuer(Some(NameConstraints { + permitted_subtrees: vec![GeneralSubtree::DirectoryName(dn)], + excluded_subtrees: vec![], + })); + let ee = generate_cert_with_names(Some("subject.example.com"), None, vec![], &issuer); assert_eq!( check_cert(ee.der(), issuer.der(), &[], &[], &[]), Err(webpki::Error::NameConstraintViolation) @@ -701,20 +714,73 @@ fn permit_directory_name_not_implemented() { } #[test] -fn exclude_directory_name_not_implemented() { +fn exclude_directory_name_match() { let mut dn = DistinguishedName::new(); - dn.push(DnType::CountryName, "CN"); + dn.push(DnType::CommonName, "subject.example.com"); let issuer = make_issuer(Some(NameConstraints { permitted_subtrees: vec![], excluded_subtrees: vec![GeneralSubtree::DirectoryName(dn)], })); - let ee = generate_cert(vec![], &issuer); + let ee = generate_cert_with_names(Some("subject.example.com"), None, vec![], &issuer); assert_eq!( check_cert(ee.der(), issuer.der(), &[], &[], &[]), Err(webpki::Error::NameConstraintViolation) ); } +#[test] +fn exclude_directory_name_no_match() { + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "different value"); + let issuer = make_issuer(Some(NameConstraints { + permitted_subtrees: vec![], + excluded_subtrees: vec![GeneralSubtree::DirectoryName(dn)], + })); + let ee = generate_cert_with_names(Some("subject.example.com"), None, vec![], &issuer); + assert_eq!(check_cert(ee.der(), issuer.der(), &[], &[], &[]), Ok(())); +} + +#[test] +fn permit_directory_name_exact_match() { + // Constraint matches the entire EE DN, not just a prefix. + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "subject.example.com"); + dn.push(DnType::OrganizationName, "test"); + let issuer = make_issuer(Some(NameConstraints { + permitted_subtrees: vec![GeneralSubtree::DirectoryName(dn)], + excluded_subtrees: vec![], + })); + let ee = generate_cert_with_names(Some("subject.example.com"), None, vec![], &issuer); + assert_eq!(check_cert(ee.der(), issuer.der(), &[], &[], &[]), Ok(())); +} + +#[test] +fn permit_directory_name_deeper_subtree() { + // Constraint is shorter than EE DN; EE has extra RDN(s) at the tail. + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "subject.example.com"); + let issuer = make_issuer(Some(NameConstraints { + permitted_subtrees: vec![GeneralSubtree::DirectoryName(dn)], + excluded_subtrees: vec![], + })); + let ee = generate_cert_with_names( + Some("subject.example.com"), + None, + vec![SanType::DnsName("dns.example.com".try_into().unwrap())], + &issuer, + ); + assert_eq!( + check_cert( + ee.der(), + issuer.der(), + &["dns.example.com"], + &[], + &["DnsName(\"dns.example.com\")"] + ), + Ok(()) + ); +} + #[test] fn invalid_dns_name_matching() { let issuer = make_issuer(None); diff --git a/third-party/x509-limbo/exceptions.json b/third-party/x509-limbo/exceptions.json index 60314193..9f4dfd50 100644 --- a/third-party/x509-limbo/exceptions.json +++ b/third-party/x509-limbo/exceptions.json @@ -9,11 +9,6 @@ "actual": "FAILURE", "reason": "webpki does not support self-signed certificates" }, - "rfc5280::nc::permitted-dn-match": { - "expected": "SUCCESS", - "actual": "FAILURE", - "reason": "webpki does not support DirectoryName name constraints" - }, "rfc5280::nc::permitted-self-issued": { "expected": "SUCCESS", "actual": "FAILURE",