24

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 ????????
            }
        }
noctonura
  • 12,763
  • 10
  • 52
  • 85

10 Answers10

26

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));
}
ccellar
  • 10,326
  • 2
  • 38
  • 56
Mark Brackett
  • 84,552
  • 17
  • 108
  • 152
  • 3
    I needed to add .Extensions, like so: foreach(X509Extension extension in cert.Extensions) – dcrobbins Jan 10 '17 at 20:16
  • 2
    From the documentation of the Format method you linked to: "*Do not rely on output from this method, because it's intended for pretty-printing, and its output format is not guaranteed.*" Example differences in outputs for *the same certificate* are provided. – gaazkam Aug 31 '21 at 22:54
  • With .net >7 there is an extension that is capable to pase some information from a SAN https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509subjectalternativenameextension?view=net-7.0 – Daniel Fisher lennybacon May 22 '23 at 21:47
20

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);
Adrian Sanguineti
  • 2,455
  • 1
  • 27
  • 29
user7254972
  • 217
  • 2
  • 2
  • 2
    Alternatively, you may want to use `X508NameType.DnsName`, if that's what you are after. In any case, `GetNameInfo` is the correct method, instead of parsing the certificate. – John Gietzen Oct 31 '18 at 20:04
  • 5
    Note that this only returns a single value, even if the cert has multiple subjectAltNames.. @Mark Brackett's approach returns all values. – user2871239 Jan 30 '19 at 15:21
  • Or `X509NameType.UrlName` if that's what your after ;) – Anttu Oct 13 '20 at 12:18
  • What is the difference between DNS, UPN and URL names? The descriptions provided in the docs here are somewhat circular: https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509nametype?view=net-7.0#fields – pooya13 Jan 18 '23 at 08:16
10

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;
}
0xced
  • 25,219
  • 10
  • 103
  • 255
Sebastian Krysmanski
  • 8,114
  • 10
  • 49
  • 91
  • 1
    This is the correct kind of solution, parsing formated strings is not a good to way to do it. – NiKiZe Jul 29 '22 at 17:38
5

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;
        }
Rui Caramalho
  • 455
  • 8
  • 16
4

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;
    }
Jason Shuler
  • 382
  • 3
  • 7
  • 3
    Notice that this solution doesn’t work on non-English versions of Window, e.g. in Polish OS version ‘DNS name’ is ‘Nazwa DNS’. The term ‘Subject Alternative Name’ is translated also. – rgb Jul 04 '19 at 18:11
  • 1
    See https://stackoverflow.com/a/59382929/8302901 for a platform-independent language independent version of this. – Dan Dec 17 '19 at 22:22
3

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]);
        }
    }
Dan
  • 858
  • 7
  • 18
3

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();
        }
    }
}
Chris Benard
  • 3,167
  • 2
  • 29
  • 35
Mafoo
  • 69
  • 5
2

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];
}
Adassko
  • 5,201
  • 20
  • 37
  • This is the only code so far that was able to parse my subject alternative name extension correctly. I tested all the other ones and almost gave up. – Andreas Pardeike Nov 11 '21 at 13:57
1

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;           
}
Minh Nguyen
  • 2,106
  • 1
  • 28
  • 34
  • You can also get a result list like so: `result = Regex.Matches(subjectAlternativeName, "\\bDNS Name=(?.*)\\b").Where(m => m.Success).Select(m => m.Groups["domain"].Value).ToList()` – ProgrammingLlama Jan 22 '19 at 03:11
  • For multilingual replace `.Where(n => n.Oid.FriendlyName.EqualsCase(SubjectAlternativeName))` with `.Where(n => n.Oid.Value== "2.5.29.17")` – Rui Caramalho Jan 07 '20 at 20:33
1

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;
    }
Paul Hermans
  • 361
  • 2
  • 4
  • 13