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 →