noctonura
noctonura

Reputation: 13133

How do you parse the Subject Alternate Names from an X509Certificate2?

Is there an easy way to get the Subject Alternate Names from an X509Certificate2 object?

        foreach (X509Extension ext in certificate.Extensions)
        {
            if (ext.Oid.Value.Equals(/* SAN OID */"2.5.29.17"))
            {
                byte[] raw = ext.RawData;
                // ?????? parse to get type and name ????????
            }
        }

Upvotes: 29

Views: 30501

Answers (12)

Stefan Steiger
Stefan Steiger

Reputation: 82336

Fixed all the broken code from here.
Here's how I do it:

// Function to parse and extract SAN values from the RawData
private static System.Collections.Generic.List<string> GetSubjectAlternativeNames(byte[] rawData)
{
    System.Collections.Generic.List<string> sanValues = new System.Collections.Generic.List<string>();

    System.Formats.Asn1.Asn1Tag dnsNameTag = new System.Formats.Asn1.Asn1Tag(System.Formats.Asn1.TagClass.ContextSpecific, tagValue: 2, isConstructed: false);
    System.Formats.Asn1.Asn1Tag ipTag = new System.Formats.Asn1.Asn1Tag(System.Formats.Asn1.TagClass.ContextSpecific, tagValue: 7, isConstructed: false);


    try
    {
        // Initialize an AsnValueReader to decode the ASN.1 DER-encoded SAN data
        System.Formats.Asn1.AsnReader asnReader = new System.Formats.Asn1.AsnReader(rawData, System.Formats.Asn1.AsnEncodingRules.DER);
        System.Formats.Asn1.AsnReader sequenceReader = asnReader.ReadSequence();
        
        while (sequenceReader.HasData)
        {
            System.Formats.Asn1.Asn1Tag tag = sequenceReader.PeekTag();
            if (tag == dnsNameTag)
            {
                string dnsName = sequenceReader.ReadCharacterString(System.Formats.Asn1.UniversalTagNumber.IA5String, dnsNameTag);
                sanValues.Add(dnsName);
                continue;
            } // End if (tag == dnsNameTag) 
            else if (tag == ipTag)
            {
                byte[] ipAddressBytes = sequenceReader.ReadOctetString(ipTag);

                // Convert the byte array to an IP address string (IPv4 or IPv6)
                if (ipAddressBytes.Length == 4) // IPv4
                {
                    string ipv4Address = new System.Net.IPAddress(ipAddressBytes).ToString();
                    sanValues.Add(ipv4Address);
                }
                else if (ipAddressBytes.Length == 16) // IPv6
                {

                    System.Net.IPAddress address = new System.Net.IPAddress(ipAddressBytes);
                    string ipv6Address = address.IsIPv4MappedToIPv6 ? address.MapToIPv4().ToString() : address.ToString();

                    sanValues.Add(ipv6Address);
                }
                else
                {
                    // If the length doesn't match IPv4 or IPv6, log or handle as needed
                    sanValues.Add("Invalid IP address format");
                }

                continue;
            } // End else if (tag == ipTag) 

            sequenceReader.ReadEncodedValue();
        } // Whend 

    }
    catch (System.Formats.Asn1.AsnContentException e)
    {
        System.Console.WriteLine("Error parsing SAN extension: " + e.Message);
    }

    return sanValues;
} // End Function GetSubjectAlternativeNames 

Usage:

System.Security.Cryptography.X509Certificates.X509Certificate2? certificate = new System.Security.Cryptography.X509Certificates.X509Certificate2(@"D:\mycert.pfx", "");


System.Security.Cryptography.X509Certificates.X509Extension? subjectAlternativeNames = certificate.Extensions["2.5.29.17"]; // OID for Subject Alternative Name

if (subjectAlternativeNames != null)
{
    // Decode the Subject Alternative Name extension
    System.Security.Cryptography.AsnEncodedData sanData = new System.Security.Cryptography.AsnEncodedData(subjectAlternativeNames.Oid, subjectAlternativeNames.RawData);
    string san = sanData.Format(true);
    // System.Console.WriteLine("Subject Alternative Names: " + san);

    System.Console.WriteLine("subjectAlternativeNames: ");
    var ls = GetSubjectAlternativeNames(subjectAlternativeNames.RawData);

    foreach (string nvp in san.Split(new string[] { System.Environment.NewLine }, System.StringSplitOptions.RemoveEmptyEntries))
    {
        
        string[] parts = nvp.Split('=');
        string name = parts[0];
        string? value = (parts.Length > 0) ? parts[1] : null;
        System.Console.WriteLine(name + ": " + value);
    }

}
else
{
    System.Console.WriteLine("No Subject Alternative Names found.");
}

Upvotes: 1

Monsignor
Monsignor

Reputation: 2955

For .NET 7 and above:

foreach (X509Extension ext in certificate.Extensions)
{
    if (ext is X509SubjectAlternativeNameExtension san)
    {
        var dnsNames = san.EnumerateDnsNames();
    }
}

Upvotes: 1

Sebastian Krysmanski
Sebastian Krysmanski

Reputation: 8414

Here's a solution that does not require parsing the text returned by AsnEncodedData.Format() (but requires .NET 5 or the System.Formats.Asn1 NuGet package):

using System.Formats.Asn1;

...

public static List<string> GetAlternativeDnsNames(X509Certificate2 cert)
{
    const string SAN_OID = "2.5.29.17";

    var extension = cert.Extensions[SAN_OID];
    if (extension is null)
    {
        return new List<string>();
    }

    // Tag value "2" is defined by:
    //
    //    dNSName                         [2]     IA5String,
    //
    // in: https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6
    var dnsNameTag = new Asn1Tag(TagClass.ContextSpecific, tagValue: 2, isConstructed: false);

    var asnReader = new AsnReader(extension.RawData, AsnEncodingRules.BER);
    var sequenceReader = asnReader.ReadSequence(Asn1Tag.Sequence);

    var resultList = new List<string>();

    while (sequenceReader.HasData)
    {
        var tag = sequenceReader.PeekTag();
        if (tag != dnsNameTag)
        {
            sequenceReader.ReadEncodedValue();
            continue;
        }

        var dnsName = sequenceReader.ReadCharacterString(UniversalTagNumber.IA5String, dnsNameTag);
        resultList.Add(dnsName);
    }

    return resultList;
}

Upvotes: 12

Paul Hermans
Paul Hermans

Reputation: 360

OS independent approach

This code snippet assumes there are two possible delimiters '=' and ':'

    private static List<string> GetSubjectAlternativeNames(X509Certificate2 cert)
    {
        var result = new List<string>();

        var subjectAlternativeNames = cert.Extensions.Cast<X509Extension>()
                                            .Where(n => n.Oid.Value == "2.5.29.17") //Subject Alternative Name
                                            .Select(n => new AsnEncodedData(n.Oid, n.RawData))
                                            .Select(n => n.Format(true))
                                            .FirstOrDefault();

        // Example outputs:
        // Windows: "DNS Name=example.com, DNS Name=www.example.com"
        // Windows NL: "DNS-naam=example.com\r\nDNS-naam=www.example.com\r\n"
        // Linux: "DNS:example.com, DNS:www.example.com"

        var delimiters = new char[] { '=', ':' };
        var notfound = -1;
        var pairs = subjectAlternativeNames.Split(new[] { ",", "\r\n", "\r", "\n" }, StringSplitOptions.TrimEntries);

        foreach (var pair in pairs)
        {
            int position = pair.IndexOfAny(delimiters);
            if (position == notfound)
                continue;

            var subjectAlternativeName = pair.Substring(position + 1);
            if (String.IsNullOrEmpty(subjectAlternativeName))
                continue;

            result.Add(subjectAlternativeName);
        }
        return result;
    }

Upvotes: 1

Mafoo
Mafoo

Reputation: 69

Expanding on Minh Nguyen's Answer taking into account using OID i rewrote it as a extension

namespace MyExtensions
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Security.Cryptography;
    using System.Security.Cryptography.X509Certificates;
    using System.Text.RegularExpressions;

    public static class X509Certificate2Extensions
    {
        private const string SubjectAlternateNameOID = "2.5.29.17";
        private static readonly Regex _dnsNameRegex = new Regex(@"^DNS Name=(.+)");

        public static List<string> SubjectAlternativeNames(this X509Certificate2 cert)
        {
            var subjectAlternativeName = cert.Extensions.Cast<X509Extension>()
                .Where(n => n.Oid.Value == SubjectAlternateNameOID)
                .Select(n => new AsnEncodedData(n.Oid, n.RawData))
                .Select(n => n.Format(true))
                .FirstOrDefault();

            return string.IsNullOrWhiteSpace(subjectAlternativeName)
                ? new List<string>()
                : subjectAlternativeName.Split(new[] {"\r\n", "\r", "\n"}, StringSplitOptions.RemoveEmptyEntries)
                    .Select(n => _dnsNameRegex.Match(n))
                    .Where(r => r.Success && !string.IsNullOrWhiteSpace(r.Groups[1].Value))
                    .Select(r => r.Groups[1].Value)
                    .ToList();
        }
    }
}

Upvotes: 3

Adassko
Adassko

Reputation: 5343

All of the answers here are either platform or OS language specific or are able to retrieve only one alternative subject name so I wrote my own parser by reverse engineering raw data which can parse DNS and IP Addresses and suits my needs:

private const string SAN_OID = "2.5.29.17";

private static int ReadLength(ref Span<byte> span)
{
    var length = (int)span[0];
    span = span[1..];
    if ((length & 0x80) > 0)
    {
        var lengthBytes = length & 0x7F;
        length = 0;
        for (var i = 0; i < lengthBytes; i++)
        {
            length = length * 0x100 + span[0];
            span = span[1..];
        }
    }
    return length;
}

public static IList<string> ParseSubjectAlternativeNames(byte[] rawData)
{
    var result = new List<string>(); // cannot yield results when using Span yet
    if (rawData.Length < 1 || rawData[0] != '0')
    {
        throw new InvalidDataException("They told me it will start with zero :(");
    }

    var data = rawData.AsSpan(1);
    var length = ReadLength(ref data);
    if (length != data.Length)
    {
        throw new InvalidDataException("I don't know who I am anymore");
    }

    while (!data.IsEmpty)
    {
        var type = data[0];
        data = data[1..];

        var partLength = ReadLength(ref data);
        if (type == 135) // ip
        {
            result.Add(new IPAddress(data[0..partLength]).ToString());
        } else if (type == 160) // upn
        {
            // not sure how to parse the part before \f
            var index = data.IndexOf((byte)'\f') + 1;
            var upnData = data[index..];
            var upnLength = ReadLength(ref upnData);
            result.Add(Encoding.UTF8.GetString(upnData[0..upnLength]));
        } else // all other
        {
            result.Add(Encoding.UTF8.GetString(data[0..partLength]));
        }
        data = data[partLength..];
    }
    return result;
}

public static IEnumerable<string> ParseSubjectAlternativeNames(X509Certificate2 cert)
{
    return cert.Extensions
        .Cast<X509Extension>()
        .Where(ext => ext.Oid.Value.Equals(SAN_OID))
        .SelectMany(x => ParseSubjectAlternativeNames(x.RawData));
}

I also found this test in corefx repo itself: https://github.com/dotnet/corefx/blob/master/src/System.Security.Cryptography.Encoding/tests/AsnEncodedData.cs#L38

The idea there is to just split the asnData.Format result on ':', '=', ',' and take every other value which is a much easier approach:

byte[] sanExtension =
{
    0x30, 0x31, 0x82, 0x0B, 0x65, 0x78, 0x61, 0x6D,
    0x70, 0x6C, 0x65, 0x2E, 0x6F, 0x72, 0x67, 0x82,
    0x0F, 0x73, 0x75, 0x62, 0x2E, 0x65, 0x78, 0x61,
    0x6D, 0x70, 0x6C, 0x65, 0x2E, 0x6F, 0x72, 0x67,
    0x82, 0x11, 0x2A, 0x2E, 0x73, 0x75, 0x62, 0x2E,
    0x65, 0x78, 0x61, 0x6D, 0x70, 0x6C, 0x65, 0x2E,
    0x6F, 0x72, 0x67,
};

AsnEncodedData asnData = new AsnEncodedData(
    new Oid("2.5.29.17"),
    sanExtension);

string s = asnData.Format(false);
// Windows says: "DNS Name=example.org, DNS Name=sub.example.org, DNS Name=*.sub.example.org"
// X-Plat (OpenSSL) says: "DNS:example.org, DNS:sub.example.org, DNS:*.sub.example.org".
// This keeps the parsing generalized until we can get them to converge
string[] parts = s.Split(new[] { ':', '=', ',' }, StringSplitOptions.RemoveEmptyEntries);
// Parts is now { header, data, header, data, header, data }.
string[] output = new string[parts.Length / 2];

for (int i = 0; i < output.Length; i++)
{
    output[i] = parts[2 * i + 1];
}

Upvotes: 2

Rui Caramalho
Rui Caramalho

Reputation: 483

Solution to all Languages .NET
This solution is an improvement of Minh Nguyen above solution so it can work in all Languages

private static List<string> GetSujectAlternativeName(X509Certificate2 cert)
        {
            var result = new List<string>();


            var subjectAlternativeName = cert.Extensions.Cast<X509Extension>()
                                                .Where(n => n.Oid.Value== "2.5.29.17") //n.Oid.FriendlyName=="Subject Alternative Name")
                                                .Select(n => new AsnEncodedData(n.Oid, n.RawData))
                                                .Select(n => n.Format(true))
                                                .FirstOrDefault();

            if (subjectAlternativeName != null)
            {
                var alternativeNames = subjectAlternativeName.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);

                foreach (var alternativeName in alternativeNames)
                {
                    var groups = Regex.Match(alternativeName, @"^(.*)=(.*)").Groups; // @"^DNS Name=(.*)").Groups;

                    if (groups.Count > 0 && !String.IsNullOrEmpty(groups[2].Value))
                    {
                        result.Add(groups[2].Value);
                    }
                }
            }

            return result;
        }

Upvotes: 5

Dan
Dan

Reputation: 883

With .net core, its more relevant to need a cross-platform way to do this. @Jason Shuler solution is windows only, but with some extra work, can be platform-independent. I've adapted the code WCF uses to do this in the following snippet(MIT Licensed)

    // Adapted from https://github.com/dotnet/wcf/blob/a9984490334fdc7d7382cae3c7bc0c8783eacd16/src/System.Private.ServiceModel/src/System/IdentityModel/Claims/X509CertificateClaimSet.cs
    // We don't have a strongly typed extension to parse Subject Alt Names, so we have to do a workaround 
    // to figure out what the identifier, delimiter, and separator is by using a well-known extension
    // If https://github.com/dotnet/corefx/issues/22068 ever goes anywhere, we can remove this
    private static class X509SubjectAlternativeNameParser
    {
        private const string SAN_OID = "2.5.29.17";

        private static readonly string platform_identifier;
        private static readonly char platform_delimiter;
        private static readonly string platform_seperator;

        static X509SubjectAlternativeNameParser()
        {
            // Extracted a well-known X509Extension
            byte[] x509ExtensionBytes = new byte[] {
                48, 36, 130, 21, 110, 111, 116, 45, 114, 101, 97, 108, 45, 115, 117, 98, 106, 101, 99,
                116, 45, 110, 97, 109, 101, 130, 11, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109
            };
            const string subjectName1 = "not-real-subject-name";

            X509Extension x509Extension = new X509Extension(SAN_OID, x509ExtensionBytes, true);
            string x509ExtensionFormattedString = x509Extension.Format(false);

            // Each OS has a different dNSName identifier and delimiter
            // On Windows, dNSName == "DNS Name" (localizable), on Linux, dNSName == "DNS"
            // e.g.,
            // Windows: x509ExtensionFormattedString is: "DNS Name=not-real-subject-name, DNS Name=example.com"
            // Linux:   x509ExtensionFormattedString is: "DNS:not-real-subject-name, DNS:example.com"
            // Parse: <identifier><delimter><value><separator(s)>

            int delimiterIndex = x509ExtensionFormattedString.IndexOf(subjectName1) - 1;
            platform_delimiter = x509ExtensionFormattedString[delimiterIndex];

            // Make an assumption that all characters from the the start of string to the delimiter 
            // are part of the identifier
            platform_identifier = x509ExtensionFormattedString.Substring(0, delimiterIndex);

            int separatorFirstChar = delimiterIndex + subjectName1.Length + 1;
            int separatorLength = 1;
            for (int i = separatorFirstChar + 1; i < x509ExtensionFormattedString.Length; i++)
            {
                // We advance until the first character of the identifier to determine what the
                // separator is. This assumes that the identifier assumption above is correct
                if (x509ExtensionFormattedString[i] == platform_identifier[0])
                {
                    break;
                }

                separatorLength++;
            }

            platform_seperator = x509ExtensionFormattedString.Substring(separatorFirstChar, separatorLength);
        }

        public static IEnumerable<string> ParseSubjectAlternativeNames(X509Certificate2 cert)
        {
            return cert.Extensions
                .Cast<X509Extension>()
                .Where(ext => ext.Oid.Value.Equals(SAN_OID)) // Only use SAN extensions
                .Select(ext => new AsnEncodedData(ext.Oid, ext.RawData).Format(false)) // Decode from ASN
                // This is dumb but AsnEncodedData.Format changes based on the platform, so our static initialization code handles making sure we parse it correctly
                .SelectMany(text => text.Split(platform_seperator, StringSplitOptions.RemoveEmptyEntries))
                .Select(text => text.Split(platform_delimiter))
                .Where(x => x[0] == platform_identifier)
                .Select(x => x[1]);
        }
    }

Upvotes: 3

Jason Shuler
Jason Shuler

Reputation: 399

Based on the answer from Minh, here is a self-contained static function that should return them all

    public static IEnumerable<string> ParseSujectAlternativeNames(X509Certificate2 cert)
    {
        Regex sanRex = new Regex(@"^DNS Name=(.*)", RegexOptions.Compiled | RegexOptions.CultureInvariant);

        var sanList = from X509Extension ext in cert.Extensions
                      where ext.Oid.FriendlyName.Equals("Subject Alternative Name", StringComparison.Ordinal)
                      let data = new AsnEncodedData(ext.Oid, ext.RawData)
                      let text = data.Format(true)
                      from line in text.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
                      let match = sanRex.Match(line)
                      where match.Success && match.Groups.Count > 0 && !string.IsNullOrEmpty(match.Groups[1].Value)
                      select match.Groups[1].Value;

        return sanList;
    }

Upvotes: 4

Minh Nguyen
Minh Nguyen

Reputation: 2201

I have created a function to do this:

private static List<string> ParseSujectAlternativeName(X509Certificate2 cert)
{
            var result = new List<string>();

            var subjectAlternativeName = cert.Extensions.Cast<X509Extension>()
                                                .Where(n => n.Oid.FriendlyName.EqualsCase(SubjectAlternativeName))
                                                .Select(n => new AsnEncodedData(n.Oid, n.RawData))
                                                .Select(n => n.Format(true))
                                                .FirstOrDefault();

            if (subjectAlternativeName != null)
            {
                var alternativeNames = subjectAlternativeName.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);

                foreach (var alternativeName in alternativeNames)
                {
                    var groups = Regex.Match(alternativeName, @"^DNS Name=(.*)").Groups;

                    if (groups.Count > 0 && !String.IsNullOrEmpty(groups[1].Value))
                    {
                        result.Add(groups[1].Value);
                    }
                }
            }

            return result;           
}

Upvotes: 1

Mark Brackett
Mark Brackett

Reputation: 85665

Use the Format method of the extension for a printable version.

X509Certificate2 cert = /* your code here */;

foreach (X509Extension extension in cert.Extensions)
{
    // Create an AsnEncodedData object using the extensions information.
    AsnEncodedData asndata = new AsnEncodedData(extension.Oid, extension.RawData);
    Console.WriteLine("Extension type: {0}", extension.Oid.FriendlyName);
    Console.WriteLine("Oid value: {0}",asndata.Oid.Value);
    Console.WriteLine("Raw data length: {0} {1}", asndata.RawData.Length, Environment.NewLine);
    Console.WriteLine(asndata.Format(true));
}

Upvotes: 28

user7254972
user7254972

Reputation: 237

To get the "Subject Alternative Name" from a certificate:

X509Certificate2 cert = /* your code here */;

Console.WriteLine("UpnName : {0}{1}", cert.GetNameInfo(X509NameType.UpnName, false), Environment.NewLine);

Upvotes: 22

Related Questions