Skip to content

Certificate Decoder — Zig 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 Zig 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: Zig 0.14 (standard library only)
//! Ported from: src/lib/cert-decoder.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! No external dependencies — DER is deterministic and parsed sequentially.
//! Dates are Unix epoch seconds (`i64`); the TS reference uses `Date`, which
//! carries the same instant. All string fields are allocated from the
//! allocator passed in.

const std = @import("std");

// ── OID name map ───────────────────────────────────────────────────────────

const OID_NAMES = std.StaticStringMap([]const u8).initComptime(.{
    .{ "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.
const EC_CURVE_NAMES = std.StaticStringMap([]const u8).initComptime(.{
    .{ "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, MSB-first inside each octet.
const KEY_USAGE_BITS = [_][]const u8{
    "digitalSignature",
    "nonRepudiation",
    "keyEncipherment",
    "dataEncipherment",
    "keyAgreement",
    "keyCertSign",
    "cRLSign",
    "encipherOnly",
    "decipherOnly",
};

pub const Error = error{
    EmptyInput,
    NoPemBlock,
    InvalidBase64,
    TruncatedDer,
    UnexpectedEnd,
    IndefiniteLength,
    LengthTooLarge,
    InvalidCertificate,
    InvalidTbs,
    InvalidUTCTime,
    InvalidGeneralizedTime,
    UnknownTimeTag,
    OutOfMemory,
};

// ── ASN.1 DER types ────────────────────────────────────────────────────────

pub const TagClass = enum { universal, application, context, private };

pub const ASN1Node = struct {
    tag_class: TagClass,
    constructed: bool,
    tag_number: u32,
    raw_value: []const u8, // value bytes (decoded content), aliasing the input
    offset: usize, // offset into the original buffer
    children: ?[]ASN1Node = null,
};

// ── DER parsing ────────────────────────────────────────────────────────────

pub const DERParser = struct {
    buf: []const u8,
    pos: usize = 0,
    allocator: std.mem.Allocator,

    pub fn exhausted(self: *const DERParser) bool {
        return self.pos >= self.buf.len;
    }

    /// Read the next TLV node (recursing into constructed content).
    pub fn readNode(self: *DERParser) Error!ASN1Node {
        const offset = self.pos;
        const byte0 = try self.readByte();

        const tag_class: TagClass = switch (byte0 & 0xc0) {
            0x00 => .universal,
            0x40 => .application,
            0x80 => .context,
            else => .private,
        };
        const constructed = (byte0 & 0x20) != 0;
        var tag_number: u32 = byte0 & 0x1f;

        // Long-form tag (tag number >= 31)
        if (tag_number == 0x1f) {
            tag_number = 0;
            while (true) {
                const b = try self.readByte();
                tag_number = (tag_number << 7) | (b & 0x7f);
                if (b & 0x80 == 0) break;
            }
        }

        const length = try self.readLength();
        if (self.pos + length > self.buf.len) {
            return Error.TruncatedDer;
        }
        const raw_value = self.buf[self.pos .. self.pos + length];
        self.pos += length;

        var node = ASN1Node{
            .tag_class = tag_class,
            .constructed = constructed,
            .tag_number = tag_number,
            .raw_value = raw_value,
            .offset = offset,
        };

        // Parse children for constructed types
        if (constructed and raw_value.len > 0) {
            var child_parser = DERParser{ .buf = raw_value, .allocator = self.allocator };
            var children = std.ArrayList(ASN1Node).init(self.allocator);
            errdefer children.deinit();
            while (!child_parser.exhausted()) {
                try children.append(try child_parser.readNode());
            }
            node.children = try children.toOwnedSlice();
        }
        return node;
    }

    fn readByte(self: *DERParser) Error!u8 {
        if (self.pos >= self.buf.len) return Error.UnexpectedEnd;
        const b = self.buf[self.pos];
        self.pos += 1;
        return b;
    }

    fn readLength(self: *DERParser) Error!usize {
        const first = try self.readByte();
        if (first < 0x80) return first;
        const num_bytes = first & 0x7f;
        if (num_bytes == 0) return Error.IndefiniteLength;
        if (num_bytes > 4) return Error.LengthTooLarge;
        var len: usize = 0;
        for (0..num_bytes) |_| {
            len = (len << 8) | try self.readByte();
        }
        return len;
    }
};

// ── OID decoding ──────────────────────────────────────────────────────────

/// Decode OID content bytes into a dotted string. Caller owns the result.
pub fn decodeOID(allocator: std.mem.Allocator, bytes: []const u8) Error![]u8 {
    if (bytes.len == 0) return Error.UnexpectedEnd;
    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    const w = out.writer();
    try w.print("{d}.{d}", .{ bytes[0] / 40, bytes[0] % 40 });
    var value: u64 = 0;
    for (bytes[1..]) |b| {
        value = (value << 7) | (b & 0x7f);
        if (b & 0x80 == 0) {
            try w.print(".{d}", .{value});
            value = 0;
        }
    }
    return out.toOwnedSlice();
}

fn oidName(oid: []const u8) []const u8 {
    return OID_NAMES.get(oid) orelse oid;
}

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

pub const RDNAttribute = struct {
    type_: []const u8, // short name like "CN", "O", or the raw dotted OID
    value: []const u8,
};

fn parseRDN(allocator: std.mem.Allocator, node: *const ASN1Node) Error![]RDNAttribute {
    var attrs = std.ArrayList(RDNAttribute).init(allocator);
    errdefer attrs.deinit();
    const sets = node.children orelse return attrs.toOwnedSlice();
    for (sets) |rdn_set| {
        const seqs = rdn_set.children orelse continue;
        for (seqs) |attr_seq| {
            const kids = attr_seq.children orelse continue;
            if (kids.len < 2) continue;
            const oid = try decodeOID(allocator, kids[0].raw_value);
            try attrs.append(.{ .type_ = oidName(oid), .value = try readString(allocator, &kids[1]) });
        }
    }
    return attrs.toOwnedSlice();
}

/// "CN=example.com, O=Acme" — caller owns the result.
pub fn formatDN(allocator: std.mem.Allocator, attrs: []const RDNAttribute) Error![]u8 {
    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    for (attrs, 0..) |a, i| {
        if (i > 0) try out.appendSlice(", ");
        try out.writer().print("{s}={s}", .{ a.type_, a.value });
    }
    return out.toOwnedSlice();
}

/// Decode a node's content as text (UTF8String / PrintableString / IA5String /
/// BMPString tags), falling back to UTF-8, then to colon-separated hex.
fn readString(allocator: std.mem.Allocator, node: *const ASN1Node) Error![]u8 {
    const tag = node.tag_number;
    if (tag == 12 or tag == 19 or tag == 22 or tag == 30 or tag == 36) {
        return allocator.dupe(u8, node.raw_value);
    }
    // Fallback: valid UTF-8 passes through, anything else renders as hex.
    if (std.unicode.utf8ValidateSlice(node.raw_value)) {
        return allocator.dupe(u8, node.raw_value);
    }
    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    for (node.raw_value, 0..) |b, i| {
        if (i > 0) try out.append(':');
        try out.writer().print("{x:0>2}", .{b});
    }
    return out.toOwnedSlice();
}

// ── Time helpers ───────────────────────────────────────────────────────────

/// Days since 1970-01-01 for a civil (year, month, day) date.
fn daysFromCivil(y_in: i64, m: i64, d: i64) i64 {
    var y = y_in;
    if (m <= 2) y -= 1;
    const era = @divFloor(if (y >= 0) y else y - 399, 400);
    const yoe: i64 = y - era * 400; // [0, 399]
    const doy = @divTrunc(153 * (m + (if (m > 2) @as(i64, -3) else @as(i64, 9))) + 2, 5) + d - 1;
    const doe = yoe * 365 + @divTrunc(yoe, 4) - @divTrunc(yoe, 100) + doy;
    return era * 146097 + doe - 719468;
}

fn parseFixedDigits(s: []const u8, comptime n: usize) Error!u64 {
    if (s.len < n) return Error.InvalidUTCTime;
    var v: u64 = 0;
    for (s[0..n]) |c| {
        if (!std.ascii.isDigit(c)) return Error.InvalidUTCTime;
        v = v * 10 + (c - '0');
    }
    return v;
}

/// UTCTime (tag 23): YYMMDDHHMMSSZ → epoch seconds (YY >= 50 → 19xx).
fn parseUTCTime(str: []const u8) Error!i64 {
    if (str.len != 13 or str[12] != 'Z') return Error.InvalidUTCTime;
    var year = try parseFixedDigits(str, 2);
    year += if (year >= 50) 1900 else 2000;
    return parseDateParts(year, str[2..]);
}

/// GeneralizedTime (tag 24): YYYYMMDDHHMMSSZ → epoch seconds.
fn parseGeneralizedTime(str: []const u8) Error!i64 {
    if (str.len != 15 or str[14] != 'Z') return Error.InvalidGeneralizedTime;
    const year = try parseFixedDigits(str, 4);
    return parseDateParts(year, str[4..]);
}

/// The MMDDHHMMSSZ tail shared by both time formats.
fn parseDateParts(year: u64, tail: []const u8) Error!i64 {
    if (tail.len != 11 or tail[10] != 'Z') return Error.InvalidUTCTime;
    const month = try parseFixedDigits(tail[0..], 2);
    const day = try parseFixedDigits(tail[2..], 2);
    const hh = try parseFixedDigits(tail[4..], 2);
    const mm = try parseFixedDigits(tail[6..], 2);
    const ss = try parseFixedDigits(tail[8..], 2);
    if (month < 1 or month > 12 or day < 1 or day > 31) return Error.InvalidUTCTime;
    const days = daysFromCivil(@intCast(year), @intCast(month), @intCast(day));
    return days * 86400 + @as(i64, @intCast(hh)) * 3600 + @as(i64, @intCast(mm)) * 60 + @as(i64, @intCast(ss));
}

fn parseTime(allocator: std.mem.Allocator, node: *const ASN1Node) Error!i64 {
    const str = try readString(allocator, node);
    defer allocator.free(str);
    if (node.tag_number == 23) return parseUTCTime(str);
    if (node.tag_number == 24) return parseGeneralizedTime(str);
    return Error.UnknownTimeTag;
}

// ── Extension parsing ──────────────────────────────────────────────────────

pub const Extensions = struct {
    basic_constraints_ca: ?bool = null,
    key_usage: ?[][]const u8 = null,
    ext_key_usage: ?[][]const u8 = null,
    san_dns: [][]const u8 = &.{},
    san_ip: [][]const u8 = &.{},
    san_email: [][]const u8 = &.{},
};

fn parseExtensions(allocator: std.mem.Allocator, nodes: []const ASN1Node) Error!Extensions {
    var ext = Extensions{};
    var dns = std.ArrayList([]const u8).init(allocator);
    var ips = std.ArrayList([]const u8).init(allocator);
    var emails = std.ArrayList([]const u8).init(allocator);
    errdefer {
        dns.deinit();
        ips.deinit();
        emails.deinit();
    }

    for (nodes) |ext_node| {
        const kids = ext_node.children orelse continue;
        if (kids.len < 2) continue;
        const oid = try decodeOID(allocator, kids[0].raw_value);
        defer allocator.free(oid);

        // Skip the critical BOOLEAN if present
        var value_idx: usize = 1;
        if (kids.len >= 3 and kids[1].tag_number == 1 and kids[1].tag_class == .universal) {
            value_idx = 2;
        }

        // The value is wrapped in an OCTET STRING containing the actual DER
        const octet_content = kids[value_idx].raw_value;
        if (octet_content.len == 0) continue;
        var inner = DERParser{ .buf = octet_content, .allocator = allocator };
        if (inner.exhausted) continue;
        const content = try inner.readNode();

        if (std.mem.eql(u8, oid, "2.5.29.19")) {
            // Basic Constraints
            ext.basic_constraints_ca = false;
            if (content.children) |ck| {
                if (ck.len > 0 and ck[0].tag_number == 1 and
                    ck[0].raw_value.len == 1 and ck[0].raw_value[0] == 0xff)
                {
                    ext.basic_constraints_ca = true;
                }
            }
        } else if (std.mem.eql(u8, oid, "2.5.29.15")) {
            // Key Usage — BIT STRING
            ext.key_usage = try parseBitString(allocator, &content);
        } else if (std.mem.eql(u8, oid, "2.5.29.37")) {
            // Extended Key Usage
            var ekus = std.ArrayList([]const u8).init(allocator);
            if (content.children) |ck| {
                for (ck) |child| {
                    const purpose = try decodeOID(allocator, child.raw_value);
                    try ekus.append(oidName(purpose));
                }
            }
            ext.ext_key_usage = try ekus.toOwnedSlice();
        } else if (std.mem.eql(u8, oid, "2.5.29.17")) {
            // Subject Alternative Name
            if (content.children) |ck| {
                for (ck) |child| {
                    if (child.tag_class != .context) continue;
                    switch (child.tag_number) {
                        2 => try dns.append(try readString(allocator, &child)),
                        1 => try emails.append(try readString(allocator, &child)),
                        7 => {
                            const ip_bytes = child.raw_value;
                            if (ip_bytes.len == 4) {
                                try ips.append(try std.fmt.allocPrint(allocator, "{d}.{d}.{d}.{d}", .{ ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3] }));
                            } else if (ip_bytes.len == 16) {
                                // IPv6 — standard hex representation
                                var parts: [8]u16 = undefined;
                                for (0..8) |i| {
                                    parts[i] = (@as(u16, ip_bytes[i * 2]) << 8) | ip_bytes[i * 2 + 1];
                                }
                                var hex = std.ArrayList(u8).init(allocator);
                                for (parts, 0..) |p, i| {
                                    if (i > 0) try hex.append(':');
                                    try hex.writer().print("{x}", .{p});
                                }
                                try ips.append(try hex.toOwnedSlice());
                            }
                        },
                        else => {},
                    }
                }
            }
        }
    }

    ext.san_dns = try dns.toOwnedSlice();
    ext.san_ip = try ips.toOwnedSlice();
    ext.san_email = try emails.toOwnedSlice();
    return ext;
}

/// Decode a Key Usage BIT STRING into the set flag names (MSB-first per octet,
/// unused-bit count honored).
fn parseBitString(allocator: std.mem.Allocator, node: *const ASN1Node) Error![][]const u8 {
    var flags = std.ArrayList([]const u8).init(allocator);
    errdefer flags.deinit();
    if (node.raw_value.len < 2) return flags.toOwnedSlice();
    const unused_bits = node.raw_value[0];
    const octets = node.raw_value[1..];

    for (KEY_USAGE_BITS, 0..) |bit_name, i| {
        const octet_idx = i / 8;
        const bit_idx: u3 = @intCast(7 - (i % 8));
        if (octet_idx < octets.len) {
            if (octets[octet_idx] & (@as(u8, 1) << bit_idx) != 0) {
                try flags.append(bit_name);
            }
        }
    }

    // Mask out unused bits: drop flags past the meaningful bit count.
    if (unused_bits > 0 and flags.items.len > 0) {
        const total_bits = octets.len * 8 - @as(usize, unused_bits);
        while (flags.items.len > total_bits) {
            _ = flags.pop();
        }
    }
    return flags.toOwnedSlice();
}

// ── Public types ────────────────────────────────────────────────────────────

pub const CertificateInfo = struct {
    /// Parsed subject RDN attributes
    subject: []RDNAttribute,
    /// Formatted subject DN string
    subject_dn: []const u8,
    /// Parsed issuer RDN attributes
    issuer: []RDNAttribute,
    /// Formatted issuer DN string
    issuer_dn: []const u8,
    /// Not-before, Unix epoch seconds
    not_before: i64,
    /// Not-after, Unix epoch seconds
    not_after: i64,
    /// Serial number as "AA:BB:…" hex string
    serial_number: []const u8,
    /// Signature algorithm (human-readable name)
    signature_algorithm: []const u8,
    /// Public key algorithm name
    key_algorithm: []const u8,
    /// Public key size in bits
    key_size: usize,
    /// Basic Constraints CA flag
    basic_constraints_ca: ?bool,
    /// Key Usage flags
    key_usage: [][]const u8,
    /// Extended Key Usage purposes
    ext_key_usage: [][]const u8,
    /// Subject Alternative Names — DNS entries
    san_dns: [][]const u8,
    /// Subject Alternative Names — IP entries
    san_ip: [][]const u8,
    /// Subject Alternative Names — email entries
    san_email: [][]const u8,
    /// Version number (0=v1, 1=v2, 2=v3)
    version: u8,
    /// Raw DER bytes (for fingerprinting)
    raw_der: []const u8,
};

// ── PEM handling ───────────────────────────────────────────────────────────

const BEGIN_MARKER = "-----BEGIN CERTIFICATE-----";
const END_MARKER = "-----END CERTIFICATE-----";
const B64Decoder = std.base64.standard.Decoder;

/// Extract every PEM certificate block's Base64 body from `pem`.
pub fn extractPemBlocks(allocator: std.mem.Allocator, pem: []const u8) Error![][]u8 {
    var blocks = std.ArrayList([]u8).init(allocator);
    errdefer blocks.deinit();
    var cursor: usize = 0;
    while (std.mem.indexOfPos(u8, pem, cursor, BEGIN_MARKER)) |begin| {
        const body_start = begin + BEGIN_MARKER.len;
        const end = std.mem.indexOfPos(u8, pem, body_start, END_MARKER) orelse break;
        const b64 = try stripWhitespace(allocator, pem[body_start..end]);
        defer allocator.free(b64);
        const decoded_len = B64Decoder.calcSizeForSlice(b64) catch return Error.InvalidBase64;
        const der = try allocator.alloc(u8, decoded_len);
        B64Decoder.decode(der, b64) catch {
            allocator.free(der);
            return Error.InvalidBase64;
        };
        try blocks.append(der);
        cursor = end + END_MARKER.len;
    }
    return blocks.toOwnedSlice();
}

fn stripWhitespace(allocator: std.mem.Allocator, s: []const u8) Error![]u8 {
    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    for (s) |c| {
        if (c == ' ' or c == '\t' or c == '\r' or c == '\n') continue;
        try out.append(c);
    }
    return out.toOwnedSlice();
}

// ── Main decoder ───────────────────────────────────────────────────────────

/// Decode the first certificate in a PEM string. Caller owns every field.
pub fn decodeCertificate(allocator: std.mem.Allocator, pem: []const u8) Error!CertificateInfo {
    if (std.mem.trim(u8, pem, " \t\r\n").len == 0) return Error.EmptyInput;
    const blocks = try extractPemBlocks(allocator, pem);
    defer {
        for (blocks) |b| allocator.free(b);
        allocator.free(blocks);
    }
    if (blocks.len == 0) return Error.NoPemBlock;
    return decodeDER(allocator, blocks[0]);
}

fn decodeDER(allocator: std.mem.Allocator, der: []u8) Error!CertificateInfo {
    // Parse outer SEQUENCE
    var parser = DERParser{ .buf = der, .allocator = allocator };
    const cert_seq = try parser.readNode();
    const top = cert_seq.children orelse return Error.InvalidCertificate;
    if (top.len < 3) return Error.InvalidCertificate; // TBSCertificate, signatureAlgorithm, signatureValue

    const tbs = &top[0];
    const sig_alg_node = &top[1];
    const sig_alg_oid: []u8 = if (sig_alg_node.children != null and sig_alg_node.children.?.len > 0)
        try decodeOID(allocator, sig_alg_node.children.?[0].raw_value)
    else
        try allocator.dupe(u8, "");

    // Parse TBSCertificate
    const kids = tbs.children orelse return Error.InvalidTbs;
    if (kids.len < 7) return Error.InvalidTbs;

    var idx: usize = 0;

    // Version (explicit context [0]); default v1
    var version: u8 = 0;
    if (kids[idx].tag_class == .context and kids[idx].tag_number == 0) {
        if (kids[idx].children) |vk| {
            if (vk.len > 0 and vk[0].raw_value.len == 1) version = vk[0].raw_value[0];
        }
        idx += 1;
    }

    // Serial Number
    const serial_node = &kids[idx];
    idx += 1;

    idx += 1; // skip the inner signature algorithm

    // Issuer
    const issuer = try parseRDN(allocator, &kids[idx]);
    idx += 1;

    // Validity
    var not_before: i64 = 0;
    var not_after: i64 = 0;
    if (kids[idx].children) |vk| {
        if (vk.len >= 2) {
            not_before = try parseTime(allocator, &vk[0]);
            not_after = try parseTime(allocator, &vk[1]);
        }
    }
    idx += 1;

    // Subject
    const subject = try parseRDN(allocator, &kids[idx]);
    idx += 1;

    // SubjectPublicKeyInfo
    const spki_node = &kids[idx];
    idx += 1;
    var key_algorithm: []const u8 = "unknown";
    var key_size: usize = 0;

    if (spki_node.children) |spki| {
        if (spki.len >= 2) {
            const alg_seq = &spki[0];
            const key_bits = spki[1].raw_value;
            if (alg_seq.children) |alg| {
                if (alg.len >= 1) {
                    const alg_oid = try decodeOID(allocator, alg[0].raw_value);
                    key_algorithm = oidName(alg_oid);

                    if (std.mem.eql(u8, alg_oid, "1.2.840.113549.1.1.1")) {
                        // RSA: the BIT STRING value starts with an unused-bits
                        // byte (0x00), then a DER SEQUENCE of { modulus, exponent }.
                        if (key_bits.len > 1) {
                            var rsa_inner = DERParser{ .buf = key_bits[1..], .allocator = allocator };
                            if (!rsa_inner.exhausted) {
                                const rsa_seq = try rsa_inner.readNode();
                                if (rsa_seq.children) |rk| {
                                    if (rk.len > 0) {
                                        const mod_bytes = rk[0].raw_value;
                                        // First byte may be 0x00 padding for positive sign
                                        const effective = if (mod_bytes[0] == 0x00) mod_bytes.len - 1 else mod_bytes.len;
                                        key_size = effective * 8;
                                    }
                                }
                            }
                        }
                    } else if (std.mem.eql(u8, alg_oid, "1.2.840.10045.2.1")) {
                        // ECDSA: look at the curve OID parameter
                        if (alg.len >= 2) {
                            const curve_oid = try decodeOID(allocator, alg[1].raw_value);
                            if (EC_CURVE_NAMES.get(curve_oid)) |curve| {
                                key_algorithm = try std.fmt.allocPrint(allocator, "{s} ({s})", .{ key_algorithm, curve });
                            }
                        }
                        // EC public key: BIT STRING = 0x00 + uncompressed point;
                        // for P-256 the point is 65 bytes (0x04 + 32 + 32).
                        if (key_bits.len > 1) {
                            key_size = (key_bits.len - 1 - 1) * 8; // drop 0x00 + 0x04 prefix → bits
                        }
                    } else {
                        key_size = if (key_bits.len > 1) (key_bits.len - 1) * 8 else 0;
                    }
                }
            }
        }
    }

    // Extensions — look for context [3] after subjectPublicKeyInfo
    var extensions = Extensions{};
    while (idx < kids.len) : (idx += 1) {
        const child = &kids[idx];
        if (child.tag_class == .context and child.tag_number == 3 and child.children != null) {
            const outer_seq = child.children.?[0];
            if (outer_seq.children) |ext_entries| {
                extensions = try parseExtensions(allocator, ext_entries);
            }
        }
    }

    // Serial number as "AA:BB:…" uppercase hex, dropping a 0x00 pad byte.
    var serial_hex = std.ArrayList(u8).init(allocator);
    if (serial_node.raw_value.len > 0) {
        var serial_bytes = serial_node.raw_value;
        if (serial_bytes[0] == 0x00 and serial_bytes.len > 1) serial_bytes = serial_bytes[1..];
        for (serial_bytes, 0..) |b, i| {
            if (i > 0) try serial_hex.append(':');
            try serial_hex.writer().print("{X:0>2}", .{b});
        }
    }

    return .{
        .subject = subject,
        .subject_dn = try formatDN(allocator, subject),
        .issuer = issuer,
        .issuer_dn = try formatDN(allocator, issuer),
        .not_before = not_before,
        .not_after = not_after,
        .serial_number = try serial_hex.toOwnedSlice(),
        .signature_algorithm = oidName(sig_alg_oid),
        .key_algorithm = key_algorithm,
        .key_size = key_size,
        .basic_constraints_ca = extensions.basic_constraints_ca,
        .key_usage = extensions.key_usage orelse &.{},
        .ext_key_usage = extensions.ext_key_usage orelse &.{},
        .san_dns = extensions.san_dns,
        .san_ip = extensions.san_ip,
        .san_email = extensions.san_email,
        .version = version,
        .raw_der = der,
    };
}

/// True when the certificate's not-after instant is in the past.
pub fn isExpired(cert: *const CertificateInfo, now_epoch: i64) bool {
    return cert.not_after < now_epoch;
}

/// Whole days from `now_epoch` until expiry (negative once expired).
pub fn daysUntilExpiry(cert: *const CertificateInfo, now_epoch: i64) i64 {
    const diff_s = cert.not_after - now_epoch;
    return @divTrunc(diff_s + 86399, 86400); // ceil toward +∞, like Math.ceil(ms/day)
}

/// Extract all PEM certificate blocks and return info for each.
pub fn decodeCertificateChain(allocator: std.mem.Allocator, pem: []const u8) Error![]CertificateInfo {
    if (std.mem.trim(u8, pem, " \t\r\n").len == 0) return Error.EmptyInput;
    const blocks = try extractPemBlocks(allocator, pem);
    defer allocator.free(blocks);
    if (blocks.len == 0) return Error.NoPemBlock;

    var infos = std.ArrayList(CertificateInfo).init(allocator);
    errdefer infos.deinit();
    for (blocks) |der| {
        try infos.append(try decodeDER(allocator, der));
    }
    return infos.toOwnedSlice();
}

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 →