std1.0.0-beta-7 SafeC docs

Source crypto/x509.sc

View in the repository

// SafeC Standard Library — X.509 Certificate Parsing (DER / ASN.1)
#pragma once
#include <std/stddef.h>
#include <std/crypto/x509.h>

namespace std {

extern unsigned char* memcpy(unsigned char* d, const unsigned char* s, size_t n);
extern unsigned char* memset(unsigned char* p, int v, size_t n);

// ── ASN.1 tag constants ────────────────────────────────────────────────────────

#define ASN1_BOOLEAN          0x01
#define ASN1_INTEGER          0x02
#define ASN1_BIT_STRING       0x03
#define ASN1_OCTET_STRING     0x04
#define ASN1_OID              0x06
#define ASN1_UTF8STRING       0x0C
#define ASN1_PRINTABLESTRING  0x13
#define ASN1_IA5STRING        0x16
#define ASN1_UTCTIME          0x17
#define ASN1_GENERALIZEDTIME  0x18
#define ASN1_BMPSTRING        0x1E
#define ASN1_SEQUENCE         0x30
#define ASN1_SET              0x31
#define ASN1_CONTEXT_0        0xA0
#define ASN1_CONTEXT_1        0xA1
#define ASN1_CONTEXT_2        0xA2
#define ASN1_CONTEXT_3        0xA3

// OID bytes for id-ce-subjectAltName (2.5.29.17)
static unsigned char oid_san_[3] = { 0x55, 0x1d, 0x11 };

// OID bytes for id-ce-basicConstraints (2.5.29.19)
static unsigned char oid_bc_[3]  = { 0x55, 0x1d, 0x13 };

// OID bytes for id-ce-keyUsage (2.5.29.15)
static unsigned char oid_ku_[3]  = { 0x55, 0x1d, 0x0f };

// ── Low-level string helpers ───────────────────────────────────────────────────

static int str_len_(const char* s) {
    int n = 0;
    unsafe { while (s[n] != (char)0) { n = n + 1; } }
    return n;
}

static int str_eq_(const char* a, const char* b) {
    int i = 0;
    unsafe {
        while (a[i] != (char)0 && b[i] != (char)0) {
            if (a[i] != b[i]) { return 0; }
            i = i + 1;
        }
        return (a[i] == (char)0 && b[i] == (char)0) ? 1 : 0;
    }
}

// Copy at most `max-1` bytes from src (not null-terminated) into dst, add null.
static void copy_str_(char* dst, const unsigned char* src, unsigned long srclen,
                      unsigned long max) {
    unsigned long n = srclen;
    if (n >= max) { n = max - (unsigned long)1; }
    unsafe {
        unsigned long i = (unsigned long)0;
        while (i < n) { dst[i] = (char)src[i]; i = i + (unsigned long)1; }
        dst[n] = (char)0;
    }
}

// ── ASN.1 TLV decoder ─────────────────────────────────────────────────────────

// Reads the tag-length header at buf[*offset].
// Sets *content_start to the first byte of content and *content_len to the
// number of content bytes.  Returns 0 on success, -1 on truncation.
// tag_out receives the raw tag byte.
static int asn1_tag_len_(const unsigned char* buf, unsigned long buf_len,
                          unsigned long* offset,
                          unsigned char* tag_out,
                          unsigned long* content_start,
                          unsigned long* content_len) {
    unsafe {
        if (*offset >= buf_len) { return -1; }
        *tag_out = buf[*offset];
        *offset  = *offset + (unsigned long)1;

        if (*offset >= buf_len) { return -1; }
        unsigned char lb = buf[*offset];
        *offset = *offset + (unsigned long)1;

        if ((lb & (unsigned char)0x80) == (unsigned char)0) {
            // Short form: low 7 bits = length
            *content_len   = (unsigned long)lb;
        } else {
            // Long form: low 7 bits = number of subsequent length bytes
            unsigned long num_bytes = (unsigned long)(lb & (unsigned char)0x7F);
            if (num_bytes == (unsigned long)0 || num_bytes > (unsigned long)4) {
                return -1;  // indefinite or too large
            }
            *content_len = (unsigned long)0;
            unsigned long k = (unsigned long)0;
            while (k < num_bytes) {
                if (*offset >= buf_len) { return -1; }
                *content_len = (*content_len << (unsigned long)8)
                             | (unsigned long)buf[*offset];
                *offset = *offset + (unsigned long)1;
                k = k + (unsigned long)1;
            }
        }
        *content_start = *offset;
        if (*content_start + *content_len > buf_len) { return -1; }
        return 0;
    }
}

// Skip past a TLV field (advance *offset to byte after it).
static int asn1_skip_(const unsigned char* buf, unsigned long buf_len,
                       unsigned long* offset) {
    unsigned char tag_out;
    unsigned long cstart;
    unsigned long clen;
    int rc = asn1_tag_len_(buf, buf_len, offset, &tag_out, &cstart, &clen);
    if (rc != 0) { return rc; }
    unsafe { *offset = cstart + clen; }
    return 0;
}

// ── OID comparison ─────────────────────────────────────────────────────────────

static int oid_eq_(const unsigned char* oid_content, unsigned long oid_len,
                   const unsigned char* expected, unsigned long expected_len) {
    if (oid_len != expected_len) { return 0; }
    unsigned long i = (unsigned long)0;
    unsafe {
        while (i < oid_len) {
            if (oid_content[i] != expected[i]) { return 0; }
            i = i + (unsigned long)1;
        }
    }
    return 1;
}

// ── UTCTime / GeneralizedTime → Unix timestamp ────────────────────────────────

// Parses a 2-digit decimal number from s[0..1].
static int parse2_(const unsigned char* s) {
    unsafe {
        return ((int)(s[0] - (unsigned char)0x30)) * 10
             +  (int)(s[1] - (unsigned char)0x30);
    }
}

// Days in month (non-leap-year).
static int days_in_month_(int m) {
    int dm[12] = { 31,28,31,30,31,30,31,31,30,31,30,31 };
    if (m < 1 || m > 12) { return 30; }
    return dm[m - 1];
}

static int is_leap_(int y) {
    return ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)) ? 1 : 0;
}

// Very simplified epoch conversion (ignores sub-second, assumes UTC Z suffix).
static unsigned long long date_to_epoch_(int year, int month, int day,
                                          int hour, int min, int sec) {
    // Validity is unsigned seconds since 1970: an earlier date clamps to 0
    // (still "before" every timestamp is_valid_at can be asked about).
    if (year < 1970) { return (unsigned long long)0; }
    // Days from 1970-01-01 to year-01-01
    int y = 1970;
    unsigned long long days = (unsigned long long)0;
    while (y < year) {
        days = days + (unsigned long long)(is_leap_(y) != 0 ? 366 : 365);
        y = y + 1;
    }
    int m = 1;
    while (m < month) {
        int d = days_in_month_(m);
        if (m == 2 && is_leap_(year) != 0) { d = d + 1; }
        days = days + (unsigned long long)d;
        m = m + 1;
    }
    days = days + (unsigned long long)(day - 1);
    return days * (unsigned long long)86400
         + (unsigned long long)hour * (unsigned long long)3600
         + (unsigned long long)min  * (unsigned long long)60
         + (unsigned long long)sec;
}

// Parse UTCTime "YYMMDDHHMMSSZ" (13 bytes) to Unix timestamp.
static unsigned long long utctime_to_epoch_(const unsigned char* s,
                                             unsigned long len) {
    if (len < (unsigned long)13) { return (unsigned long long)0; }
    int yy    = parse2_(s);
    int year  = (yy >= 50) ? (1900 + yy) : (2000 + yy);
    int month;
    int day;
    int hour;
    int min;
    int sec;
    unsafe {
        month = parse2_(s + 2);
        day   = parse2_(s + 4);
        hour  = parse2_(s + 6);
        min   = parse2_(s + 8);
        sec   = parse2_(s + 10);
    }
    return date_to_epoch_(year, month, day, hour, min, sec);
}

// Parse GeneralizedTime "YYYYMMDDHHMMSSZ" (15 bytes) to Unix timestamp.
static unsigned long long gentime_to_epoch_(const unsigned char* s,
                                             unsigned long len) {
    if (len < (unsigned long)15) { return (unsigned long long)0; }
    unsafe {
        int year = ((int)(s[0]-0x30))*1000 + ((int)(s[1]-0x30))*100
                 + ((int)(s[2]-0x30))*10   +  (int)(s[3]-0x30);
        int month = parse2_(s + 4);
        int day   = parse2_(s + 6);
        int hour  = parse2_(s + 8);
        int min   = parse2_(s + 10);
        int sec   = parse2_(s + 12);
        return date_to_epoch_(year, month, day, hour, min, sec);
    }
}

// ── Name parsing ──────────────────────────────────────────────────────────────

// OID for CN  = 2.5.4.3  → {0x55, 0x04, 0x03}
// OID for O   = 2.5.4.10 → {0x55, 0x04, 0x0A}
// OID for C   = 2.5.4.6  → {0x55, 0x04, 0x06}
static unsigned char oid_cn_[3] = { 0x55, 0x04, 0x03 };
static unsigned char oid_o_[3]  = { 0x55, 0x04, 0x0A };
static unsigned char oid_c_[3]  = { 0x55, 0x04, 0x06 };

// Parse a SEQUENCE OF SET OF { OID, string } name structure into X509Name.
static int parse_name_(const unsigned char* buf, unsigned long buf_len,
                        unsigned long name_start, unsigned long name_len,
                        &X509Name name) {
    unsigned long pos = name_start;
    unsigned long end = name_start + name_len;

    while (pos < end) {
        // Each RDN is a SET
        unsigned char set_tag;
        unsigned long set_start;
        unsigned long set_len;
        if (asn1_tag_len_(buf, buf_len, &pos, &set_tag, &set_start, &set_len) != 0) {
            return -1;
        }
        unsigned long set_end = set_start + set_len;
        pos = set_start;

        while (pos < set_end) {
            // AttributeTypeAndValue SEQUENCE
            unsigned char atv_tag;
            unsigned long atv_start;
            unsigned long atv_len;
            if (asn1_tag_len_(buf, buf_len, &pos, &atv_tag, &atv_start, &atv_len) != 0) {
                return -1;
            }
            pos = atv_start;

            // OID
            unsigned char oid_tag;
            unsigned long oid_start;
            unsigned long oid_len;
            if (asn1_tag_len_(buf, buf_len, &pos, &oid_tag, &oid_start, &oid_len) != 0) {
                return -1;
            }
            pos = oid_start + oid_len;

            // String value (any supported string type)
            unsigned char val_tag;
            unsigned long val_start;
            unsigned long val_len;
            if (asn1_tag_len_(buf, buf_len, &pos, &val_tag, &val_start, &val_len) != 0) {
                return -1;
            }
            pos = val_start + val_len;

            // Match OID and copy string
            unsafe {
                const unsigned char* oid_bytes = buf + oid_start;
                const unsigned char* val_bytes = buf + val_start;
                if (oid_eq_(oid_bytes, oid_len, (const unsigned char*)oid_cn_, (unsigned long)3) != 0) {
                    copy_str_((char*)name.cn, val_bytes, val_len,
                              (unsigned long)X509_MAX_NAME_LEN);
                } else if (oid_eq_(oid_bytes, oid_len, (const unsigned char*)oid_o_, (unsigned long)3) != 0) {
                    copy_str_((char*)name.o, val_bytes, val_len,
                              (unsigned long)X509_MAX_NAME_LEN);
                } else if (oid_eq_(oid_bytes, oid_len, (const unsigned char*)oid_c_, (unsigned long)3) != 0) {
                    unsigned long n = val_len;
                    if (n > (unsigned long)3) { n = (unsigned long)3; }
                    unsigned long k = (unsigned long)0;
                    while (k < n) {
                        name.c[k] = (char)val_bytes[k];
                        k = k + (unsigned long)1;
                    }
                    name.c[n] = (char)0;
                }
            }

            // Advance to end of this ATV
            if (pos < atv_start + atv_len) { pos = atv_start + atv_len; }
        }

        if (pos < set_end) { pos = set_end; }
    }
    return 0;
}

// ── SAN extension parsing ─────────────────────────────────────────────────────

static int parse_san_(const unsigned char* buf, unsigned long buf_len,
                       unsigned long ext_val_start, unsigned long ext_val_len,
                       &X509San san) {
    // ext_val is OCTET STRING wrapping a SEQUENCE OF GeneralName
    unsigned long pos = ext_val_start;
    unsigned long end = ext_val_start + ext_val_len;

    // Skip outer OCTET STRING wrapper if present (we are already inside it from
    // the caller which advanced past the OCTET STRING header)
    // Parse SEQUENCE OF GeneralName
    unsigned char seq_tag;
    unsigned long seq_start;
    unsigned long seq_len;
    if (asn1_tag_len_(buf, buf_len, &pos, &seq_tag, &seq_start, &seq_len) != 0) {
        return -1;
    }
    pos = seq_start;
    unsigned long seq_end = seq_start + seq_len;

    while (pos < seq_end && san.count < X509_MAX_SAN) {
        unsigned char gn_tag;
        unsigned long gn_start;
        unsigned long gn_len;
        if (asn1_tag_len_(buf, buf_len, &pos, &gn_tag, &gn_start, &gn_len) != 0) {
            return -1;
        }
        // dNSName [2] IMPLICIT IA5String
        if ((gn_tag & (unsigned char)0x1F) == (unsigned char)2) {
            unsafe {
                copy_str_((char*)san.names[san.count], buf + gn_start, gn_len,
                           (unsigned long)X509_MAX_SAN_LEN);
            }
            san.count = san.count + 1;
        }
        pos = gn_start + gn_len;
    }
    return 0;
}

// ── Main DER parser ───────────────────────────────────────────────────────────

int X509Cert::init_n_(const &unsigned char der_ref, unsigned long len) {
    const unsigned char* der = der_ref;
    unsafe {
        memset((unsigned char*)self, 0, (unsigned long)sizeof(struct X509Cert));   // the struct, not the reference
    }

    unsigned long pos = (unsigned long)0;

    // Outer SEQUENCE (Certificate)
    unsigned char tag;
    unsigned long cstart;
    unsigned long clen;
    if (asn1_tag_len_(der, len, &pos, &tag, &cstart, &clen) != 0) { return -1; }
    if (tag != (unsigned char)ASN1_SEQUENCE) { return -1; }
    pos = cstart;

    // TBSCertificate SEQUENCE
    unsigned char tbs_tag;
    unsigned long tbs_start;
    unsigned long tbs_len;
    if (asn1_tag_len_(der, len, &pos, &tbs_tag, &tbs_start, &tbs_len) != 0) { return -1; }
    if (tbs_tag != (unsigned char)ASN1_SEQUENCE) { return -1; }

    unsigned long tbs_end = tbs_start + tbs_len;
    pos = tbs_start;

    // Optional version [0] EXPLICIT INTEGER
    unsafe {
        if (pos < tbs_end && der[pos] == (unsigned char)ASN1_CONTEXT_0) {
            if (asn1_skip_(der, len, &pos) != 0) { return -1; }
        }
    }

    // serialNumber INTEGER
    unsigned char sn_tag;
    unsigned long sn_start;
    unsigned long sn_len;
    if (asn1_tag_len_(der, len, &pos, &sn_tag, &sn_start, &sn_len) != 0) { return -1; }
    unsafe {
        unsigned long serial_copy = sn_len;
        if (serial_copy > (unsigned long)20) { serial_copy = (unsigned long)20; }
        memcpy((unsigned char*)self.serial, (const unsigned char*)(der + sn_start), serial_copy);
        self.serial_len = serial_copy;
    }
    pos = sn_start + sn_len;

    // signature AlgorithmIdentifier SEQUENCE (skip)
    if (asn1_skip_(der, len, &pos) != 0) { return -1; }

    // issuer Name SEQUENCE
    unsigned char iss_tag;
    unsigned long iss_start;
    unsigned long iss_len;
    if (asn1_tag_len_(der, len, &pos, &iss_tag, &iss_start, &iss_len) != 0) { return -1; }
    if (parse_name_(der, len, iss_start, iss_len, &self.issuer) != 0) { return -1; }
    pos = iss_start + iss_len;

    // validity SEQUENCE
    unsigned char val_tag;
    unsigned long val_start;
    unsigned long val_len;
    if (asn1_tag_len_(der, len, &pos, &val_tag, &val_start, &val_len) != 0) { return -1; }
    {
        unsigned long vpos = val_start;

        // notBefore
        unsigned char nb_tag;
        unsigned long nb_start;
        unsigned long nb_len;
        if (asn1_tag_len_(der, len, &vpos, &nb_tag, &nb_start, &nb_len) != 0) { return -1; }
        unsafe {
            if (nb_tag == (unsigned char)ASN1_UTCTIME) {
                self.validity.not_before = utctime_to_epoch_(der + nb_start, nb_len);
            } else {
                self.validity.not_before = gentime_to_epoch_(der + nb_start, nb_len);
            }
        }
        vpos = nb_start + nb_len;

        // notAfter
        unsigned char na_tag;
        unsigned long na_start;
        unsigned long na_len;
        if (asn1_tag_len_(der, len, &vpos, &na_tag, &na_start, &na_len) != 0) { return -1; }
        unsafe {
            if (na_tag == (unsigned char)ASN1_UTCTIME) {
                self.validity.not_after = utctime_to_epoch_(der + na_start, na_len);
            } else {
                self.validity.not_after = gentime_to_epoch_(der + na_start, na_len);
            }
        }
    }
    pos = val_start + val_len;

    // subject Name SEQUENCE
    unsigned char subj_tag;
    unsigned long subj_start;
    unsigned long subj_len;
    if (asn1_tag_len_(der, len, &pos, &subj_tag, &subj_start, &subj_len) != 0) { return -1; }
    if (parse_name_(der, len, subj_start, subj_len, &self.subject) != 0) { return -1; }
    pos = subj_start + subj_len;

    // subjectPublicKeyInfo SEQUENCE
    unsigned char spki_tag;
    unsigned long spki_start;
    unsigned long spki_len;
    if (asn1_tag_len_(der, len, &pos, &spki_tag, &spki_start, &spki_len) != 0) { return -1; }
    unsafe {
        unsigned long pk_copy = spki_len;
        if (pk_copy > (unsigned long)X509_KEY_LEN) { pk_copy = (unsigned long)X509_KEY_LEN; }
        memcpy((unsigned char*)self.pubkey, (const unsigned char*)(der + spki_start), pk_copy);
        self.pubkey_len = pk_copy;
    }
    pos = spki_start + spki_len;

    // Skip optional issuerUniqueID [1] and subjectUniqueID [2]
    unsafe {
        while (pos < tbs_end &&
               (der[pos] == (unsigned char)ASN1_CONTEXT_1 ||
                der[pos] == (unsigned char)ASN1_CONTEXT_2)) {
            if (asn1_skip_(der, len, &pos) != 0) { return -1; }
        }
    }

    // Extensions [3] EXPLICIT SEQUENCE OF Extension (optional)
    unsafe {
        if (pos < tbs_end && der[pos] == (unsigned char)ASN1_CONTEXT_3) {
            unsigned char ext_ctx_tag;
            unsigned long ext_ctx_start;
            unsigned long ext_ctx_len;
            if (asn1_tag_len_(der, len, &pos, &ext_ctx_tag,
                               &ext_ctx_start, &ext_ctx_len) != 0) { return -1; }

            // Inner SEQUENCE OF Extension
            unsigned long epos = ext_ctx_start;
            unsigned char exts_tag;
            unsigned long exts_start;
            unsigned long exts_len;
            if (asn1_tag_len_(der, len, &epos, &exts_tag,
                               &exts_start, &exts_len) != 0) { return -1; }

            unsigned long exts_end = exts_start + exts_len;
            epos = exts_start;

            while (epos < exts_end) {
                // Extension SEQUENCE
                unsigned char ext_tag;
                unsigned long ext_start;
                unsigned long ext_len;
                if (asn1_tag_len_(der, len, &epos, &ext_tag,
                                   &ext_start, &ext_len) != 0) { return -1; }

                unsigned long ext_end = ext_start + ext_len;
                unsigned long ipos    = ext_start;

                // extnID OID
                unsigned char oid_tag2;
                unsigned long oid_start2;
                unsigned long oid_len2;
                if (asn1_tag_len_(der, len, &ipos, &oid_tag2,
                                   &oid_start2, &oid_len2) != 0) { return -1; }
                ipos = oid_start2 + oid_len2;

                // Optional critical BOOLEAN
                if (ipos < ext_end && der[ipos] == (unsigned char)ASN1_BOOLEAN) {
                    if (asn1_skip_(der, len, &ipos) != 0) { return -1; }
                }

                // extnValue OCTET STRING
                unsigned char ev_tag;
                unsigned long ev_start;
                unsigned long ev_len;
                if (asn1_tag_len_(der, len, &ipos, &ev_tag,
                                   &ev_start, &ev_len) != 0) { return -1; }

                const unsigned char* oid_bytes = der + oid_start2;

                if (oid_eq_(oid_bytes, oid_len2,
                             (const unsigned char*)oid_san_, (unsigned long)3) != 0) {
                    parse_san_(der, len, ev_start, ev_len, &self.san);
                } else if (oid_eq_(oid_bytes, oid_len2,
                                    (const unsigned char*)oid_bc_, (unsigned long)3) != 0) {
                    // BasicConstraints: SEQUENCE { cA BOOLEAN OPTIONAL, ... }
                    unsigned long bpos = ev_start;
                    unsigned char bc_seq_tag;
                    unsigned long bc_seq_start;
                    unsigned long bc_seq_len;
                    if (asn1_tag_len_(der, len, &bpos, &bc_seq_tag,
                                       &bc_seq_start, &bc_seq_len) == 0) {
                        unsigned long bcpos = bc_seq_start;
                        if (bcpos < bc_seq_start + bc_seq_len &&
                            der[bcpos] == (unsigned char)ASN1_BOOLEAN) {
                            unsigned char bt;
                            unsigned long bs;
                            unsigned long bl;
                            if (asn1_tag_len_(der, len, &bcpos, &bt, &bs, &bl) == 0
                                && bl >= (unsigned long)1) {
                                self.is_ca = (der[bs] != (unsigned char)0) ? 1 : 0;
                            }
                        }
                    }
                } else if (oid_eq_(oid_bytes, oid_len2,
                                    (const unsigned char*)oid_ku_, (unsigned long)3) != 0) {
                    // KeyUsage: BIT STRING inside OCTET STRING
                    unsigned long kpos = ev_start;
                    unsigned char ks_tag;
                    unsigned long ks_start;
                    unsigned long ks_len;
                    if (asn1_tag_len_(der, len, &kpos, &ks_tag,
                                       &ks_start, &ks_len) == 0
                        && ks_len >= (unsigned long)2) {
                        // First byte = unused bits count; second byte = usage bits
                        self.key_usage = (int)der[ks_start + 1UL];
                    }
                }

                epos = ext_end;
            }
        }
    }

    return 0;
}

int X509Cert::init([]const unsigned char der_ref_s) {
    unsigned long len = der_ref_s.len;
    const &unsigned char der_ref = crypto_in_(der_ref_s, 0UL);
    return self.init_n_(der_ref, len);
}

// ── X509Cert methods ──────────────────────────────────────────────────────────

inline int X509Cert::is_valid_at(unsigned long long ts) const {
    return (ts >= self.validity.not_before && ts <= self.validity.not_after)
           ? 1 : 0;
}

inline int X509Cert::is_ca_cert() const {
    return self.is_ca;
}

// Wildcard match: *.example.com matches sub.example.com but not a.b.example.com
// DNS names compare case-insensitively (RFC 6125 §6.4.1).
static int host_eq_(const char* a, const char* b) {
    int i = 0;
    unsafe {
        while (a[i] != (char)0 && b[i] != (char)0) {
            char x = a[i];
            char y = b[i];
            if (x >= 'A' && x <= 'Z') { x = (char)((int)x + 32); }
            if (y >= 'A' && y <= 'Z') { y = (char)((int)y + 32); }
            if (x != y) { return 0; }
            i = i + 1;
        }
        return (a[i] == (char)0 && b[i] == (char)0) ? 1 : 0;
    }
}

static int wildcard_match_(const char* pattern, const char* hostname) {
    // pattern must start with "*."
    unsafe {
        if (pattern[0] != (char)'*' || pattern[1] != (char)'.') { return 0; }
        // Find first '.' in hostname
        int i = 0;
        while (hostname[i] != (char)'.' && hostname[i] != (char)0) { i = i + 1; }
        if (hostname[i] == (char)0) { return 0; }
        // suffix of hostname after the first '.' must match pattern+1
        const char* hs = hostname + i;      // ".example.com"
        const char* ps = pattern  + 1;      // ".example.com"
        return host_eq_(hs, ps);
    }
}

int X509Cert::matches_hostname(const &char hostname) const {
    int i = 0;
    while (i < self.san.count) {
        unsafe {
            const char* name = (const char*)self.san.names[i];
            if (name[0] == (char)'*') {
                if (wildcard_match_(name, hostname) != 0) { return 1; }
            } else {
                if (host_eq_(name, hostname) != 0) { return 1; }
            }
        }
        i = i + 1;
    }
    // The subject CN is only a fallback for certificates without DNS SANs
    // (RFC 6125 §6.4.4).
    if (self.san.count > 0) { return 0; }
    unsafe {
        const char* cn = (const char*)self.subject.cn;
        if (cn[0] == (char)'*') {
            if (wildcard_match_(cn, hostname) != 0) { return 1; }
        } else {
            if (host_eq_(cn, hostname) != 0) { return 1; }
        }
    }
    return 0;
}

// ── Chain verification (structural only) ──────────────────────────────────────

int X509Cert::verify_chain(const &X509Cert issuer_cert) const {
    // Check that self.issuer CN/O/C match issuer_cert.subject CN/O/C
    unsafe {
        if (str_eq_((const char*)self.issuer.cn,
                    (const char*)issuer_cert.subject.cn) == 0) { return 0; }
        if (str_eq_((const char*)self.issuer.o,
                    (const char*)issuer_cert.subject.o) == 0) { return 0; }
        if (str_eq_((const char*)self.issuer.c,
                    (const char*)issuer_cert.subject.c) == 0) { return 0; }
    }
    return 1;
}

} // namespace std