x509

tape
import x509 from "x509";

x509 reads X.509 v3 certificates the way a TLS client needs them: it parses a certificate into views of its fields, checks signatures with crypto, matches a host name against the certificate’s subjectAltName, and validates a chain from a server’s certificate up to a trusted root at a time you give it.

tape
import x509 from "x509";
import ca_roots from "ca_roots";

fn check_server(chain: []const []const u8, host: []const u8, now: i64) -> void or x509.X509Error {
    var anchors: [ca_roots.COUNT]x509.Cert;
    var store: x509.TrustStore = x509.trust_store(anchors[0..ca_roots.COUNT]);
    ca_roots.load(&store) or return;
    x509.verify_chain(&store, chain, host, now) or return;   // NameMismatch, Expired, UnknownIssuer, ...
}

The module is pure and works in every profile, t0 included. Nothing is copied or allocated: a Cert is a set of views into the DER bytes you parsed, so those bytes must outlive it. x509 reads no clock; you pass the time (seconds since 1970, UTC).

What it does not do: revocation (no OCSP, CRL or Certificate Transparency: the largest gap in what a chain check proves), name constraints and certificate policies (a chain through a CA that carries them as critical extensions is refused), PEM text, creating certificates, client certificates. Host names are matched against the subjectAltName only, never the subject’s common name.

Errors

tape
pub enum X509Error : u8 {
    BadDer = 1: "malformed DER";
    UnsupportedVersion = 2: "not an X.509 v3 certificate";
    UnsupportedAlgorithm = 3: "unsupported signature or key algorithm";
    UnsupportedCriticalExtension = 4: "unsupported critical extension";
    BadTime = 5: "malformed validity time";
    NotYetValid = 6: "certificate is not yet valid";
    Expired = 7: "certificate has expired";
    NameMismatch = 8: "certificate does not match the host name";
    UnknownIssuer = 9: "chain does not lead to a trusted root";
    BadSignature = 10: "certificate signature does not verify";
    NotACa = 11: "issuer is not a certificate authority";
    PathTooLong = 12: "chain exceeds a path length constraint";
    KeyUsage = 13: "key usage does not allow this use";
    ChainTooLong = 14: "chain is longer than allowed";
    StoreFull = 15: "trust store is full";
}

DER

tape
pub struct Tlv { tag: u8; body: []const u8; whole: []const u8; }
pub struct Der { rest: []const u8; }

pub fn der(data: []const u8) -> Der
pub fn Der.next(*self) -> Tlv or X509Error
pub fn Der.expect(*self, tag: u8) -> Tlv or X509Error
pub fn Der.is_empty(*const self) -> bool
pub fn Tlv.int_unsigned(*const self) -> []const u8 or X509Error

A Der reads a sequence of DER elements; each next returns one as a Tlv: its tag, its contents (body) and its whole encoding, header included (whole). expect is next that also requires a tag. The reader is strict, as DER (X.690 §10) is: definite lengths in their shortest form, at most 4 length bytes, one-byte tags, and every element inside what contains it. Anything else is BadDer, and a failed next or expect consumes nothing.

tape
var d: x509.Der = x509.der(cert_bytes);
let cert: x509.Tlv = d.expect(x509.TAG_SEQUENCE) or return;     // Certificate
var fields: x509.Der = x509.der(cert.body);
let tbs: x509.Tlv = fields.next() or return;                     // TBSCertificate

int_unsigned gives a non-negative INTEGER’s magnitude without its sign byte, and refuses a negative or non-minimal one (BadDer): an RSA modulus 00 C3 ... comes back as C3 ....

The tags certificates use are constants: TAG_BOOLEAN, TAG_INTEGER, TAG_BIT_STRING, TAG_OCTET_STRING, TAG_NULL, TAG_OID, TAG_UTC_TIME, TAG_GENERALIZED_TIME, TAG_SEQUENCE, TAG_SET.

Certificates

tape
pub fn parse(der: []const u8) -> Cert or X509Error

parse reads one certificate (all of der) into a Cert:

Field
der, tbsthe whole certificate; the signed part (TBSCertificate, header included)
sig_alg: SigAlg, sigthe signature algorithm and the signature bytes
serialthe serial number’s INTEGER contents, as they are
issuer, subjectthe names, DER with their headers (compare them with bytes.eq)
not_before, not_after: i64the validity period, seconds since 1970 UTC
key_alg: KeyAlg, key, spkithe key: an EC point (65 or 97 bytes) or an RSAPublicKey SEQUENCE; the whole SubjectPublicKeyInfo
san, has_santhe subjectAltName’s GeneralNames contents
is_ca, path_lenbasicConstraints: cA, and pathLenConstraint (-1 when absent)
key_usage: u16, has_key_usagekeyUsage bits, KU_DIGITAL_SIGNATURE (bit 0) to KU_DECIPHER_ONLY (bit 8)
eku, has_ekuthe extKeyUsage OIDs
tape
pub enum SigAlg : u8 {
    RsaPkcs1Sha256; RsaPkcs1Sha384; RsaPkcs1Sha512;
    RsaPssSha256; RsaPssSha384; RsaPssSha512;
    EcdsaSha256; EcdsaSha384; EcdsaSha512;
    Unsupported;        // any other: parsed, never verified
}
pub enum KeyAlg : u8 { Rsa; EcP256; EcP384; }

What parse refuses:

  • not version 3: UnsupportedVersion;
  • the inner and outer signature algorithms differing in any byte: BadDer;
  • a key that is not RSA, P-256 or P-384 (with an uncompressed point): UnsupportedAlgorithm;
  • a validity time that is not YYMMDDHHMMSSZ (UTCTime, 50-99 meaning 19xx) or YYYYMMDDHHMMSSZ (GeneralizedTime) with every field in range: BadTime;
  • an extension that appears twice: BadDer; a critical extension other than subjectAltName, basicConstraints, keyUsage, extKeyUsage, subjectKeyIdentifier, authorityKeyIdentifier and certificatePolicies: UnsupportedCriticalExtension. Unknown non-critical extensions are skipped.

A signature algorithm x509 cannot verify (SHA-1, RSA-PSS with other parameters than MGF1 over the same hash and a salt as long as the hash, Ed25519) does not stop parse: sig_alg is then SigAlg.Unsupported, and checking a signature made with it is UnsupportedAlgorithm. So a root that signs itself with SHA-1 can be a trust anchor (its own signature is never checked), but a SHA-1-signed intermediate or leaf is refused.

tape
let c: x509.Cert = x509.parse(der) or {
    io.println("not usable: {err}");
    return;
};
io.println("valid until {c.not_after}, signed with {c.sig_alg.desc()}");
if (c.has_key_usage && (c.key_usage & x509.KU_KEY_CERT_SIGN) != 0) {
    io.println("may sign certificates");
}

Names

tape
pub fn Cert.dns_names(*const self) -> NameIter
pub fn NameIter.next(*self) -> ?[]const u8
pub fn Cert.common_name(*const self) -> ?[]const u8
pub fn Cert.matches_host(*const self, host: []const u8) -> bool

dns_names iterates the DNS names of the subjectAltName; common_name is the subject’s last commonName, for showing to people (null when there is none).

tape
for (name in c.dns_names()) {
    io.println("  DNS: {name}");
}
let label: []const u8 = c.common_name() or { "(no name)" };

matches_host says whether the certificate is for host, the way RFC 9525 says:

  • host is a DNS name (one trailing dot ignored) or an IP address in text form (192.0.2.1, 2001:db8::1). An address matches only an address entry of the subjectAltName with the same bytes; a name never matches an address entry, nor an address a DNS entry.
  • Names compare ASCII case-insensitively. A wildcard counts only as the whole leftmost label with at least two labels after it: *.example.com matches a.example.com but not example.com or a.b.example.com; *.com, f*.example.com and a bare * match nothing.
  • Only the subjectAltName counts. A certificate with no DNS names in it matches no host name, whatever its common name says (browsers stopped reading the common name in 2017).

Internationalized names are compared in their ASCII form (xn--...).

Signatures

tape
pub fn Cert.verify_signed_by(*const self, issuer: *const Cert) -> void or X509Error
pub fn verify_signature(key_alg: KeyAlg, key: []const u8, alg: SigAlg, signed: []const u8, sig: []const u8) -> void or X509Error

verify_signed_by checks that issuer’s key made this certificate’s signature. verify_signature is the same check on any signed bytes, with a certificate’s key: it is what a TLS client uses on the server’s CertificateVerify. It hashes signed with the algorithm’s hash and calls crypto’s ECDSA, PKCS#1 v1.5 or PSS verification. An EC key with an RSA algorithm (or the reverse), or an RSA key outside 2048-4096 bits, is UnsupportedAlgorithm, as is SigAlg.Unsupported; a signature that doesn’t verify is BadSignature.

tape
// content: what TLS 1.3 signs; signature: the CertificateVerify's bytes
x509.verify_signature(leaf.key_alg, leaf.key, x509.SigAlg.EcdsaSha256, content, signature) or {
    io.println("CertificateVerify: {err}");
};

Trust store

tape
pub struct TrustStore { anchors: []Cert; count: i64; }

pub fn trust_store(storage: []Cert) -> TrustStore
pub fn TrustStore.add(*self, der: []const u8) -> void or X509Error
pub fn TrustStore.len(*const self) -> i64
pub fn TrustStore.find_issuer(*const self, cert: *const Cert, start: i64) -> ?i64

A store is the roots you trust, parsed into storage you provide: a store over [N]x509.Cert holds N anchors. add parses one root (DER, which must outlive the store); it doesn’t check the root’s own signature, dates or extensions, since a root is trusted by being there. A full store is StoreFull, a certificate that doesn’t parse is refused with its error. There is no remove: build another store for other anchors.

find_issuer returns the index of the first anchor at or after start whose subject is cert’s issuer.

tape
var anchors: [4]x509.Cert;
var store: x509.TrustStore = x509.trust_store(anchors[0..4]);
store.add(my_root_der) or { io.println("bad root: {err}"); };

Most programs start from tape’s bundled roots, ca_roots: ca_roots.load(&store) adds all ca_roots.COUNT of them.

Chain validation

tape
pub const MAX_CHAIN: i64 = 8;

pub fn verify_chain(store: *const TrustStore, chain: []const []const u8, host: []const u8, now_unix: i64) -> void or X509Error
pub fn verify_chain_parsed(store: *const TrustStore, certs: []const Cert, host: []const u8, now_unix: i64) -> void or X509Error

chain is what a TLS server sends: the leaf first, then intermediates in any order, possibly with certificates that aren’t needed. verify_chain_parsed is the same on certificates you have parsed. The checks, in this order (the first failure is returned):

  1. The leaf matches host (NameMismatch); its keyUsage, if any, allows digitalSignature, and its extKeyUsage, if any, includes serverAuth or anyExtendedKeyUsage (KeyUsage).
  2. A path to an anchor: from each certificate, its issuer is an anchor with that subject whose key verifies it, or else an unused intermediate from chain that does. No issuer is UnknownIssuer (or the failing signature’s error, BadSignature, when a certificate with the right name didn’t verify). A leaf identical to an anchor is trusted as it is. A path has at most MAX_CHAIN certificates, anchor included (ChainTooLong); more than MAX_CHAIN in chain is ChainTooLong too.
  3. Every certificate on the path, the anchor too, is valid at now_unix, both ends included (NotYetValid, Expired).
  4. Every intermediate is a CA (NotACa), may sign certificates when it has a keyUsage (KeyUsage), and its pathLenConstraint allows the intermediates below it (PathTooLong). The anchor’s own extensions aren’t checked.

The leaf must parse; an intermediate that doesn’t is left out, and if no path is found its error is returned instead of UnknownIssuer. An empty chain or host is a broken contract and traps.

tape
var chain: [3][]const u8;
chain[0] = leaf;
chain[1] = intermediate;
chain[2] = cross_signed_root;            // extra certificates are fine
x509.verify_chain(&store, chain[0..3], "example.com", now) or {
    io.println("untrusted: {err}");      // e.g. "certificate has expired"
    return;
};

The time is yours: a wrong clock makes good chains fail, never bad ones pass. In a hosted program it is time.unix_nanos() / 1000000000 (time).

Notes

  • Certificates are public, so nothing here is @constant_time.
  • DER only; a PEM file is base64 between -----BEGIN CERTIFICATE----- lines, which encoding decodes.
  • Tested with the Uusi kernel’s fixtures, generated chains for every algorithm and failure, captured chains of public servers, and the server-authentication cases of C2SP’s x509-limbo suite.

Last modified: