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 →