Skip to content

Implement directoryName constraint validation#489

Open
sfackler wants to merge 1 commit into
rustls:mainfrom
sfackler:dn-constraints
Open

Implement directoryName constraint validation#489
sfackler wants to merge 1 commit into
rustls:mainfrom
sfackler:dn-constraints

Conversation

@sfackler
Copy link
Copy Markdown

A few notes:

  • This intentionally does not fully implement the requirements from RFC 5280, following BoringSSL's lead. In particular, "conforming implementations MUST use the LDAP StringPrep profile" which none of OpenSSL, BoringSSL, or this PR do. Instead, we implement a loose "ascii flavor" of the StringPrep profile with whitespace folding and case-insensitive comparison. We could add a full StringPrep implementation, but it would add a bunch of extra complexity and some very large Unicode tables. Given how obscure DN constraints are, I feel like BoringSSL/OpenSSL's approach makes more sense. I also assume that their decision has probably forced the hand of every issuer to not rely on full unicode normalization.
  • There's a bit of awkwardness in subject_name/mod.rs due to the differing encodings of the DNs - it is directly represented as an RDNSequence in the certificate Subject, but is encoded in EXPLICIT form elsewhere. This currently results in separate functions to handle those two validation cases. If you wanted, the cleanest way to combine those back to one is probably to strip the EXPLICIT wrapper in GeneralName::from_der.
  • The implementation of rdn_eq is an O(n^2) nested loop around the two sets of AVAs. This mirrors the overall approach taken in BoringSSL, though the one here is implemented with fixed size buffers and a bitmask rather than vectors to keep everything no-std.

Depends on rustls/rcgen#429, so I have a Cargo patch for rcgen until a new release gets cut.

Closes #19

@codecov
Copy link
Copy Markdown

codecov Bot commented May 10, 2026

Codecov Report

❌ Patch coverage is 97.44136% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.05%. Comparing base (0fe5e6c) to head (8bb3a0d).

Files with missing lines Patch % Lines
src/subject_name/directory_name.rs 98.15% 7 Missing ⚠️
src/der.rs 0.00% 3 Missing ⚠️
src/subject_name/mod.rs 97.70% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #489      +/-   ##
==========================================
+ Coverage   97.01%   97.05%   +0.04%     
==========================================
  Files          20       21       +1     
  Lines        3950     4379     +429     
==========================================
+ Hits         3832     4250     +418     
- Misses        118      129      +11     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@djc
Copy link
Copy Markdown
Member

djc commented May 10, 2026

What's your use case for this?

@sfackler
Copy link
Copy Markdown
Author

I am unfortunately trying to deal with a (privately issued) cert issued by an intermediate with DN constraints.

@ctz ctz self-requested a review May 14, 2026 16:33
Copy link
Copy Markdown
Member

@ctz ctz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks good to me. A few comments.

I have tested that TunTrust websites now work with this change in place.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I really recognise the AVA terminology here, It's a AttributeTypeAndValue right?

let mut reader = untrusted::Reader::new(rdn);
let mut count = 0;
while !reader.at_end() {
if count >= MAX_AVAS {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use match out.get_mut(count) to ensure the bounds check and usage stay aligned in the future.

let mut count = 0;
while !reader.at_end() {
if count >= MAX_AVAS {
return Err(Error::BadDer);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably deserves a unique error, as BadDer is overused in this library to mean a number of different cases; this is an internal limitation rather than a problem with the input.

// RDNSequence. Strip the wrapper to expose the RDNSequence content.
pub(super) fn strip_explicit_sequence(
value: untrusted::Input<'_>,
) -> Result<untrusted::Input<'_>, Error> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be tempted to introduce a bunch of trivial private newtypes to explain what these untrusted::Input values (here and below) mean.

Comment on lines +47 to +50
// 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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type of comment better reified into the type system?

Comment thread src/subject_name/mod.rs
excluded_subtrees: Option<untrusted::Input<'_>>,
budget: &mut Budget,
) -> Option<Result<(), Error>> {
walk_constraints(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this is the venue where the "when the certificate includes a non-empty subject field" in 4.2.1.10 can be addressed?

Ideally with a test, if there isn't already one. Apologies if this is already dealt with elsewhere!

Comment on lines +292 to +304
struct Normalizer<'a> {
inner: CodePoints<'a>,
state: NormState,
buffered: Option<char>,
}

#[derive(PartialEq)]
enum NormState {
Leading,
Content,
PendingWs,
Done,
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: kind of wondering whether NormState could be merged into the Normalizer type? As some of these fields aren't meaningful for some states.

@briansmith
Copy link
Copy Markdown
Contributor

Does this really need to be so complicated? Is it not possible to restrict this to supporting only reasonable string encodings and canonical DER encoding?

Like, during name constraint processing, e.g. if you see a BMPString, just fail, following "TeletexString, BMPString, and UniversalString are included for backward compatibility, and SHOULD NOT be used for certificates for new subjects." Hopefully every producer of certificates that is relevant to us is following that advice.

Because X.509 certificates are DER, there is really only one correct ordering of the parts of a DN, so all the complexity with supporting the constraint and the presented name having different orderings is unnecessary if we require that the CA hierarchy be conformant with X.509. Actually the requirement would be less than that--just be consistent in how names are encoded in presented names and constraints; i.e. require that the constraint and the presented name be in the same order. If they aren't in the same order then they aren't valid.

I think this would notably simplify the implementation and make it more likely that it is actually safe.

@briansmith
Copy link
Copy Markdown
Contributor

More generally, the original design philosophy of mozilla::pkix and the webpki crate is to reject as many weird things as we possible can, to set the stage for having a much more limited X.509 profile for the WebPKI, to make it easier to make safe implementations. This PR is really far from that design philosophy.

I highly recommend that you look at the very simple matching that mozilla::pkix does for these constraints, and aim for similar simplicity in the initial implementation. If we need more complexity then that complexity should be added in small, minimal, doses.

Is Firefox able to handle the constraints that your CA hierarchy uses?

https://github.com/nss-dev/nss/blob/145258062236390b159197b73b317b97caa62e62/lib/mozpkix/lib/pkixnames.cpp#L623-L649

https://github.com/nss-dev/nss/blob/145258062236390b159197b73b317b97caa62e62/lib/mozpkix/lib/pkixnames.cpp#L1364

@briansmith
Copy link
Copy Markdown
Contributor

Also, excludedSubtrees is one of the things that makes this hard because it precludes simplifications like what I suggested above. But, there is an easy solution to that, I believe: Simply reject any CA certificate that contains an excludedSubtrees DN name constraint, unless/until there are real-world certificates that have them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Complete and correct support for directoryName constraints

4 participants