Skip to content

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

// cert-decoder — pure ASN.1 DER parser + X.509 certificate decoder.
//
// Language: Swift 5.9+ (Foundation only)
// Ported from src/lib/cert-decoder.ts
// display source — part of CosmoDev's polyglot tool pages
//
// No external dependencies — DER is deterministic and parsed sequentially.

import Foundation

// MARK: - OID name map

let oidNames: [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",
]

/// EC named-curve OIDs
let ecCurveNames: [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",
]

/// Key Usage bit names
let keyUsageBits = [
    "digitalSignature",
    "nonRepudiation",
    "keyEncipherment",
    "dataEncipherment",
    "keyAgreement",
    "keyCertSign",
    "cRLSign",
    "encipherOnly",
    "decipherOnly",
]

// MARK: - Errors

enum CertDecoderError: Error, CustomStringConvertible {
    case emptyInput
    case noPemBlock
    case truncatedDER(Int, Int)
    case unexpectedEnd
    case indefiniteLength
    case lengthTooLarge(Int)
    case emptyOID
    case invalidUTCTime(String)
    case invalidGeneralizedTime(String)
    case unknownTimeTag(Int)
    case invalidStructure(String)
    case invalidBase64

    var description: String {
        switch self {
        case .emptyInput: return "Empty input — paste a PEM certificate"
        case .noPemBlock: return "No PEM certificate block found — expected -----BEGIN CERTIFICATE-----"
        case .truncatedDER(let need, let have): return "Truncated DER: need \(need) bytes but only \(have) remain"
        case .unexpectedEnd: return "Unexpected end of DER data"
        case .indefiniteLength: return "Indefinite length is not supported in DER"
        case .lengthTooLarge(let n): return "Length too large: \(n) bytes"
        case .emptyOID: return "Empty OID"
        case .invalidUTCTime(let s): return "Invalid UTCTime: \(s)"
        case .invalidGeneralizedTime(let s): return "Invalid GeneralizedTime: \(s)"
        case .unknownTimeTag(let t): return "Unknown time tag: \(t)"
        case .invalidStructure(let why): return "Invalid certificate structure: \(why)"
        case .invalidBase64: return "Invalid Base64 in PEM body"
        }
    }
}

// MARK: - ASN.1 DER types

enum TagClass: String {
    case universal
    case context
    case application
    case `private`
}

struct ASN1Node {
    let tagClass: TagClass
    let constructed: Bool
    let tagNumber: Int
    let rawValue: [UInt8] // value bytes (decoded content)
    let offset: Int // offset into original buffer
    var children: [ASN1Node]? = nil
}

// MARK: - DER parsing

final class DERParser {
    private var pos = 0
    private let buf: [UInt8]

    init(_ buf: [UInt8]) {
        self.buf = buf
    }

    var exhausted: Bool { pos >= buf.count }

    /// Read the next TLV node.
    func readNode() throws -> ASN1Node {
        let offset = pos
        let byte0 = try readByte()

        let tagClass: TagClass
        switch byte0 & 0xc0 {
        case 0x00: tagClass = .universal
        case 0x40: tagClass = .application
        case 0x80: tagClass = .context
        default: tagClass = .private
        }

        let constructed = (byte0 & 0x20) != 0
        var tagNumber = Int(byte0 & 0x1f)

        // Long-form tag (tag number >= 31)
        if tagNumber == 0x1f {
            tagNumber = 0
            var b: UInt8
            repeat {
                b = try readByte()
                tagNumber = (tagNumber << 7) | Int(b & 0x7f)
            } while b & 0x80 != 0
        }

        // Length
        let length = try readLength()

        if pos + length > buf.count {
            throw CertDecoderError.truncatedDER(length, buf.count - pos)
        }

        let rawValue = Array(buf[pos..<(pos + length)])
        pos += length

        var node = ASN1Node(tagClass: tagClass, constructed: constructed,
                            tagNumber: tagNumber, rawValue: rawValue, offset: offset)

        // Parse children for constructed types
        if constructed, !rawValue.isEmpty {
            let childParser = DERParser(rawValue)
            var children: [ASN1Node] = []
            while !childParser.exhausted {
                children.append(try childParser.readNode())
            }
            node.children = children
        }

        return node
    }

    private func readByte() throws -> UInt8 {
        if pos >= buf.count { throw CertDecoderError.unexpectedEnd }
        defer { pos += 1 }
        return buf[pos]
    }

    private func readLength() throws -> Int {
        let first = try readByte()
        if first < 0x80 { return Int(first) }

        let numBytes = Int(first & 0x7f)
        if numBytes == 0 { throw CertDecoderError.indefiniteLength }
        if numBytes > 4 { throw CertDecoderError.lengthTooLarge(numBytes) }

        var len = 0
        for _ in 0..<numBytes {
            len = (len << 8) | Int(try readByte())
        }
        return len
    }
}

// MARK: - OID decoding

func decodeOID(_ bytes: [UInt8]) throws -> String {
    if bytes.isEmpty { throw CertDecoderError.emptyOID }
    var parts: [Int] = []
    parts.append(Int(bytes[0]) / 40)
    parts.append(Int(bytes[0]) % 40)

    var value = 0
    for i in 1..<bytes.count {
        let b = bytes[i]
        value = (value << 7) | Int(b & 0x7f)
        if b & 0x80 == 0 {
            parts.append(value)
            value = 0
        }
    }
    return parts.map(String.init).joined(separator: ".")
}

func oidName(_ oid: String) -> String {
    oidNames[oid] ?? oid
}

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

struct RDNAttribute {
    let type: String // short name like 'CN', 'O', etc.
    let value: String
}

func parseRDN(_ node: ASN1Node) -> [RDNAttribute] {
    var attrs: [RDNAttribute] = []
    guard let sets = node.children else { return attrs }

    for rdnSet in sets {
        guard let attrSeqs = rdnSet.children else { continue }
        for attrSeq in attrSeqs {
            guard let children = attrSeq.children, children.count >= 2 else { continue }
            guard let oid = try? decodeOID(children[0].rawValue) else { continue }
            let valueStr = readString(children[1])
            attrs.append(RDNAttribute(type: oidName(oid), value: valueStr))
        }
    }
    return attrs
}

func formatDN(_ attrs: [RDNAttribute]) -> String {
    attrs.map { "\($0.type)=\($0.value)" }.joined(separator: ", ")
}

func readString(_ node: ASN1Node) -> String {
    // Try to decode as text string
    let 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(decoding: node.rawValue, as: UTF8.self)
    }
    // Fallback: try UTF-8 (Data's initializer validates)
    if let text = String(bytes: node.rawValue, encoding: .utf8) {
        return text
    }
    // Last resort: hex
    return node.rawValue.map { String(format: "%02x", $0) }.joined(separator: ":")
}

// MARK: - Time helpers

func parseTime(_ node: ASN1Node) throws -> Date {
    let str = readString(node)
    var comps = DateComponents()
    comps.timeZone = TimeZone(identifier: "UTC")
    // UTCTime (tag 23): YYMMDDHHMMSSZ
    if node.tagNumber == 23 {
        let digits = str.dropLast() // trailing Z
        guard digits.count == 12, digits.allSatisfy({ $0.isASCII && $0.isNumber }) else {
            throw CertDecoderError.invalidUTCTime(str)
        }
        var year = Int(digits.prefix(2))!
        year += year >= 50 ? 1900 : 2000
        comps.year = year
        comps.month = Int(digits.dropFirst(2).prefix(2))!
        comps.day = Int(digits.dropFirst(4).prefix(2))!
        comps.hour = Int(digits.dropFirst(6).prefix(2))!
        comps.minute = Int(digits.dropFirst(8).prefix(2))!
        comps.second = Int(digits.dropFirst(10).prefix(2))!
    } else if node.tagNumber == 24 {
        // GeneralizedTime (tag 24): YYYYMMDDHHMMSSZ
        let digits = str.dropLast() // trailing Z
        guard digits.count == 14, digits.allSatisfy({ $0.isASCII && $0.isNumber }) else {
            throw CertDecoderError.invalidGeneralizedTime(str)
        }
        comps.year = Int(digits.prefix(4))!
        comps.month = Int(digits.dropFirst(4).prefix(2))!
        comps.day = Int(digits.dropFirst(6).prefix(2))!
        comps.hour = Int(digits.dropFirst(8).prefix(2))!
        comps.minute = Int(digits.dropFirst(10).prefix(2))!
        comps.second = Int(digits.dropFirst(12).prefix(2))!
    } else {
        throw CertDecoderError.unknownTimeTag(node.tagNumber)
    }
    var cal = Calendar(identifier: .gregorian)
    cal.timeZone = TimeZone(identifier: "UTC")!
    return cal.date(from: comps) ?? Date(timeIntervalSince1970: 0)
}

// MARK: - Extension parsing

struct Extensions {
    var basicConstraintsCA: Bool? = nil
    var keyUsage: [String]? = nil
    var extKeyUsage: [String]? = nil
    var sanDNS: [String] = []
    var sanIP: [String] = []
    var sanEmail: [String] = []
}

func parseExtensions(_ nodes: [ASN1Node]) throws -> Extensions {
    var ext = Extensions()

    for extNode in nodes {
        guard let children = extNode.children, children.count >= 2 else { continue }
        guard let oid = try? decodeOID(children[0].rawValue) else { continue }

        // Skip critical boolean if present
        var valueIdx = 1
        if children.count >= 3, children[1].tagNumber == 1, children[1].tagClass == .universal {
            valueIdx = 2
        }

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

        let inner = DERParser(octetContent)
        if inner.exhausted { continue }
        let content = try inner.readNode()

        if oid == "2.5.29.19" {
            // Basic Constraints
            ext.basicConstraintsCA = false
            if let innerChildren = content.children, innerChildren.count > 0 {
                let boolVal = innerChildren[0]
                if boolVal.tagNumber == 1, boolVal.rawValue.count == 1, boolVal.rawValue[0] == 0xff {
                    ext.basicConstraintsCA = true
                }
            }
        } else if oid == "2.5.29.15" {
            // Key Usage — BIT STRING
            ext.keyUsage = parseBitString(content)
        } else if oid == "2.5.29.37" {
            // Extended Key Usage
            ext.extKeyUsage = []
            if let innerChildren = content.children {
                for child in innerChildren {
                    if let purpose = try? decodeOID(child.rawValue) {
                        ext.extKeyUsage!.append(oidName(purpose))
                    }
                }
            }
        } else if oid == "2.5.29.17" {
            // Subject Alternative Name
            if let innerChildren = content.children {
                for child in innerChildren {
                    guard child.tagClass == .context else { continue }
                    let tag = child.tagNumber
                    if tag == 2 {
                        // DNS
                        ext.sanDNS.append(readString(child))
                    } else if tag == 7 {
                        // IP
                        let ipBytes = child.rawValue
                        if ipBytes.count == 4 {
                            ext.sanIP.append(ipBytes.map(String.init).joined(separator: "."))
                        } else if ipBytes.count == 16 {
                            // IPv6 — produce standard hex representation
                            var parts: [String] = []
                            for i in stride(from: 0, to: 16, by: 2) {
                                parts.append(String((Int(ipBytes[i]) << 8) | Int(ipBytes[i + 1]), radix: 16))
                            }
                            ext.sanIP.append(parts.joined(separator: ":"))
                        }
                    } else if tag == 1 {
                        // Email
                        ext.sanEmail.append(readString(child))
                    }
                }
            }
        }
    }

    return ext
}

func parseBitString(_ node: ASN1Node) -> [String] {
    if node.rawValue.count < 2 { return [] }
    let unusedBits = Int(node.rawValue[0])
    let octets = Array(node.rawValue[1...])
    var flags: [String] = []

    for (i, name) in keyUsageBits.enumerated() {
        let octetIdx = i / 8
        let bitIdx = 7 - (i % 8)
        if octetIdx < octets.count, octets[octetIdx] & (1 << bitIdx) != 0 {
            flags.append(name)
        }
    }

    // Mask out unused bits
    if unusedBits > 0, !flags.isEmpty {
        let totalBits = octets.count * 8 - unusedBits
        while flags.count > totalBits {
            flags.removeLast()
        }
    }

    return flags
}

// MARK: - Public types

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

// MARK: - PEM handling

let pemRegex = try! NSRegularExpression(pattern: "-----BEGIN\\s+CERTIFICATE-----\\s*\\r?\\n([\\s\\S]*?)\\r?\\n-----END\\s+CERTIFICATE-----")

func pemBlocks(_ pem: String) -> [String] {
    var blocks: [String] = []
    let full = NSRange(pem.startIndex..., in: pem)
    pemRegex.enumerateMatches(in: pem, range: full) { m, _, _ in
        guard let m = m, let r1 = Range(m.range(at: 1), in: pem) else { return }
        blocks.append(String(pem[r1]))
    }
    return blocks
}

func pemToDER(_ pem: String) throws -> [UInt8] {
    // Strip whitespace and decode base64
    let b64 = String(pem.filter { !$0.isWhitespace })
    guard let data = Data(base64Encoded: b64) else { throw CertDecoderError.invalidBase64 }
    return [UInt8](data)
}

// MARK: - Main decoder

func decodeCertificate(_ pem: String) throws -> CertificateInfo {
    guard !pem.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
        throw CertDecoderError.emptyInput
    }

    // Extract PEM blocks
    let blocks = pemBlocks(pem)
    guard let firstBlock = blocks.first else { throw CertDecoderError.noPemBlock }

    // Decode the first certificate
    let der = try pemToDER(firstBlock)

    // Parse outer SEQUENCE
    let parser = DERParser(der)
    let certSeq = try parser.readNode()
    guard let certChildren = certSeq.children, certChildren.count >= 3 else {
        throw CertDecoderError.invalidStructure("expected TBSCertificate, signatureAlgorithm, signatureValue")
    }

    let tbs = certChildren[0]
    let sigAlgNode = certChildren[1]
    let sigAlgOID = sigAlgNode.children.flatMap { try? decodeOID($0[0].rawValue) } ?? ""

    // Parse TBSCertificate
    guard let tbsChildren = tbs.children, tbsChildren.count >= 7 else {
        throw CertDecoderError.invalidStructure("invalid TBSCertificate")
    }

    var idx = 0

    // Version (explicit context [0])
    var version = 0 // default v1
    if tbsChildren[idx].tagClass == .context, tbsChildren[idx].tagNumber == 0 {
        if let versionNode = tbsChildren[idx].children?.first, versionNode.rawValue.count == 1 {
            version = Int(versionNode.rawValue[0])
        }
        idx += 1
    }

    // Serial Number
    let serialNode = tbsChildren[idx]
    idx += 1

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

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

    // Validity
    let validityNode = tbsChildren[idx]
    idx += 1
    var notBefore = Date(timeIntervalSince1970: 0)
    var notAfter = Date(timeIntervalSince1970: 0)
    if let validityChildren = validityNode.children, validityChildren.count >= 2 {
        notBefore = try parseTime(validityChildren[0])
        notAfter = try parseTime(validityChildren[1])
    }

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

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

    if let spkiChildren = spkiNode.children, spkiChildren.count >= 2 {
        let algSeq = spkiChildren[0]
        let keyBits = spkiChildren[1].rawValue

        if let algChildren = algSeq.children, !algChildren.isEmpty {
            guard let algOID = try? decodeOID(algChildren[0].rawValue) else {
                throw CertDecoderError.emptyOID
            }
            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.count > 1 {
                    let rsaInner = DERParser(Array(keyBits[1...])) // skip unused-bits byte
                    if !rsaInner.exhausted {
                        let rsaSeq = try rsaInner.readNode()
                        // The modulus INTEGER is the first child
                        if let rsaChildren = rsaSeq.children, !rsaChildren.isEmpty {
                            let modBytes = rsaChildren[0].rawValue
                            // First byte may be 0x00 padding for positive sign
                            let effectiveLen = (modBytes[0] == 0x00 ? modBytes.count - 1 : modBytes.count) * 8
                            keySize = effectiveLen
                        }
                    }
                }
            } else if algOID == "1.2.840.10045.2.1" {
                // ECDSA: look at the curve OID parameter
                if algChildren.count >= 2, let curveOID = try? decodeOID(algChildren[1].rawValue),
                   let curveName = ecCurveNames[curveOID] {
                    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.count > 1 {
                    let pointBytes = Array(keyBits[1...]) // skip unused-bits byte
                    keySize = (pointBytes.count - 1) * 8 // subtract 0x04 prefix, then byte count -> bits
                }
            } else {
                keySize = keyBits.count > 1 ? (keyBits.count - 1) * 8 : 0
            }
        }
    }

    // Extensions — look for context [3] after subjectPublicKeyInfo
    var extensions = Extensions()
    while idx < tbsChildren.count {
        let child = tbsChildren[idx]
        if child.tagClass == .context, child.tagNumber == 3, let outerSeq = child.children?.first {
            // context [3] wraps a single outer SEQUENCE of Extension entries
            if let extNodes = outerSeq.children {
                extensions = try parseExtensions(extNodes)
            }
        }
        idx += 1
    }

    // Serial number as hex
    var serialHex = ""
    if !serialNode.rawValue.isEmpty {
        // Remove leading zero if it's just padding
        var serialBytes = serialNode.rawValue
        if serialBytes[0] == 0x00, serialBytes.count > 1 {
            serialBytes = Array(serialBytes[1...])
        }
        serialHex = serialBytes.map { String(format: "%02X", $0) }.joined(separator: ":")
    }

    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 ?? [],
        extKeyUsage: extensions.extKeyUsage ?? [],
        sanDNS: extensions.sanDNS,
        sanIP: extensions.sanIP,
        sanEmail: extensions.sanEmail,
        version: version,
        rawDER: der
    )
}

func isExpired(_ cert: CertificateInfo) -> Bool {
    cert.notAfter < Date()
}

func daysUntilExpiry(_ cert: CertificateInfo) -> Int {
    let diffSeconds = cert.notAfter.timeIntervalSinceNow
    return Int((diffSeconds / 86_400).rounded(.up))
}

/// Extract all PEM certificate blocks and return info for each.
func decodeCertificateChain(_ pem: String) throws -> [CertificateInfo] {
    guard !pem.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
        throw CertDecoderError.emptyInput
    }

    let blocks = pemBlocks(pem)
    guard !blocks.isEmpty else { throw CertDecoderError.noPemBlock }

    return try blocks.map {
        try decodeCertificate("-----BEGIN CERTIFICATE-----\n\($0)\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 →