Skip to content

Make it safer and easier to build an X500DistinguishedName #44738

Closed

Description

@vcsjones

What / Why

The CertificateRequest functionality takes an X500DistingishedName type as input for the subject of the certificate request in many of its constructor arguments. However, (correctly) and safely building one of these is a bit difficult at the moment, and requires either input sanitation, escaping, or a combination of both. Consider code that tries to do something like this:

string userEmailInput = "kevin@example.com, DC=contoso";
X500DistinguishedName dn = new X500DistinguishedName($"E = {userEmailInput}");
CertificateRequest req = new CertificateRequest(dn, ECDsa.Create(), HashAlgorithmName.SHA512);

A program that does no or little email validation input would end up allowing someone to inject a component in to the distinguished name.

Ideally, something like SubjectAlternativeNameBuilder would exist that makes it easier and safer to encode an X500DistinguishedName.

Proposal

Update from before:

  • The Add* methods no longer take an encoding option, they will just do the optimal thing for their data type.
  • Instead of using Asn1Tag or UniversalTagNumber from System.Formats.Asn1 add a new nested enum for the power-user Add(OID, value, encoding) method.
    • This is because there were complexities in an inbox-only contract referencing a semi-OOB
namespace System.Security.Cryptography.X509Certificates
{
    public sealed partial class X500DistinguishedNameBuilder
    {
        public void AddEmailAddress(string emailAddress); // IA5String
        public void AddDomainComponent(string domainComponent); // IA5String, needs to use IdnMapping.
        public void AddLocalityName(string localityName); // UnboundDirectoryString (encode as UTF8)
        public void AddCommonName(string commonName);  // UnboundDirectoryString (encode as UTF8)
        public void AddCountryOrRegion(string twoLetterCode); // Always encoded as PrintableString
        public void AddOrganizationName(string organizationName); // UnboundDirectoryString (encode as UTF8)
        public void AddOrganizationalUnitName(string organizationalUnitName); // UnboundDirectoryString (encode as UTF8)
        public void AddStateOrProvinceName(string stateOrProvinceName); // UnboundDirectoryString (encode as UTF8)
    
        public void Add(string oidValue, string value, X500DistinguishedNameBuilder.EncodingType stringEncodingType = default);
        public void Add(Oid oid, string value, X500DistinguishedNameBuilder.EncodingType stringEncodingType = default);
    
        public void AddEncoded(Oid oid, byte[] encodedValue);
        public void AddEncoded(Oid oid, ReadOnlySpan<byte> encodedValue);
        public void AddEncoded(string oidValue, byte[] encodedValue);
        public void AddEncoded(string oidValue, ReadOnlySpan<byte> encodedValue);
    
        public X500DistinguishedName Build();
    
        public enum EncodingType
        {
            Default = 0, // Will be treated like UTF8String but is a distinct value of "0" so that default(EncodingType) has a sane value
            UTF8String = 12,
            PrintableString = 19,
            TeletexString = 20,
            BMPString = 30,
        }
    }
}

Previous proposal:

namespace System.Security.Cryptography.X509Certificates
{
    public sealed class X500DistinguishedNameBuilder
    {
        public void AddEmailAddress(ReadOnlySpan<char> emailAddress, Asn1Tag? stringEncodingType = null);
        public void AddDomainComponent(ReadOnlySpan<char> domainComponent, Asn1Tag? stringEncodingType = null);
        public void AddLocalityName(ReadOnlySpan<char> localityName, Asn1Tag? stringEncodingType = null);
        public void AddCommonName(ReadOnlySpan<char> commonName, Asn1Tag? stringEncodingType = null);
        public void AddCountryOrRegion(ReadOnlySpan<char> twoLetterCode, Asn1Tag? stringEncodingType = null);
        public void AddOrganizationName(ReadOnlySpan<char> organizationName, Asn1Tag? stringEncodingType = null);
        public void AddOrganizationalUnitName(ReadOnlySpan<char> organizationalUnitName, Asn1Tag? stringEncodingType = null);
        public void AddStateOrProvinceName(ReadOnlySpan<char> stateOrProvinceName, Asn1Tag? stringEncodingType = null);
    
        public void Add(Oid oid, ReadOnlySpan<char> value, Asn1Tag? stringEncodingType = null);
        public void Add(ReadOnlySpan<char> oidValue, ReadOnlySpan<char> value, Asn1Tag? stringEncodingType = null);
    
        public void AddEncoded(Oid oid, ReadOnlySpan<byte> encodedValue);
        public void AddEncoded(ReadOnlySpan<char> oidValue, ReadOnlySpan<byte> encodedValue);
    
        public X500DistinguishedName Build();
    }
}

We can add the common cases as methods, and for the uncommon / custom cases folks can use the Add method that takes an Oid.

Alternatives

Can be built yourself using AsnWriter, but, that feels a bit too close to the metal for my taste.

Activity

  1. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Nov 16, 2020
  2. Dotnet-GitSync-Bot commented on Nov 16, 2020

    @Dotnet-GitSync-Bot
    Collaborator

    I couldn't figure out the best area label to add to this issue. If you have write-permissions please help me learn by adding exactly one area label.

  3. vcsjones commented on Nov 16, 2020

    @vcsjones
    MemberAuthor
  4. bartonjs commented on Nov 16, 2020

    @bartonjs
    Member

    A couple of questions:

    What does this do?

    X509DistinguishedNameBuilder builder = new X509DistinguishedNameBuilder();
    builder.AddEmail("test@example.org");
    builder.AddCommonName("example.org");
    string whatAmI = builder.Build().Name;

    If it's E = test@example.org, CN = example.org that probably matches user expectations from the .NET API perspective, but that technically means that "Add" is "Push", since the Name property prints reversed. Conversely, CN = example.org, E = test@example.org probably makes sense to someone who understands X.500 names more than .NET.

    My initial thought was that Build and BuildReversed were both warranted, but I think that this is probably something that someone can easily debug one way or the other.

    Is there validation?

    https://www.itu.int/ITU-T/formal-language/itu-t/x/x520/2008/SelectedAttributeTypes.html says that CountryName (C / 2.5.4.6) is a 2 character PrintableString that corresponds to an ISO 3166 country code. How much, if any, of that is validated? "日本" is two characters, but it falls outside the PrintableString character set. "ZQ" is two characters, and a PrintableString, but not a current two letter ISO 3166 identifier.

    I think my preference would be to restrict AddCountryName to two-character PrintableString values (not trying to keep alignment with ISO 3166). I could be wrong, and maybe "no validation" is best.

    We might also have to name AddCountryName AddCountryOrRegionName. It doesn't match what the spec calls it, but it solves the problem with the TW identifier.

    Are all of the MultiValue things needed?

    Multi-Valued RDNs are pretty rare. Leaving the OID-keyed one as an escape valve seems reasonable, but do the others provide enough value (basically to tests) to warrant the confusion they introduce (namely, that they're useful).

    If Multi-Value things are needed, should it be a suffix?

    Assuming classic IntelliSense ordering, should the multi-value methods be grouped together, or grouped with their single-value counterpart?

    Prefix logical grouping:

        public void AddDomainComponent(string domainComponent);
        public void AddEmailAddress(string emailAddress);
    
        public void AddMultiValueDomainComponent(string[] domainComponents);
        public void AddMultiValueEmailAddress(string[] emailAddresses);

    Suffix logical grouping:

        public void AddDomainComponent(string domainComponent);
        public void AddDomainComponentMultiValued(string[] domainComponents);
    
        public void AddEmailAddress(string emailAddress);
        public void AddEmailAddressMultiValued(string[] emailAddresses);

    Should we accept pre-encoded values?

    public void Add(Oid oid, byte[] encodedValue);
    public void AddEncoded(Oid oid, byte[] encodedValue);

    Should we accept string oidValue as an alternative to Oid oid?

    Probably 😄 It can keep the Oid-typed one, just add overloads.

  5. vcsjones commented on Nov 16, 2020

    @vcsjones
    MemberAuthor

    My initial thought was that Build and BuildReversed were both warranted

    That makes sense, I also considered making this more ICollection like with Insert and such... but I tend to agree that once the behavior is understood, it's fairly straight forward.

    Is there validation?

    The case of countryName is fairly straight forward, so I could see validating it as simple as two letters (so there is no need to debate which country codes are valid...).

    CountryName I think is also restricted to not having multiple values, but I agree the multi-value could go away anyway.

    Are all of the MultiValue things needed?

    I like keeping the generic Add one around for it, but agree that the common case ones can go away. I also think we can make the single value cases accept ROS now. Nope.

  6. vcsjones commented on Nov 16, 2020

    @vcsjones
    MemberAuthor

    A thought: does it need an option to specify how things get encoded? e.g. to force UTF8String or BMPString? If we do, does it belong on Build or for individual Add* calls?

  7. bartonjs commented on Nov 17, 2020

    @bartonjs
    Member

    does it need an option to specify how things get encoded?

    Something like

    /// <param name="stringEncodingType">An optional ASN.1 tag for the string encoding type to use for the value. The default value uses the preferred encoding for the OID, when known, or UTF-8.</param>
    /// <exception cref="WhateverAsnWriterThrows"><paramref name="value"/> cannot be validly encoded under the specified encoding type.</exception>
    public void Add(Oid oid, string value, Asn1Tag? stringEncodingType = null);

    ?

    If we do, does it belong on Build or for individual Add* calls?

    Clearly my thought was Add. If we build things using the encoded order (the opposite of the .NET strings) the add methods can just immediately go into an AsnWriter (though that presumably closes itself out when you call Build, we'd need to codify if Build closes the builder or you can call Add again and then Build again for a "and one more thing" approach.

    If we build them in the friendly way then an AsnWriter can still be used immediately which then Encode()s into a Stack<byte[]> and Reset()s, then Build builds up the macro structure reading from the stack... not hyper-efficient, but not really the end of the world.

    I also think we can make the single value cases accept ROS now. Nope.

    Why can't the add methods take ReadOnlySpan<char> for the value parameter as an overload to the string version?

  8. vcsjones commented on Nov 17, 2020

    @vcsjones
    MemberAuthor

    Taking the AsnTag seems reasonable and provides the most flexibility. Presumably this would stop you from doing something nonsensical like using a AsnNull tag?

    Why can't the add methods take ReadOnlySpan<char> for the value parameter as an overload to the string version?

    I had not planned on having Add immediately write to the AsnWriter but to defer it until Encode. As you have just explained, it seems doable now.

  9. vcsjones commented on Nov 17, 2020

    @vcsjones
    MemberAuthor

    Updated proposal.

    The Old overloads looks a bit redundant to me now. I don't really have a preference one way or the other, but if there is strong belief that the ROS overloads are good enough I wouldn't mind omitting them.

  10. KalleOlaviNiemitalo commented on Nov 17, 2020

    @KalleOlaviNiemitalo

    How would you use that API to add a multi-value RDN like in one of the examples in RFC 4514 section 4:

    OU=Sales+CN=J. Smith,DC=example,DC=net

    If public void AddMultiValue(Oid oid, string[] values, Asn1Tag? stringEncodingType = null) adds a whole RDN, then it cannot be used here, because the attribute types don't match.

    Perhaps the API could work like this instead (assuming big-endian):

    X509DistinguishedNameBuilder builder = new X509DistinguishedNameBuilder();
    builder.AddDomainComponent("net");
    builder.AddDomainComponent("example");
    builder.StartMultiValueRdn();
    builder.AddOrganizationUnitName("Sales");
    builder.AddCommonName("J. Smith");
    builder.EndMultiValueRdn();
    builder.Build();
  11. bartonjs commented on Nov 17, 2020

    @bartonjs
    Member

    Rather than have state with Start/End multi, probably just something like

    // Easy CN+CN
    public void AddMultiValue(string oidValue, string[] values, Asn1Tag? stringEncodingType = null);
    // OU+CN with common/default encoding
    public void AddMultiValue(IEnumerable<(string OidValue, string Value)> valueSegments, Asn1Tag? stringEncodingType = null);
    // OU with IA5String and CN with UTF8String, 'cuz why not?
    public void AddMultiValue(IEnumerable<(string OidValue, string Value, Asn1Tag? StringEncodingType)> valueSegments);

    Or just cut multi-valued altogether, because the ordering is weird. The example of OU=Sales+CN=J. Smith,DC=example,DC=net (which is the .NET default order, aka "Reversed" (RFC 4514 section 2.1 says last-prints-first)) is

    SEQUENCE OF
        SET OF
            SEQUENCE
                OBJECT IDENTIFIER id-at-domainComponent
                SOME SORT OF STRING "net"
        SET OF
            SEQUENCE
                OBJECT IDENTIFIER id-at-domainComponent
                SOME SORT OF STRING "example"
        SET OF
            SEQUENCE
                OBJECT IDENTIFIER id-at-organizationalUnit
                SOME SORT OF STRING "Sales"
            SEQUENCE
                OBJECT IDENTIFIER id-at-commonName
                SOME SORT OF STRING "Smith"
    

    Because of how strings display, the calls would actually be (I think)

    X509DistinguishedNameBuilder builder = new X509DistinguishedNameBuilder();
    builder.AddMultiValue(new[] { (Oids.OU, "Sales"), (Oids.CN, "Smith") });
    builder.AddDomainComponent("example");
    builder.AddDomainComponent("net");
    return builder.Build().Name;

    Since we're maintaining "the API call order matches the string order" it might make sense, but it's definitely a bit of a trip for anyone who knows how the final output is encoded (ok, maybe just me).

  12. vcsjones commented on Nov 18, 2020

    @vcsjones
    MemberAuthor

    Or just cut multi-valued altogether

    At this point I'm inclined to agree. My motivation for the proposal was CertificateRequest, which accepts an X500DN as input, whereas most of the places it has been used is as some kind of output.

    I suppose if someone wanted All The Flexibility they can use AsnWriter to encode it.

  13. 38 remaining items

  14. bartonjs commented on Sep 21, 2021

    @bartonjs
    Member

    Video

    Looks good as proposed (for the third time!)

    namespace System.Security.Cryptography.X509Certificates
    {
        public sealed partial class X500DistinguishedNameBuilder
        {
            public void AddEmailAddress(string emailAddress);
            public void AddDomainComponent(string domainComponent);
            public void AddLocalityName(string localityName);
            public void AddCommonName(string commonName); 
            public void AddCountryOrRegion(string twoLetterCode);
            public void AddOrganizationName(string organizationName);
            public void AddOrganizationalUnitName(string organizationalUnitName);
            public void AddStateOrProvinceName(string stateOrProvinceName);
      
            public void Add(string oidValue, string value, UniversalTagNumber? stringEncodingType = null);
            public void Add(Oid oid, string value, UniversalTagNumber? stringEncodingType = null);
        
            public void AddEncoded(Oid oid, byte[] encodedValue);
            public void AddEncoded(Oid oid, ReadOnlySpan<byte> encodedValue);
            public void AddEncoded(string oidValue, byte[] encodedValue);
            public void AddEncoded(string oidValue, ReadOnlySpan<byte> encodedValue);
        
            public X500DistinguishedName Build();    
        }
    }
  15. removed
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Sep 21, 2021
  16. vcsjones commented on Sep 21, 2021

    @vcsjones
    MemberAuthor

    @bartonjs did this get approved and is just missing the label?

  17. added
    api-approvedAPI was approved in API review, it can be implemented
    on Sep 21, 2021
  18. bartonjs commented on Sep 21, 2021

    @bartonjs
    Member

    Clicking's hard, mm'kay? 😄

  19. amin1best commented on Oct 1, 2021

    @amin1best

    According to the following links:

    https://docs.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names

    https://www.ietf.org/rfc/rfc2253.txt

    If possible, I suggest:

    1. AddStreetAddress

    2. AddUserid

    3. In x520 and x509 for each attribute there is a field called LDAP-NAME which I use to build RDN.

    x520 Sample
    x520
    x509 Sample
    x509

    Isn't there an easier way to add a RDN?
    like:

    public void Add(Oid oid, string LDAPName, string value);

    @bartonjs , @vcsjones

  20. bartonjs commented on Oct 1, 2021

    @bartonjs
    Member

    @amin1best LDAPName is mostly redundant with oid. It largely informs what a string lookup table for OID to FriendlyName (and vice versa) should be, and/or a string-based lookup in LDAP. At any case, only the OID and string are actually encoded into the X.500 value.

    Once we take that parameter out, its now an overload redundant to one we already have:

    public void Add(Oid oid, string value, UniversalTagNumber? stringEncodingType = null);
  21. self-assigned this
    on Nov 30, 2021
  22. ghost added
    in-prThere is an active PR which will close this issue when it is merged
    on Mar 11, 2022
  23. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Mar 15, 2022
  24. ghost locked as resolved and limited conversation to collaborators on Apr 23, 2022
  25. added
    needs-further-triageIssue has been initially triaged, but needs deeper consideration or reconsideration
    on Aug 19, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-System.Securityneeds-further-triageIssue has been initially triaged, but needs deeper consideration or reconsideration

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions