Skip to content

Certificate Decoder — C# source

Paste a PEM X.509 certificate and see its subject, issuer, SAN, validity, key usage, fingerprints, and chain details in a human-readable format.

This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Certificate Decoder — pure ASN.1 DER parser + X.509 certificate decoder.
// C# 12 / .NET 8 — ported from src/lib/cert-decoder.ts (the canonical
// TypeScript implementation). Display source for CosmoDev's polyglot pages.
//
// No external dependencies — DER is deterministic and parsed sequentially:
// the certificate is walked TLV-by-TLV (the outer SEQUENCE, the
// TBSCertificate, RDN attributes, validity times, the SubjectPublicKeyInfo
// and every extension), never via a higher-level X509 API. The same OID
// name map, key-usage bit table and EC curve table as the TS reference.

using System.Text;
using System.Text.RegularExpressions;

/// <summary>ASN.1 tag class: the top two bits of the identifier octet.</summary>
public enum TagClass
{
    Universal,
    Application,
    Context,
    Private,
}

/// <summary>One parsed DER TLV node: tag info, raw value bytes and, for
/// constructed types, the recursively parsed children.</summary>
public sealed class Asn1Node
{
    public required TagClass TagClass { get; init; }
    public bool Constructed { get; init; }
    public int TagNumber { get; init; }
    /// <summary>Value bytes (decoded content).</summary>
    public required byte[] RawValue { get; init; }
    /// <summary>Offset into the original buffer.</summary>
    public int Offset { get; init; }
    public List<Asn1Node>? Children { get; set; }
}

/// <summary>Sequential DER reader over one buffer.</summary>
public sealed class DerParser
{
    private readonly byte[] _buf;
    private int _pos;

    public DerParser(byte[] buf) => _buf = buf;

    public bool Exhausted => _pos >= _buf.Length;

    /// <summary>Read the next TLV node.</summary>
    public Asn1Node ReadNode()
    {
        var offset = _pos;
        var byte0 = ReadByte();

        var tagClass =
            (byte0 & 0xc0) == 0x00 ? TagClass.Universal
            : (byte0 & 0xc0) == 0x40 ? TagClass.Application
            : (byte0 & 0xc0) == 0x80 ? TagClass.Context
            : TagClass.Private;

        var constructed = (byte0 & 0x20) != 0;
        var tagNumber = byte0 & 0x1f;

        // Long-form tag (tag number >= 31)
        if (tagNumber == 0x1f)
        {
            tagNumber = 0;
            int b;
            do
            {
                b = ReadByte();
                tagNumber = (tagNumber << 7) | (b & 0x7f);
            }
            while ((b & 0x80) != 0);
        }

        var length = ReadLength();

        if (_pos + length > _buf.Length)
        {
            throw new FormatException(
                $"Truncated DER: need {length} bytes at offset {_pos}, but only {_buf.Length - _pos} remain");
        }

        var rawValue = _buf[_pos..(_pos + length)];
        _pos += length;

        var node = new Asn1Node
        {
            TagClass = tagClass,
            Constructed = constructed,
            TagNumber = tagNumber,
            RawValue = rawValue,
            Offset = offset,
        };

        // Parse children for constructed types
        if (constructed && rawValue.Length > 0)
        {
            var childParser = new DerParser(rawValue);
            var children = new List<Asn1Node>();
            while (!childParser.Exhausted)
            {
                children.Add(childParser.ReadNode());
            }
            node.Children = children;
        }

        return node;
    }

    private byte ReadByte()
    {
        if (_pos >= _buf.Length) throw new FormatException("Unexpected end of DER data");
        return _buf[_pos++];
    }

    private int ReadLength()
    {
        var first = ReadByte();
        if (first < 0x80) return first;

        var numBytes = first & 0x7f;
        if (numBytes == 0) throw new FormatException("Indefinite length is not supported in DER");
        if (numBytes > 4) throw new FormatException($"Length too large: {numBytes} bytes");

        var len = 0;
        for (var i = 0; i < numBytes; i++)
        {
            len = (len << 8) | ReadByte();
        }
        return len;
    }
}

/// <summary>One Relative Distinguished Name attribute (e.g. CN=example.com).</summary>
public sealed record RdnAttribute(string Type, string Value);

/// <summary>The subset of X.509 extensions the decoder surfaces.</summary>
public sealed record CertificateExtensions(
    bool? BasicConstraintsCA,
    List<string>? KeyUsage,
    List<string>? ExtKeyUsage,
    List<string> SanDns,
    List<string> SanIp,
    List<string> SanEmail)
{
    public static CertificateExtensions Empty() =>
        new(null, null, null, [], [], []);
}

/// <summary>Everything decoded from one PEM certificate block.</summary>
public sealed record CertificateInfo(
    IReadOnlyList<RdnAttribute> Subject,
    string SubjectDn,
    IReadOnlyList<RdnAttribute> Issuer,
    string IssuerDn,
    DateTimeOffset NotBefore,
    DateTimeOffset NotAfter,
    /// <summary>Serial number as colon-separated uppercase hex.</summary>
    string SerialNumber,
    string SignatureAlgorithm,
    string KeyAlgorithm,
    int KeySize,
    bool? BasicConstraintsCA,
    IReadOnlyList<string> KeyUsage,
    IReadOnlyList<string> ExtKeyUsage,
    IReadOnlyList<string> SanDns,
    IReadOnlyList<string> SanIp,
    IReadOnlyList<string> SanEmail,
    /// <summary>Version number (0=v1, 1=v2, 2=v3).</summary>
    int Version,
    /// <summary>Raw DER bytes (for fingerprinting).</summary>
    byte[] RawDer);

public static class CertDecoder
{
    // ── OID name map ──────────────────────────────────────────────────────────

    public static readonly IReadOnlyDictionary<string, string> OidNames = new Dictionary<string, string>
    {
        ["1.2.840.113549.1.1.1"] = "RSA",
        ["1.2.840.113549.1.1.5"] = "SHA-1 with RSA",
        ["1.2.840.113549.1.1.11"] = "SHA-256 with RSA",
        ["1.2.840.113549.1.1.12"] = "SHA-384 with RSA",
        ["1.2.840.113549.1.1.13"] = "SHA-512 with RSA",
        ["1.2.840.113549.1.1.14"] = "SHA-224 with RSA",
        ["1.2.840.10045.2.1"] = "ECDSA",
        ["1.2.840.10045.4.3.2"] = "ECDSA with SHA-256",
        ["1.2.840.10045.4.3.3"] = "ECDSA with SHA-384",
        ["1.2.840.10045.4.3.4"] = "ECDSA with SHA-512",
        ["1.3.14.3.2.29"] = "SHA-1 with RSA (OIW)",
        ["2.5.4.3"] = "CN",
        ["2.5.4.6"] = "C",
        ["2.5.4.7"] = "L",
        ["2.5.4.8"] = "ST",
        ["2.5.4.10"] = "O",
        ["2.5.4.11"] = "OU",
        ["2.5.29.14"] = "Subject Key Identifier",
        ["2.5.29.15"] = "Key Usage",
        ["2.5.29.17"] = "Subject Alternative Name",
        ["2.5.29.19"] = "Basic Constraints",
        ["2.5.29.35"] = "Authority Key Identifier",
        ["2.5.29.37"] = "Extended Key Usage",
        ["1.3.6.1.5.5.7.1.1"] = "Authority Information Access",
        ["1.3.6.1.5.5.7.3.1"] = "serverAuth",
        ["1.3.6.1.5.5.7.3.2"] = "clientAuth",
        ["1.3.6.1.5.5.7.3.3"] = "codeSigning",
        ["1.3.6.1.5.5.7.3.4"] = "emailProtection",
        ["1.3.6.1.5.5.7.3.8"] = "timeStamping",
        ["1.2.840.113549.1.9.14"] = "Extension Request",
        ["1.2.840.113549.1.9.1"] = "emailAddress",
        ["1.3.6.1.4.1.11129.2.1.17"] = "CT Precertificate SCTs",
        ["1.3.6.1.5.5.7.1.3"] = "CRL Distribution Points",
        ["1.3.6.1.4.1.311.21.7"] = "Microsoft Certificate Template",
    };

    /// <summary>EC named-curve OIDs.</summary>
    public static readonly IReadOnlyDictionary<string, string> EcCurveNames = new Dictionary<string, string>
    {
        ["1.2.840.10045.3.1.7"] = "P-256",
        ["1.3.132.0.34"] = "P-384",
        ["1.3.132.0.35"] = "P-512",
        ["1.3.132.0.10"] = "secp256k1",
    };

    /// <summary>Key Usage bit names, MSB-first inside the BIT STRING.</summary>
    private static readonly string[] KeyUsageBits =
    [
        "digitalSignature",
        "nonRepudiation",
        "keyEncipherment",
        "dataEncipherment",
        "keyAgreement",
        "keyCertSign",
        "cRLSign",
        "encipherOnly",
        "decipherOnly",
    ];

    // ── OID decoding ──────────────────────────────────────────────────────────

    private static string DecodeOid(byte[] bytes)
    {
        if (bytes.Length == 0) throw new FormatException("Empty OID");
        var parts = new List<int> { bytes[0] / 40, bytes[0] % 40 };

        var value = 0;
        for (var i = 1; i < bytes.Length; i++)
        {
            var b = bytes[i];
            value = (value << 7) | (b & 0x7f);
            if ((b & 0x80) == 0)
            {
                parts.Add(value);
                value = 0;
            }
        }
        return string.Join('.', parts);
    }

    private static string OidName(string oid) => OidNames.GetValueOrDefault(oid, oid);

    // ── RDN (Relative Distinguished Name) helpers ─────────────────────────────

    private static List<RdnAttribute> ParseRdn(Asn1Node node)
    {
        var attrs = new List<RdnAttribute>();
        if (node.Children == null) return attrs;

        foreach (var rdnSet in node.Children)
        {
            if (rdnSet.Children == null) continue;
            foreach (var attrSeq in rdnSet.Children)
            {
                if (attrSeq.Children == null || attrSeq.Children.Count < 2) continue;
                var oid = DecodeOid(attrSeq.Children[0].RawValue);
                var valueStr = ReadString(attrSeq.Children[1]);
                attrs.Add(new RdnAttribute(OidName(oid), valueStr));
            }
        }
        return attrs;
    }

    private static string FormatDn(IEnumerable<RdnAttribute> attrs) =>
        string.Join(", ", attrs.Select(a => $"{a.Type}={a.Value}"));

    private static string ReadString(Asn1Node node)
    {
        // Try to decode as text string
        var tag = node.TagNumber;
        if (tag is 12 or 19 or 22 or 30 or 36)
        {
            // UTF8String(12), PrintableString(19), IA5String(22), BMPString(30),
            // etc. — all decode as text
            return Encoding.UTF8.GetString(node.RawValue);
        }
        // Fallback: try strict UTF-8
        try
        {
            return new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(node.RawValue);
        }
        catch (DecoderFallbackException)
        {
            // Last resort: hex
            return string.Join(':', node.RawValue.Select(b => b.ToString("x2")));
        }
    }

    // ── Time helpers ──────────────────────────────────────────────────────────

    private static readonly Regex UtcTimeRe = new(@"^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$", RegexOptions.Compiled);
    private static readonly Regex GeneralizedTimeRe = new(@"^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$", RegexOptions.Compiled);

    private static DateTimeOffset ParseTime(Asn1Node node)
    {
        var str = ReadString(node);
        // UTCTime (tag 23): YYMMDDHHMMSSZ
        if (node.TagNumber == 23)
        {
            var m = UtcTimeRe.Match(str);
            if (!m.Success) throw new FormatException($"Invalid UTCTime: {str}");
            var year = int.Parse(m.Groups[1].Value);
            year += year >= 50 ? 1900 : 2000;
            return Utc(year, m.Groups);
        }
        // GeneralizedTime (tag 24): YYYYMMDDHHMMSSZ
        if (node.TagNumber == 24)
        {
            var m = GeneralizedTimeRe.Match(str);
            if (!m.Success) throw new FormatException($"Invalid GeneralizedTime: {str}");
            return Utc(int.Parse(m.Groups[1].Value), m.Groups);
        }
        throw new FormatException($"Unknown time tag: {node.TagNumber}");

        DateTimeOffset Utc(int fullYear, GroupCollection groups) => new(
            fullYear, int.Parse(groups[2].Value), int.Parse(groups[3].Value),
            int.Parse(groups[4].Value), int.Parse(groups[5].Value), int.Parse(groups[6].Value),
            TimeSpan.Zero);
    }

    // ── Extension parsing ─────────────────────────────────────────────────────

    private static CertificateExtensions ParseExtensions(IReadOnlyList<Asn1Node> nodes)
    {
        var basicConstraintsCA = (bool?)null;
        List<string>? keyUsage = null;
        List<string>? extKeyUsage = null;
        var sanDns = new List<string>();
        var sanIp = new List<string>();
        var sanEmail = new List<string>();

        foreach (var extNode in nodes)
        {
            if (extNode.Children == null || extNode.Children.Count < 2) continue;
            var oid = DecodeOid(extNode.Children[0].RawValue);

            // Skip critical boolean if present
            var valueIdx = 1;
            if (extNode.Children.Count >= 3
                && extNode.Children[1].TagNumber == 1
                && extNode.Children[1].TagClass == TagClass.Universal)
            {
                valueIdx = 2;
            }

            // The value is wrapped in an OCTET STRING containing the actual DER
            var octetContent = extNode.Children[valueIdx].RawValue;
            if (octetContent.Length == 0) continue;

            var inner = new DerParser(octetContent);
            if (inner.Exhausted) continue;
            var content = inner.ReadNode();

            if (oid == "2.5.29.19")
            {
                // Basic Constraints
                var isCa = false;
                if (content.Children is { Count: > 0 })
                {
                    var boolVal = content.Children[0];
                    if (boolVal.TagNumber == 1 && boolVal.RawValue.Length == 1 && boolVal.RawValue[0] == 0xff)
                    {
                        isCa = true;
                    }
                }
                basicConstraintsCA = isCa;
            }
            else if (oid == "2.5.29.15")
            {
                // Key Usage — BIT STRING
                keyUsage = ParseBitString(content);
            }
            else if (oid == "2.5.29.37")
            {
                // Extended Key Usage
                extKeyUsage = [];
                if (content.Children != null)
                {
                    foreach (var child in content.Children)
                    {
                        extKeyUsage.Add(OidName(DecodeOid(child.RawValue)));
                    }
                }
            }
            else if (oid == "2.5.29.17")
            {
                // Subject Alternative Name
                if (content.Children != null)
                {
                    foreach (var child in content.Children)
                    {
                        if (child.TagClass != TagClass.Context) continue;
                        var tag = child.TagNumber;
                        if (tag == 2)
                        {
                            // DNS
                            sanDns.Add(ReadString(child));
                        }
                        else if (tag == 7)
                        {
                            // IP
                            var ipBytes = child.RawValue;
                            if (ipBytes.Length == 4)
                            {
                                sanIp.Add(string.Join('.', ipBytes));
                            }
                            else if (ipBytes.Length == 16)
                            {
                                // IPv6 — produce standard hex representation
                                var parts = new List<string>();
                                for (var i = 0; i < 16; i += 2)
                                {
                                    parts.Add(((ipBytes[i] << 8) | ipBytes[i + 1]).ToString("x"));
                                }
                                sanIp.Add(string.Join(':', parts));
                            }
                        }
                        else if (tag == 1)
                        {
                            // Email
                            sanEmail.Add(ReadString(child));
                        }
                    }
                }
            }
        }

        return new CertificateExtensions(basicConstraintsCA, keyUsage, extKeyUsage, sanDns, sanIp, sanEmail);
    }

    private static List<string> ParseBitString(Asn1Node node)
    {
        if (node.RawValue.Length < 2) return [];
        var unusedBits = node.RawValue[0];
        var octets = node.RawValue[1..];
        var flags = new List<string>();

        for (var i = 0; i < KeyUsageBits.Length; i++)
        {
            var octetIdx = i / 8;
            var bitIdx = 7 - (i % 8);
            if (octetIdx < octets.Length && (octets[octetIdx] & (1 << bitIdx)) != 0)
            {
                flags.Add(KeyUsageBits[i]);
            }
        }

        // Mask out unused bits
        if (unusedBits > 0 && flags.Count > 0)
        {
            var totalBits = octets.Length * 8 - unusedBits;
            while (flags.Count > totalBits)
            {
                flags.RemoveAt(flags.Count - 1);
            }
        }

        return flags;
    }

    // ── PEM handling ──────────────────────────────────────────────────────────

    private static readonly Regex PemRegex =
        new(@"-----BEGIN\s+CERTIFICATE-----\s*\r?\n([\s\S]*?)\r?\n-----END\s+CERTIFICATE-----", RegexOptions.Compiled);

    private static byte[] PemToDer(string pem)
    {
        // Strip whitespace and decode base64
        var b64 = Regex.Replace(pem, @"\s", "");
        return Convert.FromBase64String(b64);
    }

    private static List<string> ExtractPemBlocks(string pem)
    {
        var blocks = new List<string>();
        foreach (Match match in PemRegex.Matches(pem))
        {
            blocks.Add(match.Groups[1].Value);
        }
        return blocks;
    }

    // ── Main decoder ──────────────────────────────────────────────────────────

    public static CertificateInfo DecodeCertificate(string pem)
    {
        if (string.IsNullOrWhiteSpace(pem))
        {
            throw new ArgumentException("Empty input — paste a PEM certificate");
        }

        var blocks = ExtractPemBlocks(pem);
        if (blocks.Count == 0)
        {
            throw new ArgumentException("No PEM certificate block found — expected -----BEGIN CERTIFICATE-----");
        }

        // Decode the first certificate
        var der = PemToDer(blocks[0]);

        // Parse outer SEQUENCE
        var parser = new DerParser(der);
        var certSeq = parser.ReadNode();
        if (certSeq.Children == null || certSeq.Children.Count < 3)
        {
            throw new FormatException(
                "Invalid certificate structure: expected TBSCertificate, signatureAlgorithm, signatureValue");
        }

        var tbs = certSeq.Children[0];
        var sigAlgOid = certSeq.Children[1].Children != null
            ? DecodeOid(certSeq.Children[1].Children[0].RawValue)
            : "";

        // Parse TBSCertificate
        if (tbs.Children == null || tbs.Children.Count < 7)
        {
            throw new FormatException("Invalid TBSCertificate structure");
        }

        var idx = 0;

        // Version (explicit context [0])
        var version = 0; // default v1
        if (tbs.Children[idx].TagClass == TagClass.Context && tbs.Children[idx].TagNumber == 0)
        {
            var versionNode = tbs.Children[idx].Children?[0];
            if (versionNode != null && versionNode.RawValue.Length == 1)
            {
                version = versionNode.RawValue[0];
            }
            idx++;
        }

        // Serial Number
        var serialNode = tbs.Children[idx++];

        // Signature Algorithm (inside TBS)
        idx++; // skip inner sig algorithm

        // Issuer
        var issuer = ParseRdn(tbs.Children[idx++]);

        // Validity
        var validityNode = tbs.Children[idx++];
        var notBefore = DateTimeOffset.MinValue;
        var notAfter = DateTimeOffset.MinValue;
        if (validityNode.Children is { Count: >= 2 })
        {
            notBefore = ParseTime(validityNode.Children[0]);
            notAfter = ParseTime(validityNode.Children[1]);
        }

        // Subject
        var subject = ParseRdn(tbs.Children[idx++]);

        // SubjectPublicKeyInfo
        var spkiNode = tbs.Children[idx++];
        var keyAlgorithm = "unknown";
        var keySize = 0;

        if (spkiNode.Children is { Count: >= 2 })
        {
            var algSeq = spkiNode.Children[0];
            var keyBits = spkiNode.Children[1].RawValue;

            if (algSeq.Children is { Count: >= 1 })
            {
                var algOid = DecodeOid(algSeq.Children[0].RawValue);
                keyAlgorithm = OidName(algOid);

                // Key size estimation
                if (algOid == "1.2.840.113549.1.1.1")
                {
                    // RSA: the BIT STRING value starts with an unused-bits byte (0x00),
                    // then a DER-encoded SEQUENCE of { modulus INTEGER, exponent INTEGER }.
                    if (keyBits.Length > 1)
                    {
                        var rsaInner = new DerParser(keyBits[1..]); // skip unused-bits byte
                        if (!rsaInner.Exhausted)
                        {
                            var rsaSeq = rsaInner.ReadNode();
                            // The modulus INTEGER is the first child
                            if (rsaSeq.Children is { Count: > 0 })
                            {
                                var modBytes = rsaSeq.Children[0].RawValue;
                                // First byte may be 0x00 padding for positive sign
                                keySize = (modBytes[0] == 0x00 ? modBytes.Length - 1 : modBytes.Length) * 8;
                            }
                        }
                    }
                }
                else if (algOid == "1.2.840.10045.2.1")
                {
                    // ECDSA: look at the curve OID parameter
                    if (algSeq.Children.Count >= 2)
                    {
                        var curveOid = DecodeOid(algSeq.Children[1].RawValue);
                        if (EcCurveNames.TryGetValue(curveOid, out var curveName))
                        {
                            keyAlgorithm = $"{keyAlgorithm} ({curveName})";
                        }
                    }
                    // EC public key: BIT STRING value = 0x00 (unused) + uncompressed point.
                    // For P-256, the point is 65 bytes (0x04 + 32 + 32). Key "size" = curve order bits.
                    if (keyBits.Length > 1)
                    {
                        keySize = (keyBits.Length - 1) * 8; // subtract the 0x04 prefix byte count → bits
                    }
                }
                else
                {
                    keySize = keyBits.Length > 1 ? (keyBits.Length - 1) * 8 : 0;
                }
            }
        }

        // Extensions — look for context [3] after subjectPublicKeyInfo
        var extensions = CertificateExtensions.Empty();
        while (idx < tbs.Children.Count)
        {
            var child = tbs.Children[idx];
            if (child.TagClass == TagClass.Context && child.TagNumber == 3 && child.Children != null)
            {
                // context [3] wraps a single outer SEQUENCE of Extension entries
                var outerSeq = child.Children[0];
                if (outerSeq.Children != null)
                {
                    extensions = ParseExtensions(outerSeq.Children);
                }
            }
            idx++;
        }

        // Serial number as colon-separated uppercase hex
        var serialHex = "";
        if (serialNode.RawValue.Length > 0)
        {
            // Remove leading zero if it's just padding
            var serialBytes = serialNode.RawValue;
            if (serialBytes[0] == 0x00 && serialBytes.Length > 1)
            {
                serialBytes = serialBytes[1..];
            }
            serialHex = string.Join(':', serialBytes.Select(b => b.ToString("X2")));
        }

        return new CertificateInfo(
            subject,
            FormatDn(subject),
            issuer,
            FormatDn(issuer),
            notBefore,
            notAfter,
            serialHex,
            OidName(sigAlgOid),
            keyAlgorithm,
            keySize,
            extensions.BasicConstraintsCA,
            extensions.KeyUsage ?? [],
            extensions.ExtKeyUsage ?? [],
            extensions.SanDns,
            extensions.SanIp,
            extensions.SanEmail,
            version,
            der);
    }

    public static bool IsExpired(CertificateInfo cert) => cert.NotAfter < DateTimeOffset.UtcNow;

    public static int DaysUntilExpiry(CertificateInfo cert)
    {
        var diffMs = (cert.NotAfter - DateTimeOffset.UtcNow).TotalMilliseconds;
        return (int)Math.Ceiling(diffMs / (1000 * 60 * 60 * 24));
    }

    /// <summary>Extract all PEM certificate blocks and return info for each.</summary>
    public static List<CertificateInfo> DecodeCertificateChain(string pem)
    {
        if (string.IsNullOrWhiteSpace(pem))
        {
            throw new ArgumentException("Empty input — paste PEM certificate(s)");
        }

        var blocks = ExtractPemBlocks(pem);
        if (blocks.Count == 0)
        {
            throw new ArgumentException("No PEM certificate block found — expected -----BEGIN CERTIFICATE-----");
        }

        return blocks
            .Select(block => DecodeCertificate($"-----BEGIN CERTIFICATE-----\n{block}\n-----END CERTIFICATE-----"))
            .ToList();
    }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →