Skip to content

CSR Generator — C# source

Generate a Certificate Signing Request and private key pair entirely in your browser. Download the CSR and key as PEM files.

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

// CSR Generator — ASN.1 DER encoding + PKCS#10 CSR construction.
// C# 12 / .NET 8 — ported from src/lib/csr-generator.ts (the canonical
// TypeScript implementation). Display source for CosmoDev's polyglot pages.
//
// The whole certificate-request pipeline runs locally: key pair generation
// (RSA / ECDsa via System.Security.Cryptography), DER assembly of the
// CertificationRequest structure, signature, and PEM wrapping.

using System.Security.Cryptography;
using System.Text;

/// <summary>The supported key algorithms.</summary>
public enum CsrKeyAlgorithm
{
    Rsa2048,
    Rsa4096,
    EcdsaP256,
}

/// <summary>CSR generation options.</summary>
public sealed record CsrOptions(
    string CommonName,
    CsrKeyAlgorithm KeyAlgorithm,
    string? Organization = null,
    string? Country = null,
    string? State = null,
    string? Locality = null,
    string? Email = null,
    string[]? SubjectAltNames = null);

/// <summary>A generated CSR plus its matching private key, both PEM.</summary>
/// <param name="Csr">The signed PKCS#10 CSR, PEM-wrapped.</param>
/// <param name="PrivateKey">The PKCS#8 private key, PEM-wrapped.</param>
public sealed record CsrResult(string Csr, string PrivateKey);

public static class CsrGenerator
{
    // --- DER primitives ---------------------------------------------------------

    /// <summary>DER length: short form below 0x80, long form (0x80 | byte count) above.</summary>
    public static byte[] EncodeLength(int len)
    {
        if (len < 0) throw new ArgumentOutOfRangeException(nameof(len));
        if (len < 0x80) return [(byte)len];
        var bytes = new List<byte>();
        var n = len;
        while (n > 0)
        {
            bytes.Insert(0, (byte)(n & 0xff));
            n /= 256;
        }
        return [(byte)(0x80 | bytes.Count), .. bytes];
    }

    /// <summary>Wrap content bytes in a tag + DER length header.</summary>
    private static byte[] Tlv(byte tag, byte[] content)
    {
        var len = EncodeLength(content.Length);
        var output = new byte[1 + len.Length + content.Length];
        output[0] = tag;
        len.CopyTo(output, 1);
        content.CopyTo(output, 1 + len.Length);
        return output;
    }

    private static byte[] Concat(params byte[][] parts)
    {
        var output = new byte[parts.Sum(p => p.Length)];
        var offset = 0;
        foreach (var p in parts)
        {
            p.CopyTo(output, offset);
            offset += p.Length;
        }
        return output;
    }

    /// <summary>
    /// DER INTEGER. Accepts a small non-negative number, or raw big-endian
    /// bytes (signature r/s halves): leading zeros are stripped and a 0x00
    /// sign byte is prepended when the high bit is set, per DER
    /// minimal-encoding rules.
    /// </summary>
    public static byte[] EncodeInteger(int value)
    {
        if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
        var digits = new List<byte>();
        var n = value;
        while (n > 0)
        {
            digits.Insert(0, (byte)(n & 0xff));
            n /= 256;
        }
        var bytes = digits.Count > 0 ? digits.ToArray() : [0];
        return Tlv(0x02, bytes[0] > 0x7f ? [0, .. bytes] : bytes);
    }

    public static byte[] EncodeInteger(ReadOnlySpan<byte> value)
    {
        var start = 0;
        while (start < value.Length - 1 && value[start] == 0) start++;
        var bytes = value[start..].ToArray();
        return Tlv(0x02, bytes.Length > 0 && bytes[0] > 0x7f ? [0, .. bytes] : bytes);
    }

    /// <summary>DER OBJECT IDENTIFIER from a dotted string, e.g. "1.2.840.113549.1.1.11".</summary>
    public static byte[] EncodeOid(string oid)
    {
        var parts = oid.Split('.').Select(long.Parse).ToArray();
        if (parts.Length < 2 || parts.Any(p => p < 0))
        {
            throw new FormatException($"Invalid OID: {oid}");
        }
        var content = new List<byte> { (byte)(40 * parts[0] + parts[1]) };
        for (var i = 2; i < parts.Length; i++)
        {
            var stack = new List<byte>();
            var v = parts[i];
            do
            {
                stack.Insert(0, (byte)(v & 0x7f));
                v /= 128;
            } while (v > 0);
            for (var j = 0; j < stack.Count - 1; j++) content.Add((byte)(stack[j] | 0x80));
            content.Add(stack[^1]);
        }
        return Tlv(0x06, [.. content]);
    }

    public static byte[] EncodeUtf8String(string str) =>
        Tlv(0x0c, Encoding.UTF8.GetBytes(str));

    /// <summary>PrintableString — the required type for countryName in a Name.</summary>
    public static byte[] EncodePrintableString(string str)
    {
        if (str.Any(ch => !char.IsAsciiLetterOrDigit(ch) && " '()+,-./:=?".IndexOf(ch) < 0))
        {
            throw new FormatException($"Not a PrintableString: {str}");
        }
        return Tlv(0x13, [.. str.Select(ch => (byte)ch)]);
    }

    public static byte[] EncodeBitString(byte[] data) =>
        Tlv(0x03, [0, .. data]); // 0 unused bits in the last octet

    public static byte[] EncodeOctetString(byte[] data) => Tlv(0x04, data);

    public static byte[] EncodeNull() => [0x05, 0x00];

    public static byte[] EncodeSequence(params byte[][] parts) =>
        Tlv(0x30, Concat(parts));

    public static byte[] EncodeSet(params byte[][] parts) =>
        Tlv(0x31, Concat(parts));

    // --- Object identifiers -----------------------------------------------------

    private const string OidCountry = "2.5.4.6";
    private const string OidState = "2.5.4.8";
    private const string OidLocality = "2.5.4.7";
    private const string OidOrganization = "2.5.4.10";
    private const string OidCommonName = "2.5.4.3";
    private const string OidEmail = "1.2.840.113549.1.9.1";
    private const string OidExtRequest = "1.2.840.113549.1.9.14"; // pkcs-9 at extensionRequest
    private const string OidSubjectAltName = "2.5.29.17";
    private const string OidRsaSha256 = "1.2.840.113549.1.1.11"; // sha256WithRSAEncryption
    private const string OidEcdsaSha256 = "1.2.840.10045.4.3.2"; // ecdsa-with-SHA256

    // --- Validation -------------------------------------------------------------

    /// <summary>
    /// Throws on invalid input: CN required, country (when present) a 2-letter
    /// ISO 3166-1 code, key algorithm one of the supported values.
    /// </summary>
    public static void ValidateOptions(CsrOptions options)
    {
        if (string.IsNullOrWhiteSpace(options.CommonName))
        {
            throw new ArgumentException("Common Name (CN) is required.");
        }
        var country = options.Country?.Trim();
        if (country is { Length: 2 } && country.All(char.IsLetter)) return;
        if (!string.IsNullOrEmpty(country))
        {
            throw new ArgumentException("Country must be a 2-letter ISO 3166-1 code (e.g. US, DE).");
        }
    }

    // --- SAN classification -----------------------------------------------------

    /// <summary>The four SAN entry kinds.</summary>
    public enum SanType { Dns, Ip, Email, Uri }

    /// <summary>Classify a SAN entry: IPv4/IPv6 → ip, http(s):// → uri, contains @ → email, else dns.</summary>
    public static SanType ClassifySanType(string entry)
    {
        var value = entry.Trim().ToLowerInvariant();
        if (IpToBytes(value) is not null) return SanType.Ip;
        if (value.StartsWith("http://", StringComparison.Ordinal) ||
            value.StartsWith("https://", StringComparison.Ordinal)) return SanType.Uri;
        if (value.Contains('@')) return SanType.Email;
        return SanType.Dns;
    }

    /// <summary>Parse an IPv4 or IPv6 literal to 4/16 bytes; null when not an IP literal.</summary>
    private static byte[]? IpToBytes(string ip)
    {
        var v4 = System.Text.RegularExpressions.Regex.Match(
            ip, @"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$");
        if (v4.Success)
        {
            var parts = v4.Groups.Cast<Group>().Skip(1).Select(g => int.Parse(g.Value)).ToArray();
            if (parts.Any(n => n > 255)) return null;
            return [.. parts.Select(n => (byte)n)];
        }
        if (!ip.Contains(':')) return null;
        var halves = ip.Split("::");
        if (halves.Length > 3) return null;
        var groups = new List<ushort>();
        foreach (var half in halves)
        {
            if (half.Length == 0) continue;
            foreach (var group in half.Split(':'))
            {
                if (group.Contains('.'))
                {
                    var tail = IpToBytes(group); // IPv4-mapped tail, e.g. ::ffff:10.0.0.1
                    if (tail is null || tail.Length != 4) return null;
                    groups.Add((ushort)((tail[0] << 8) | tail[1]));
                    groups.Add((ushort)((tail[2] << 8) | tail[3]));
                }
                else if (group.Length is >= 1 and <= 4 && group.All(Uri.IsHexDigit))
                {
                    groups.Add(Convert.ToUInt16(group, 16));
                }
                else
                {
                    return null;
                }
            }
        }
        if (halves.Length == 3) // one "::" present
        {
            var missing = 8 - groups.Count;
            if (missing < 0) return null;
            groups.AddRange(new ushort[missing]);
        }
        if (groups.Count != 8) return null;
        var output = new byte[16];
        for (var i = 0; i < 8; i++)
        {
            output[i * 2] = (byte)(groups[i] >> 8);
            output[i * 2 + 1] = (byte)(groups[i] & 0xff);
        }
        return output;
    }

    /// <summary>One GeneralName: context-specific implicit tags per RFC 5280.</summary>
    private static byte[] EncodeGeneralName(string entry)
    {
        var value = entry.Trim();
        var ascii = Encoding.ASCII.GetBytes(value);
        return ClassifySanType(value) switch
        {
            SanType.Ip => Tlv(0x87, IpToBytes(value.ToLowerInvariant())!),
            SanType.Uri => Tlv(0x86, ascii),
            SanType.Email => Tlv(0x81, ascii),
            _ => Tlv(0x82, ascii), // dNSName
        };
    }

    // --- PEM ---------------------------------------------------------------------

    /// <summary>PEM-wrap DER bytes: 64-character base64 lines between BEGIN/END markers.</summary>
    public static string PemEncode(byte[] der, string label)
    {
        var base64 = Convert.ToBase64String(der);
        var lines = Enumerable.Range(0, (base64.Length + 63) / 64)
            .Select(i => base64.Substring(i * 64, Math.Min(64, base64.Length - i * 64)));
        return $"-----BEGIN {label}-----\n{string.Join('\n', lines)}\n-----END {label}-----\n";
    }

    // --- Key generation + CSR assembly -------------------------------------------

    /// <summary>AttributeTypeAndValue (SEQUENCE of OID + value) wrapped in its RDN SET.</summary>
    private static byte[] Rdn(string oid, byte[] value) =>
        EncodeSet(EncodeSequence(EncodeOid(oid), value));

    /// <summary>
    /// Generate a key pair and build a signed PKCS#10 CSR (PEM) plus the
    /// matching PKCS#8 private key (PEM). .NET's ECDsa.SignData already
    /// returns the DER ECDSA-Sig-Value SEQUENCE the TS reference converts
    /// raw r||s into, so no conversion is needed here.
    /// </summary>
    public static CsrResult Generate(CsrOptions options)
    {
        ValidateOptions(options);
        byte[] spki;
        byte[] cri;
        byte[] signature;
        byte[] signatureAlgorithm;
        string privateKeyPem;

        if (options.KeyAlgorithm == CsrKeyAlgorithm.EcdsaP256)
        {
            using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
            spki = ecdsa.ExportSubjectPublicKeyInfo();
            privateKeyPem = ecdsa.ExportPkcs8PrivateKeyPem().Replace("\r\n", "\n");
            cri = BuildCri(options, spki, out _);
            signatureAlgorithm = EncodeSequence(EncodeOid(OidEcdsaSha256));
            var der = ecdsa.SignData(cri, HashAlgorithmName.SHA256); // already DER r,s SEQUENCE
            signature = EncodeBitString(der);
        }
        else
        {
            using var rsa = RSA.Create(options.KeyAlgorithm == CsrKeyAlgorithm.Rsa4096 ? 4096 : 2048);
            spki = rsa.ExportSubjectPublicKeyInfo();
            privateKeyPem = rsa.ExportPkcs8PrivateKeyPem().Replace("\r\n", "\n");
            cri = BuildCri(options, spki, out _);
            signatureAlgorithm = EncodeSequence(EncodeOid(OidRsaSha256), EncodeNull());
            var raw = rsa.SignData(cri, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
            signature = EncodeBitString(raw);
        }

        return new CsrResult(
            PemEncode(EncodeSequence(cri, signatureAlgorithm, signature), "CERTIFICATE REQUEST"),
            privateKeyPem);
    }

    /// <summary>Assemble the CertificationRequestInfo: version 0, subject, SPKI, [0] attributes.</summary>
    private static byte[] BuildCri(CsrOptions options, byte[] spki, out byte[] attributes)
    {
        // subject: RDNSequence in the conventional C, ST, L, O, CN, email order
        var rdns = new List<byte[]>();
        var country = options.Country?.Trim().ToUpperInvariant();
        if (!string.IsNullOrEmpty(country)) rdns.Add(Rdn(OidCountry, EncodePrintableString(country)));
        if (!string.IsNullOrWhiteSpace(options.State)) rdns.Add(Rdn(OidState, EncodeUtf8String(options.State!.Trim())));
        if (!string.IsNullOrWhiteSpace(options.Locality)) rdns.Add(Rdn(OidLocality, EncodeUtf8String(options.Locality!.Trim())));
        if (!string.IsNullOrWhiteSpace(options.Organization)) rdns.Add(Rdn(OidOrganization, EncodeUtf8String(options.Organization!.Trim())));
        rdns.Add(Rdn(OidCommonName, EncodeUtf8String(options.CommonName.Trim())));
        if (!string.IsNullOrWhiteSpace(options.Email)) rdns.Add(Rdn(OidEmail, EncodeUtf8String(options.Email!.Trim())));

        // attributes [0] IMPLICIT SET OF — extensionRequest carrying subjectAltName
        attributes = [];
        var sans = (options.SubjectAltNames ?? []).Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
        if (sans.Length > 0)
        {
            var extension = EncodeSequence(
                EncodeOid(OidSubjectAltName),
                EncodeOctetString(EncodeSequence(sans.Select(EncodeGeneralName).ToArray())));
            var attribute = EncodeSequence(EncodeOid(OidExtRequest), EncodeSet(EncodeSequence(extension)));
            attributes = Tlv(0xa0, attribute); // [0] IMPLICIT SET OF Attribute
        }

        return EncodeSequence(EncodeInteger(0), EncodeSequence([.. rdns]), spki, attributes);
    }
}

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 →