Skip to content

CSR Generator — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// CSR Generator - ASN.1 DER encoding + PKCS#10 CSR construction on the JVM's
// standard key/signature APIs.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Ported from src/lib/csr-generator.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference (which
// drives Web Crypto): the whole certificate-request pipeline runs in-process
// - key pair generation, DER assembly of the CertificationRequest structure,
// signature, and PEM wrapping. Nothing leaves the machine.
//
// One deliberate divergence, noted inline: the JVM's "SHA256withECDSA"
// already emits the DER ECDSA-Sig-Value SEQUENCE that PKCS#10 expects, so
// the TS reference's raw r||s -> DER conversion is unnecessary here.

import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.security.spec.RSAKeyGenParameterSpec
import java.util.Base64

/** The three key algorithms the tool offers. */
enum class KeyAlgorithm { RSA_2048, RSA_4096, ECDSA_P256 }

data class CSROptions(
    val commonName: String,
    val organization: String? = null,
    val country: String? = null,
    val state: String? = null,
    val locality: String? = null,
    val email: String? = null,
    val keyAlgorithm: KeyAlgorithm,
    val subjectAltNames: List<String> = emptyList(),
)

data class CSRResult(
    val csr: String,
    val privateKey: String,
)

// --- DER primitives ---------------------------------------------------------

/** DER length: short form below 0x80, long form (0x80 | byte count) above. */
fun encodeLength(len: Int): ByteArray {
    require(len >= 0) { "Length must be a non-negative integer" }
    if (len < 0x80) return byteArrayOf(len.toByte())
    val bytes = mutableListOf<Byte>()
    var n = len
    while (n > 0) {
        bytes.add(0, (n and 0xff).toByte())
        n = n shr 8
    }
    return (byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray())
}

/** Wrap content bytes in a tag + DER length header. */
private fun tlv(tag: Int, content: ByteArray): ByteArray =
    byteArrayOf(tag.toByte()) + encodeLength(content.size) + content

private fun concat(vararg parts: ByteArray): ByteArray {
    val out = ByteArray(parts.sumOf { it.size })
    var offset = 0
    for (p in parts) {
        p.copyInto(out, offset)
        offset += p.size
    }
    return out
}

/**
 * DER INTEGER from a small non-negative number: leading zeros stripped and a
 * 0x00 sign byte prepended when the high bit is set, per DER minimal rules.
 * BigInteger.toByteArray() already emits exactly that minimal two's-complement
 * form.
 */
fun encodeInteger(value: Int): ByteArray {
    require(value >= 0) { "encodeInteger supports non-negative integers only" }
    return tlv(0x02, BigInteger.valueOf(value.toLong()).toByteArray())
}

/**
 * DER INTEGER from 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.
 */
fun encodeInteger(value: ByteArray): ByteArray {
    var start = 0
    while (start < value.size - 1 && value[start].toInt() == 0) start++
    var bytes = value.copyOfRange(start, value.size)
    if (bytes.isNotEmpty() && (bytes[0].toInt() and 0xff) > 0x7f) {
        bytes = byteArrayOf(0) + bytes
    }
    return tlv(0x02, bytes)
}

/** DER OBJECT IDENTIFIER from a dotted string, e.g. "1.2.840.113549.1.1.11". */
fun encodeOID(oid: String): ByteArray {
    val parts = oid.split('.').map { it.toIntOrNull() ?: throw IllegalArgumentException("Invalid OID: $oid") }
    if (parts.size < 2 || parts.any { it < 0 }) throw IllegalArgumentException("Invalid OID: $oid")
    val content = mutableListOf(40 * parts[0] + parts[1])
    for (i in 2 until parts.size) {
        val stack = mutableListOf<Int>()
        var v = parts[i]
        do {
            stack.add(0, v and 0x7f)
            v = v shr 7
        } while (v > 0)
        for (j in 0 until stack.size - 1) content.add(stack[j] or 0x80)
        content.add(stack.last())
    }
    return tlv(0x06, content.map { it.toByte() }.toByteArray())
}

fun encodeUTF8String(str: String): ByteArray = tlv(0x0c, str.toByteArray(Charsets.UTF_8))

/** PrintableString - the required type for countryName in a Name. */
fun encodePrintableString(str: String): ByteArray {
    if (!Regex("^[A-Za-z0-9 '()+,\\-./:=?]*$").matches(str)) {
        throw IllegalArgumentException("Not a PrintableString: $str")
    }
    return tlv(0x13, str.toByteArray(Charsets.ISO_8859_1))
}

fun encodeBitString(data: ByteArray): ByteArray =
    tlv(0x03, byteArrayOf(0) + data) // 0 unused bits in the last octet

fun encodeOctetString(data: ByteArray): ByteArray = tlv(0x04, data)

fun encodeNull(): ByteArray = byteArrayOf(0x05, 0x00)

fun encodeSequence(vararg parts: ByteArray): ByteArray = tlv(0x30, concat(*parts))

fun encodeSet(vararg parts: ByteArray): ByteArray = tlv(0x31, concat(*parts))

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

private const val OID_COUNTRY = "2.5.4.6"
private const val OID_STATE = "2.5.4.8"
private const val OID_LOCALITY = "2.5.4.7"
private const val OID_ORGANIZATION = "2.5.4.10"
private const val OID_COMMON_NAME = "2.5.4.3"
private const val OID_EMAIL = "1.2.840.113549.1.9.1"
private const val OID_EXT_REQUEST = "1.2.840.113549.1.9.14" // pkcs-9 at extensionRequest
private const val OID_SUBJECT_ALT_NAME = "2.5.29.17"
private const val OID_RSA_SHA256 = "1.2.840.113549.1.1.11" // sha256WithRSAEncryption
private const val OID_ECDSA_SHA256 = "1.2.840.10045.4.3.2" // ecdsa-with-SHA256

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

/**
 * Throws on invalid input: CN required, country (when present) a 2-letter ISO
 * 3166-1 code, key algorithm one of the supported values.
 */
fun validateCSROptions(options: CSROptions) {
    if (options.commonName.isBlank()) {
        throw IllegalArgumentException("Common Name (CN) is required")
    }
    val country = options.country?.trim()
    if (!country.isNullOrEmpty() && !Regex("^[A-Za-z]{2}$").matches(country)) {
        throw IllegalArgumentException("Country must be a 2-letter ISO 3166-1 code (e.g. US, DE)")
    }
}

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

/** How a SAN entry is encoded in the GeneralName structure. */
enum class SanType { DNS, IP, EMAIL, URI }

/** Classify a SAN entry: IPv4/IPv6 -> ip, http(s):// -> uri, contains @ -> email, else dns. */
fun classifySanType(entry: String): SanType {
    val value = entry.trim().lowercase()
    if (ipToBytes(value) != null) return SanType.IP
    if (Regex("^https?://").containsMatchIn(value)) return SanType.URI
    if (value.contains('@')) return SanType.EMAIL
    return SanType.DNS
}

fun ipToBytes(ip: String): ByteArray? {
    val v4 = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$").find(ip)
    if (v4 != null) {
        val parts = v4.groupValues.drop(1).map { it.toInt() }
        if (parts.any { it > 255 }) return null
        return parts.map { it.toByte() }.toByteArray()
    }
    if (!ip.contains(':')) return null
    val halves = ip.split("::")
    if (halves.size > 2) return null
    val groups = mutableListOf<Int>()
    for (half in halves) {
        if (half.isEmpty()) continue
        for (group in half.split(':')) {
            if (group.contains('.')) {
                val tail = ipToBytes(group) // IPv4-mapped tail, e.g. ::ffff:10.0.0.1
                if (tail == null || tail.size != 4) return null
                groups.add(((tail[0].toInt() and 0xff) shl 8) or (tail[1].toInt() and 0xff))
                groups.add(((tail[2].toInt() and 0xff) shl 8) or (tail[3].toInt() and 0xff))
            } else if (Regex("^[0-9a-f]{1,4}$").matches(group)) {
                groups.add(group.toInt(16))
            } else {
                return null
            }
        }
    }
    if (halves.size == 2) {
        val missing = 8 - groups.size
        if (missing < 0) return null
        repeat(missing) { groups.add(0) }
    }
    if (groups.size != 8) return null
    return ByteArray(16) { i ->
        if (i % 2 == 0) (groups[i / 2] shr 8).toByte() else (groups[i / 2] and 0xff).toByte()
    }
}

/** One GeneralName: context-specific implicit tags per RFC 5280. */
private fun encodeGeneralName(entry: String): ByteArray {
    val value = entry.trim()
    val ascii = value.toByteArray(Charsets.ISO_8859_1)
    return when (classifySanType(value)) {
        SanType.IP -> tlv(0x87, ipToBytes(value.lowercase())!!)
        SanType.URI -> tlv(0x86, ascii)
        SanType.EMAIL -> tlv(0x81, ascii)
        SanType.DNS -> tlv(0x82, ascii) // dNSName
    }
}

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

/** PEM-wrap DER bytes: 64-character base64 lines between BEGIN/END markers. */
fun pemEncode(der: ByteArray, label: String): String {
    val b64 = Base64.getEncoder().encodeToString(der)
    return "-----BEGIN $label-----\n${b64.chunked(64).joinToString("\n")}\n-----END $label-----\n"
}

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

private fun generateKeyPair(algorithm: KeyAlgorithm): KeyPair {
    val kpg = if (algorithm == KeyAlgorithm.ECDSA_P256) {
        KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }
    } else {
        val bits = if (algorithm == KeyAlgorithm.RSA_4096) 4096 else 2048
        KeyPairGenerator.getInstance("RSA").apply {
            initialize(RSAKeyGenParameterSpec(bits, RSAKeyGenParameterSpec.F4)) // e = 65537
        }
    }
    return kpg.generateKeyPair()
}

/** AttributeTypeAndValue (SEQUENCE of OID + value) wrapped in its RDN SET. */
private fun rdn(oid: String, value: ByteArray): ByteArray =
    encodeSet(encodeSequence(encodeOID(oid), value))

/**
 * Generate a key pair and build a signed PKCS#10 CSR (PEM) plus the matching
 * PKCS#8 private key (PEM). Runs entirely in-process via the JVM's key and
 * signature services.
 */
fun generateCSR(options: CSROptions): CSRResult {
    validateCSROptions(options)
    val pair = generateKeyPair(options.keyAlgorithm)
    val spki = pair.public.encoded // X.509 SubjectPublicKeyInfo
    val pkcs8 = pair.private.encoded // PKCS#8

    // subject: RDNSequence in the conventional C, ST, L, O, CN, email order
    val rdns = mutableListOf<ByteArray>()
    options.country?.trim()?.takeIf { it.isNotEmpty() }?.let {
        rdns.add(rdn(OID_COUNTRY, encodePrintableString(it.uppercase())))
    }
    options.state?.trim()?.takeIf { it.isNotEmpty() }?.let { rdns.add(rdn(OID_STATE, encodeUTF8String(it))) }
    options.locality?.trim()?.takeIf { it.isNotEmpty() }?.let { rdns.add(rdn(OID_LOCALITY, encodeUTF8String(it))) }
    options.organization?.trim()?.takeIf { it.isNotEmpty() }?.let { rdns.add(rdn(OID_ORGANIZATION, encodeUTF8String(it))) }
    rdns.add(rdn(OID_COMMON_NAME, encodeUTF8String(options.commonName.trim())))
    options.email?.trim()?.takeIf { it.isNotEmpty() }?.let { rdns.add(rdn(OID_EMAIL, encodeUTF8String(it))) }

    // attributes [0] IMPLICIT SET OF — extensionRequest carrying subjectAltName
    var attributes = ByteArray(0)
    val sans = options.subjectAltNames.map { it.trim() }.filter { it.isNotEmpty() }
    if (sans.isNotEmpty()) {
        val extension = encodeSequence(
            encodeOID(OID_SUBJECT_ALT_NAME),
            encodeOctetString(encodeSequence(*sans.map(::encodeGeneralName).toTypedArray())),
        )
        val attribute = encodeSequence(
            encodeOID(OID_EXT_REQUEST),
            encodeSet(encodeSequence(extension)),
        )
        attributes = tlv(0xa0, attribute) // [0] IMPLICIT SET OF Attribute
    }

    // CertificationRequestInfo: version 0, subject, SPKI, [0] attributes
    val cri = encodeSequence(encodeInteger(0), encodeSequence(*rdns.toTypedArray()), spki, attributes)

    // signatureAlgorithm + signature over the DER CRI. The JVM's ECDSA
    // signature is already the DER ECDSA-Sig-Value SEQUENCE PKCS#10 wants
    // (Web Crypto returns raw r||s, which the TS reference converts).
    return if (options.keyAlgorithm == KeyAlgorithm.ECDSA_P256) {
        val signatureAlgorithm = encodeSequence(encodeOID(OID_ECDSA_SHA256))
        val raw = Signature.getInstance("SHA256withECDSA").apply {
            initSign(pair.private)
            update(cri)
        }.sign()
        CSRResult(
            csr = pemEncode(encodeSequence(cri, signatureAlgorithm, encodeBitString(raw)), "CERTIFICATE REQUEST"),
            privateKey = pemEncode(pkcs8, "PRIVATE KEY"),
        )
    } else {
        val signatureAlgorithm = encodeSequence(encodeOID(OID_RSA_SHA256), encodeNull())
        val raw = Signature.getInstance("SHA256withRSA").apply {
            initSign(pair.private)
            update(cri)
        }.sign()
        CSRResult(
            csr = pemEncode(encodeSequence(cri, signatureAlgorithm, encodeBitString(raw)), "CERTIFICATE REQUEST"),
            privateKey = pemEncode(pkcs8, "PRIVATE KEY"),
        )
    }
}

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 →