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 →