x509
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.
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
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
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 X509ErrorA 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.
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; // TBSCertificateint_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
pub fn parse(der: []const u8) -> Cert or X509Errorparse reads one certificate (all of der) into a Cert:
| Field | |
|---|---|
der, tbs | the whole certificate; the signed part (TBSCertificate, header included) |
sig_alg: SigAlg, sig | the signature algorithm and the signature bytes |
serial | the serial number’s INTEGER contents, as they are |
issuer, subject | the names, DER with their headers (compare them with bytes.eq) |
not_before, not_after: i64 | the validity period, seconds since 1970 UTC |
key_alg: KeyAlg, key, spki | the key: an EC point (65 or 97 bytes) or an RSAPublicKey SEQUENCE; the whole SubjectPublicKeyInfo |
san, has_san | the subjectAltName’s GeneralNames contents |
is_ca, path_len | basicConstraints: cA, and pathLenConstraint (-1 when absent) |
key_usage: u16, has_key_usage | keyUsage bits, KU_DIGITAL_SIGNATURE (bit 0) to KU_DECIPHER_ONLY (bit 8) |
eku, has_eku | the extKeyUsage OIDs |
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) orYYYYMMDDHHMMSSZ(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.
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
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) -> booldns_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).
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:
hostis 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.commatchesa.example.combut notexample.comora.b.example.com;*.com,f*.example.comand 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
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 X509Errorverify_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.
// 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
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) -> ?i64A 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.
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
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 X509Errorchain 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):
- The leaf matches
host(NameMismatch); its keyUsage, if any, allows digitalSignature, and its extKeyUsage, if any, includes serverAuth or anyExtendedKeyUsage (KeyUsage). - 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
chainthat does. No issuer isUnknownIssuer(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 mostMAX_CHAINcertificates, anchor included (ChainTooLong); more thanMAX_CHAINinchainisChainTooLongtoo. - Every certificate on the path, the anchor too, is valid at
now_unix, both ends included (NotYetValid,Expired). - 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.
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, whichencodingdecodes. - 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: