Skip to content

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

// Pure ASN.1 DER parser + X.509 certificate decoder.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Ported from src/lib/cert-decoder.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// inputs -> same decoded fields. DER is deterministic and parsed
// sequentially; no external dependencies.

import java.nio.charset.CodingErrorAction
import java.time.Duration
import java.time.Instant
import java.util.Base64

// --- OID name map ----------------------------------------------------------

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

// EC named-curve OIDs
val EC_CURVE_NAMES: Map<String, String> = mapOf(
    "1.2.840.10045.3.1.7" to "P-256",
    "1.3.132.0.34" to "P-384",
    "1.3.132.0.35" to "P-512",
    "1.3.132.0.10" to "secp256k1",
)

// Key Usage bit names
private val KEY_USAGE_BITS = listOf(
    "digitalSignature",
    "nonRepudiation",
    "keyEncipherment",
    "dataEncipherment",
    "keyAgreement",
    "keyCertSign",
    "cRLSign",
    "encipherOnly",
    "decipherOnly",
)

// --- ASN.1 DER types ---------------------------------------------------------

/** Which class a DER tag byte belongs to. */
enum class TagClass { UNIVERSAL, APPLICATION, CONTEXT, PRIVATE }

/** One parsed DER TLV node. */
data class ASN1Node(
    val tagClass: TagClass,
    val constructed: Boolean,
    val tagNumber: Int,
    val rawValue: ByteArray, // value bytes (decoded content)
    val offset: Int, // offset into original buffer
    val children: List<ASN1Node>?,
) {
    override fun equals(other: Any?): Boolean = this === other
    override fun hashCode(): Int = System.identityHashCode(this)
}

// --- DER parsing helpers -----------------------------------------------------

/** Sequential reader over one DER buffer. */
class DERParser(private val buf: ByteArray) {
    private var pos = 0

    val exhausted: Boolean
        get() = pos >= buf.size

    /** Read the next TLV node. */
    fun readNode(): ASN1Node {
        val offset = pos
        val byte0 = readByte().toInt() and 0xff

        val tagClass = when (byte0 and 0xc0) {
            0x00 -> TagClass.UNIVERSAL
            0x40 -> TagClass.APPLICATION
            0x80 -> TagClass.CONTEXT
            else -> TagClass.PRIVATE
        }

        val constructed = (byte0 and 0x20) != 0
        var tagNumber = byte0 and 0x1f

        // Long-form tag (tag number >= 31)
        if (tagNumber == 0x1f) {
            tagNumber = 0
            do {
                val b = readByte().toInt() and 0xff
                tagNumber = (tagNumber shl 7) or (b and 0x7f)
            } while (b and 0x80 != 0)
        }

        // Length
        val length = readLength()

        if (pos + length > buf.size) {
            throw IllegalArgumentException(
                "Truncated DER: need $length bytes at offset $pos, but only ${buf.size - pos} remain"
            )
        }

        val rawValue = buf.copyOfRange(pos, pos + length)
        pos += length

        // Parse children for constructed types
        val children = if (constructed && rawValue.isNotEmpty()) {
            val childParser = DERParser(rawValue)
            buildList<ASN1Node> {
                while (!childParser.exhausted) add(childParser.readNode())
            }
        } else {
            null
        }

        return ASN1Node(tagClass, constructed, tagNumber, rawValue, offset, children)
    }

    private fun readByte(): Byte {
        if (pos >= buf.size) throw IllegalArgumentException("Unexpected end of DER data")
        return buf[pos++]
    }

    private fun readLength(): Int {
        val first = readByte().toInt() and 0xff
        if (first < 0x80) return first

        val numBytes = first and 0x7f
        if (numBytes == 0) throw IllegalArgumentException("Indefinite length is not supported in DER")
        if (numBytes > 4) throw IllegalArgumentException("Length too large: $numBytes bytes")

        var len = 0
        repeat(numBytes) { len = (len shl 8) or (readByte().toInt() and 0xff) }
        return len
    }
}

// --- OID decoding -----------------------------------------------------------

private fun decodeOID(bytes: ByteArray): String {
    if (bytes.isEmpty()) throw IllegalArgumentException("Empty OID")
    val parts = mutableListOf<Int>()
    parts.add((bytes[0].toInt() and 0xff) / 40)
    parts.add((bytes[0].toInt() and 0xff) % 40)

    var value = 0
    for (i in 1 until bytes.size) {
        val b = bytes[i].toInt() and 0xff
        value = (value shl 7) or (b and 0x7f)
        if (b and 0x80 == 0) {
            parts.add(value)
            value = 0
        }
    }
    return parts.joinToString(".")
}

private fun oidName(oid: String): String = OID_NAMES[oid] ?: oid

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

/** One subject/issuer attribute: short type name like "CN" plus its value. */
data class RDNAttribute(val type: String, val value: String)

private fun parseRDN(node: ASN1Node): List<RDNAttribute> {
    val attrs = mutableListOf<RDNAttribute>()
    val children = node.children ?: return attrs

    for (rdnSet in children) {
        for (attrSeq in rdnSet.children ?: continue) {
            val seq = attrSeq.children ?: continue
            if (seq.size < 2) continue
            val oid = decodeOID(seq[0].rawValue)
            attrs.add(RDNAttribute(oidName(oid), readString(seq[1])))
        }
    }
    return attrs
}

private fun formatDN(attrs: List<RDNAttribute>): String =
    attrs.joinToString(", ") { "${it.type}=${it.value}" }

private fun readString(node: ASN1Node): String {
    // Try to decode as text string
    val tag = node.tagNumber
    if (tag == 12 || tag == 19 || tag == 22 || tag == 30 || tag == 36) {
        // UTF8String(12), PrintableString(19), IA5String(22), UTF8String variant,
        // BMPString(30), etc. — all decode as text
        return String(node.rawValue, Charsets.UTF_8)
    }
    // Fallback: strict UTF-8, then hex as a last resort
    return decodeStrictUtf8(node.rawValue)
        ?: node.rawValue.joinToString(":") { "%02x".format(it) }
}

/** Strict UTF-8 decode: null instead of throwing on malformed input. */
private fun decodeStrictUtf8(bytes: ByteArray): String? = try {
    val decoder = Charsets.UTF_8.newDecoder()
        .onMalformedInput(CodingErrorAction.REPORT)
        .onUnmappableCharacter(CodingErrorAction.REPORT)
    decoder.decode(java.nio.ByteBuffer.wrap(bytes)).toString()
} catch (e: Exception) {
    null
}

// --- Time helpers --------------------------------------------------------------

private val UTC_TIME_RE = Regex("^(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})Z$")
private val GENERALIZED_TIME_RE = Regex("^(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})Z$")

private fun parseTime(node: ASN1Node): Instant {
    val str = readString(node)
    // UTCTime (tag 23): YYMMDDHHMMSSZ
    if (node.tagNumber == 23) {
        val m = UTC_TIME_RE.find(str) ?: throw IllegalArgumentException("Invalid UTCTime: $str")
        val (y, mo, d, h, mi, s) = m.destructured
        var year = y.toInt()
        year += if (year >= 50) 1900 else 2000
        return Instant.parse("%04d-%02d-%02dT%02d:%02d:%02dZ".format(year, mo.toInt(), d.toInt(), h.toInt(), mi.toInt(), s.toInt()))
    }
    // GeneralizedTime (tag 24): YYYYMMDDHHMMSSZ
    if (node.tagNumber == 24) {
        val m = GENERALIZED_TIME_RE.find(str) ?: throw IllegalArgumentException("Invalid GeneralizedTime: $str")
        val (y, mo, d, h, mi, s) = m.destructured
        return Instant.parse("%s-%s-%sT%s:%s:%sZ".format(y, mo, d, h, mi, s))
    }
    throw IllegalArgumentException("Unknown time tag: ${node.tagNumber}")
}

// --- Extension parsing ----------------------------------------------------------

private data class Extensions(
    var basicConstraintsCA: Boolean? = null,
    var keyUsage: List<String>? = null,
    var extKeyUsage: List<String>? = null,
    var sanDNS: MutableList<String> = mutableListOf(),
    var sanIP: MutableList<String> = mutableListOf(),
    var sanEmail: MutableList<String> = mutableListOf(),
)

private fun parseExtensions(nodes: List<ASN1Node>): Extensions {
    val ext = Extensions()

    for (extNode in nodes) {
        val children = extNode.children ?: continue
        if (children.size < 2) continue
        val oid = decodeOID(children[0].rawValue)

        // Skip critical boolean if present
        var valueIdx = 1
        if (children.size >= 3 && children[1].tagNumber == 1 && children[1].tagClass == TagClass.UNIVERSAL) {
            valueIdx = 2
        }

        val valueNode = children[valueIdx]
        // The value is wrapped in an OCTET STRING containing the actual DER
        val octetContent = valueNode.rawValue
        if (octetContent.isEmpty()) continue

        val inner = DERParser(octetContent)
        if (inner.exhausted) continue
        val content = inner.readNode()

        when (oid) {
            "2.5.29.19" -> {
                // Basic Constraints
                ext.basicConstraintsCA = false
                val first = content.children?.firstOrNull()
                if (first != null && first.tagNumber == 1 && first.rawValue.size == 1 &&
                    (first.rawValue[0].toInt() and 0xff) == 0xff
                ) {
                    ext.basicConstraintsCA = true
                }
            }
            "2.5.29.15" -> {
                // Key Usage — BIT STRING
                ext.keyUsage = parseBitString(content)
            }
            "2.5.29.37" -> {
                // Extended Key Usage
                ext.extKeyUsage = content.children?.map { oidName(decodeOID(it.rawValue)) } ?: emptyList()
            }
            "2.5.29.17" -> {
                // Subject Alternative Name
                for (child in content.children ?: emptyList()) {
                    if (child.tagClass == TagClass.CONTEXT) {
                        when (child.tagNumber) {
                            2 -> ext.sanDNS.add(readString(child)) // DNS
                            7 -> { // IP
                                val ipBytes = child.rawValue
                                if (ipBytes.size == 4) {
                                    ext.sanIP.add(ipBytes.joinToString(".") { (it.toInt() and 0xff).toString() })
                                } else if (ipBytes.size == 16) {
                                    // IPv6 — produce standard hex representation
                                    val parts = (0 until 16 step 2).map {
                                        (((ipBytes[it].toInt() and 0xff) shl 8) or
                                            (ipBytes[it + 1].toInt() and 0xff)).toString(16)
                                    }
                                    ext.sanIP.add(parts.joinToString(":"))
                                }
                            }
                            1 -> ext.sanEmail.add(readString(child)) // Email
                        }
                    }
                }
            }
        }
    }

    return ext
}

private fun parseBitString(node: ASN1Node): List<String> {
    if (node.rawValue.size < 2) return emptyList()
    val unusedBits = node.rawValue[0].toInt() and 0xff
    val octets = node.rawValue.copyOfRange(1, node.rawValue.size)
    val flags = mutableListOf<String>()

    for (i in KEY_USAGE_BITS.indices) {
        val octetIdx = i / 8
        val bitIdx = 7 - (i % 8)
        if (octetIdx < octets.size) {
            if ((octets[octetIdx].toInt() and 0xff) and (1 shl bitIdx) != 0) {
                flags.add(KEY_USAGE_BITS[i])
            }
        }
    }

    // Mask out unused bits
    if (unusedBits > 0 && flags.isNotEmpty()) {
        val totalBits = octets.size * 8 - unusedBits
        while (flags.size > totalBits) {
            flags.removeAt(flags.size - 1)
        }
    }

    return flags
}

// --- Public types ---------------------------------------------------------------

data class CertificateInfo(
    /** Parsed subject RDN attributes */
    val subject: List<RDNAttribute>,
    /** Formatted subject DN string */
    val subjectDN: String,
    /** Parsed issuer RDN attributes */
    val issuer: List<RDNAttribute>,
    /** Formatted issuer DN string */
    val issuerDN: String,
    /** Not-before date */
    val notBefore: Instant,
    /** Not-after date */
    val notAfter: Instant,
    /** Serial number as hex string */
    val serialNumber: String,
    /** Signature algorithm (human-readable name) */
    val signatureAlgorithm: String,
    /** Public key algorithm name */
    val keyAlgorithm: String,
    /** Public key size in bits */
    val keySize: Int,
    /** Basic Constraints CA flag */
    val basicConstraintsCA: Boolean?,
    /** Key Usage flags */
    val keyUsage: List<String>,
    /** Extended Key Usage purposes */
    val extKeyUsage: List<String>,
    /** Subject Alternative Names — DNS entries */
    val sanDNS: List<String>,
    /** Subject Alternative Names — IP entries */
    val sanIP: List<String>,
    /** Subject Alternative Names — email entries */
    val sanEmail: List<String>,
    /** Version number (0=v1, 1=v2, 2=v3) */
    val version: Int,
    /** Raw DER bytes (for fingerprinting) */
    val rawDER: ByteArray,
)

// --- PEM handling -----------------------------------------------------------------

private val PEM_REGEX =
    Regex("-----BEGIN\\s+CERTIFICATE-----\\s*\\r?\\n([\\s\\S]*?)\\r?\\n-----END\\s+CERTIFICATE-----")

private fun pemBlocks(pem: String): List<String> = PEM_REGEX.findAll(pem).map { it.groupValues[1] }.toList()

private fun pemToDER(pem: String): ByteArray {
    // Strip whitespace and decode base64
    val b64 = pem.replace(Regex("\\s"), "")
    return Base64.getDecoder().decode(b64)
}

// --- Main decoder -------------------------------------------------------------------

fun decodeCertificate(pem: String): CertificateInfo {
    if (pem.isBlank()) {
        throw IllegalArgumentException("Empty input — paste a PEM certificate")
    }

    // Extract PEM blocks
    val blocks = pemBlocks(pem)
    if (blocks.isEmpty()) {
        throw IllegalArgumentException("No PEM certificate block found — expected -----BEGIN CERTIFICATE-----")
    }

    // Decode the first certificate
    val der = pemToDER(blocks[0])

    // Parse outer SEQUENCE
    val certSeq = DERParser(der).readNode()
    val seq = certSeq.children ?: throw IllegalArgumentException(
        "Invalid certificate structure: expected TBSCertificate, signatureAlgorithm, signatureValue"
    )
    if (seq.size < 3) {
        throw IllegalArgumentException(
            "Invalid certificate structure: expected TBSCertificate, signatureAlgorithm, signatureValue"
        )
    }

    val tbs = seq[0]
    val sigAlgOID = seq[1].children?.let { decodeOID(it[0].rawValue) } ?: ""

    // Parse TBSCertificate
    val tbsChildren = tbs.children ?: throw IllegalArgumentException("Invalid TBSCertificate structure")
    if (tbsChildren.size < 7) {
        throw IllegalArgumentException("Invalid TBSCertificate structure")
    }

    var idx = 0

    // Version (explicit context [0])
    var version = 0 // default v1
    if (tbsChildren[idx].tagClass == TagClass.CONTEXT && tbsChildren[idx].tagNumber == 0) {
        val versionNode = tbsChildren[idx].children?.firstOrNull()
        if (versionNode != null && versionNode.rawValue.size == 1) {
            version = versionNode.rawValue[0].toInt() and 0xff
        }
        idx++
    }

    // Serial Number
    val serialNode = tbsChildren[idx++]

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

    // Issuer
    val issuer = parseRDN(tbsChildren[idx++])

    // Validity
    val validityChildren = tbsChildren[idx].children
    var notBefore = Instant.EPOCH
    var notAfter = Instant.EPOCH
    if (validityChildren != null && validityChildren.size >= 2) {
        notBefore = parseTime(validityChildren[0])
        notAfter = parseTime(validityChildren[1])
    }
    idx++

    // Subject
    val subject = parseRDN(tbsChildren[idx++])

    // SubjectPublicKeyInfo
    val spkiNode = tbsChildren[idx++]
    var keyAlgorithm = "unknown"
    var keySize = 0

    val spkiChildren = spkiNode.children
    if (spkiChildren != null && spkiChildren.size >= 2) {
        val algSeq = spkiChildren[0]
        val keyBits = spkiChildren[1].rawValue
        val algChildren = algSeq.children

        if (algChildren != null && algChildren.isNotEmpty()) {
            val algOID = decodeOID(algChildren[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.size > 1) {
                    val rsaInner = DERParser(keyBits.copyOfRange(1, keyBits.size)) // skip unused-bits byte
                    if (!rsaInner.exhausted) {
                        val rsaSeq = rsaInner.readNode()
                        // The modulus INTEGER is the first child
                        val modBytes = rsaSeq.children?.firstOrNull()?.rawValue
                        if (modBytes != null) {
                            // First byte may be 0x00 padding for positive sign
                            val effectiveLen = (if ((modBytes[0].toInt() and 0xff) == 0x00) modBytes.size - 1 else modBytes.size) * 8
                            keySize = effectiveLen
                        }
                    }
                }
            } else if (algOID == "1.2.840.10045.2.1") {
                // ECDSA: look at the curve OID parameter
                if (algChildren.size >= 2) {
                    val curveOID = decodeOID(algChildren[1].rawValue)
                    val curveName = EC_CURVE_NAMES[curveOID]
                    if (curveName != null) 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.size > 1) {
                    val pointBytes = keyBits.copyOfRange(1, keyBits.size) // skip unused-bits byte
                    keySize = (pointBytes.size - 1) * 8 // subtract 0x04 prefix, then byte count -> bits
                }
            } else {
                keySize = if (keyBits.size > 1) (keyBits.size - 1) * 8 else 0
            }
        }
    }

    // Extensions — look for context [3] after subjectPublicKeyInfo
    var extensions = Extensions()
    while (idx < tbsChildren.size) {
        val child = tbsChildren[idx]
        if (child.tagClass == TagClass.CONTEXT && child.tagNumber == 3 && child.children != null) {
            // context [3] wraps a single outer SEQUENCE of Extension entries
            val outerSeq = child.children.firstOrNull()
            if (outerSeq?.children != null) {
                extensions = parseExtensions(outerSeq.children)
            }
        }
        idx++
    }

    // Serial number as hex
    var serialHex = ""
    if (serialNode.rawValue.isNotEmpty()) {
        // Remove leading zero if it's just padding
        var serialBytes = serialNode.rawValue
        if ((serialBytes[0].toInt() and 0xff) == 0x00 && serialBytes.size > 1) {
            serialBytes = serialBytes.copyOfRange(1, serialBytes.size)
        }
        serialHex = serialBytes.joinToString(":") { "%02X".format(it) }
    }

    return CertificateInfo(
        subject = subject,
        subjectDN = formatDN(subject),
        issuer = issuer,
        issuerDN = formatDN(issuer),
        notBefore = notBefore,
        notAfter = notAfter,
        serialNumber = serialHex,
        signatureAlgorithm = oidName(sigAlgOID),
        keyAlgorithm = keyAlgorithm,
        keySize = keySize,
        basicConstraintsCA = extensions.basicConstraintsCA,
        keyUsage = extensions.keyUsage ?: emptyList(),
        extKeyUsage = extensions.extKeyUsage ?: emptyList(),
        sanDNS = extensions.sanDNS,
        sanIP = extensions.sanIP,
        sanEmail = extensions.sanEmail,
        version = version,
        rawDER = der,
    )
}

fun isExpired(cert: CertificateInfo): Boolean = cert.notAfter < Instant.now()

fun daysUntilExpiry(cert: CertificateInfo): Int {
    val diffMs = Duration.between(Instant.now(), cert.notAfter).toMillis()
    return kotlin.math.ceil(diffMs / (1000.0 * 60 * 60 * 24)).toInt()
}

/** Extract all PEM certificate blocks and return info for each. */
fun decodeCertificateChain(pem: String): List<CertificateInfo> {
    if (pem.isBlank()) {
        throw IllegalArgumentException("Empty input — paste PEM certificate(s)")
    }

    val blocks = pemBlocks(pem)
    if (blocks.isEmpty()) {
        throw IllegalArgumentException("No PEM certificate block found — expected -----BEGIN CERTIFICATE-----")
    }

    return blocks.map {
        decodeCertificate("-----BEGIN CERTIFICATE-----\n$it\n-----END CERTIFICATE-----")
    }
}

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 →