Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

packages/x509: strict DER and X.509 v3 certificates

Status: built. Not independently reviewed (#209). Sub-issue 5 (#202) of the self-contained TLS 1.3 client (#197). docs/tls-pure.md §1 puts certificate code in a package, versioned apart from the compiler; this is that package’s first part, the parser. Chain building, signatures and name matching are #206; RSA and ECDSA are #203 and #204.


1. What was built

FileWhat
packages/x509/x509.cho, module x509tlv (one strict DER TLV), parse(der, view), pem_next(text, at, out, info), refusal_tag, oid_code, and the view’s slot names (§2)
tests/programs/x509_driver.choa PEM bundle in, one line per certificate out, with every field §5.1 compares; or hex DER lines in
tests/programs/x509_fuzz.chothe fuzz harness (§5.4)
crates/cancho/tests/conformance/x509.rsthe corpus on both backends, the negative corpus, PEM refusals, 20,000 fuzz rounds
tests/vectors/x509/corpus.pem and corpus.txt (10 certificates and pyca’s reading of them), negative.txt (36 damaged ones)
scripts/x509_check.pyany PEM bundle against pyca/cryptography, field by field (§5.1)
scripts/x509_corpus.pymade tests/vectors/x509/ (§5.2)
scripts/x509_limbo.pyevery certificate in x509-limbo (§5.3)
scripts/x509_oids.pyprints the OID table (§2.3)

The package imports nothing, not even std, so it needs no --std to publish (docs/package-system.md §4.8). A program that uses it names the file: cancho build --std app.cho packages/x509/x509.cho. It is not published to a store yet; #205 is its first user, and publishes it.

No secrets here. A certificate is public, sent in the clear, so none of the constant-time rules of docs/tls-pure.md §4.2 apply. The rules are about input: no input reaches a trap, every refusal has its own code and tag, and no allocation is sized by the input.


2. The view

parse does not copy anything out of the certificate. It fills a caller’s [int] of view_len() (40) words with offsets into the certificate’s own DER, and a few small decoded values. Ranges are [start, end). The slots have names, so a caller writes view[x509.subject_start()], never view[11].

SlotsNameHolds
0, 1tbs_start, tbs_endthe whole TBSCertificate TLV: the bytes the issuer signed
2signature_algorithmthe outer AlgorithmIdentifier’s OID code (§2.3; 0 for one not in the table)
3, 4signature_params_start, _endits parameters (empty when absent; RSASSA-PSS has some)
5, 6signature_start, _endthe signature’s bits, after the unused-bits byte (which must be 0)
7, 8serial_start, _endthe serial’s INTEGER content
9, 10; 11, 12issuer_*, subject_*the whole Name TLVs, for byte comparison in chain building
13, 14not_before, not_afterseconds since 1970, UTC
15, 16spki_start, _endthe whole SubjectPublicKeyInfo
17, 18key_algorithm, key_curveOID codes; the curve is 0 unless the key is EC
19, 20key_start, _endthe key’s bits: an RSAPublicKey, an EC point, or 32 Ed25519 bytes
21version1, 2 or 3
22, 23is_ca, path_lenbasicConstraints: -1 absent, else 0 or 1; the path length or -1
24, 25key_usage, ext_key_usage-1 absent; else keyUsage’s named bits as a mask (bit 0 digitalSignature, 5 keyCertSign, …), and eku_* flags (1 serverAuth, 2 clientAuth, 4 any, 8 another)
26, 27san_start, _endsubjectAltName’s GeneralNames content; 0, 0 when absent
28, 29name_constraints_*nameConstraints’ content, unread here (#206 reads it)
30 to 33ski_*, aki_*the subject key identifier, and the authority’s keyIdentifier
34extension_count
35leniencylenient_* flags (§3.2)
36 to 39rsa_modulus_*, rsa_exponent_*for an RSA key, the two INTEGERs’ contents

On a refusal the view’s contents mean nothing.

2.1 What is checked, and what is only located

parse checks that the whole certificate is strict DER (§3.1), that the fields come in RFC 5280 §4.1’s order with the right tags, and that the fields it decodes are valid: version, serial, both times, both names’ shape, the key’s parameters and length, and the five extensions in §2.2. It does not verify the signature, read name constraints, compare names, or decide whether the key is acceptable. Those belong to #206, with the view’s ranges as their input.

2.2 Extensions

ExtensionReadCritical allowed
basicConstraintscA, pathLenConstraint (non-negative, under 2^31)yes
keyUsagethe BIT STRING, with its unused bits zeroyes
extendedKeyUsageat least one OID, as flagsyes
subjectAltNameat least one entry, at most 1,024; a dNSName, rfc822Name or URI is ASCII; an iPAddress is 4 or 16 bytesyes
nameConstraintslocated, for #206yes
subjectKeyIdentifier, authorityKeyIdentifierlocatedno
any otherits DER checked by the whole-certificate walkno

A critical extension that is not in the “critical allowed” column is refused (x509-critical-extension), as RFC 5280 §4.2 requires of an extension a verifier does not process. That includes a critical certificatePolicies, cRLDistributionPoints or AKI. docs/tls-pure.md §5.4 already says policies and revocation are not checked; refusing them when critical is the honest consequence. The same OID twice is refused (x509-duplicate-extension), and so is an empty extensions list.

2.3 Object identifiers

oid_table is a static of 30 OIDs: the RSA, RSASSA-PSS, ECDSA and Ed25519 algorithms, the three NIST curves, the extensions above and five more, and the three EKU purposes. scripts/x509_oids.py prints it from the dotted forms, so no byte was typed by hand, and its output equals the static byte for byte. Each OID was also checked against openssl asn1parse -genstr OID:<dotted>. An OID that is not in the table has code 0, which is not a refusal: an algorithm the parser does not know is for the verifier to refuse, with the tag docs/tls-pure.md §8 gives it (x509-unsupported-algorithm).


3. What is accepted

3.1 Strict DER

X.690 §10, enforced in one place, tlv, and applied to every TLV in the certificate by a recursive walk before any field is read:

  • a tag in one byte (the high-tag-number form is refused);
  • a definite length (indefinite, 0x80, is refused), in the fewest bytes: the short form below 128, no leading zero byte, at most four bytes;
  • the content entirely inside its parent, and the parent filled exactly;
  • at most 24 levels deep;
  • INTEGERs minimal, BOOLEANs 0x00 or 0xff, BIT STRINGs with a valid unused-bits count.

A serial may be up to 21 content bytes (20 bytes of value, plus the 0x00 that keeps a high bit positive). A negative or zero serial is accepted: RFC 5280 §4.1.2.2 forbids it, but one of the system roots on this machine has one, and refusing a root for its serial helps nobody.

3.2 Two leniencies, flagged

Two things strict RFC 5280 refuses are accepted, and each one sets a bit in view[leniency()], so a caller can see them and #206 can refuse them for a leaf if it chooses:

  • A GeneralizedTime before 2050 (RFC 5280 §4.1.2.5 says UTCTime). One of the 128 roots in this machine’s bundle has one (§5.1).
  • A BOOLEAN DEFAULT FALSE written out as FALSE (X.690 §11.5 says omit it): an extension’s critical, or basicConstraints’ cA. The first version of this document, and the parser’s comments, said some system roots do this. Measured, none of the 128 does. The leniency stays, because other issuers have done it and refusing it gains nothing, but the claim is corrected here, where it was made. pyca/cryptography refuses such a certificate outright (EncodedDefault); OpenSSL accepts it.

3.3 Limits

LimitValueRefusal
a certificate’s DER16,384 bytes (docs/tls-pure.md §5.2)x509-too-large, before anything is read
a PEM block’s decoded bytesthe caller’s bufferx509-too-large, and the bundle goes on
extensions32x509-too-large
subjectAltName entries1,024x509-too-large
DER nesting24der-too-deep

4. Refusals

CodeTagWhen
-1der-truncateda length runs past its parent
-2der-trailing-bytesbytes after the certificate
-3der-indefinite-lengtha 0x80 length
-4der-non-minimal-lengtha length not in its fewest bytes
-5der-tagthe high-tag-number form, or a tag where another was required
-6der-too-deepnesting past 24
-7der-integera non-minimal INTEGER, a serial over 21 bytes, a negative path length
-8der-booleana BOOLEAN that is not 0x00 or 0xff
-9der-bit-stringa bad unused-bits count, or nonzero unused bits
-10x509-structurea field missing, extra or empty where RFC 5280 says it is not
-11x509-versiona version other than v1 to v3, or extensions (or unique IDs) in a version that has none
-12x509-timenot YYMMDDHHMMSSZ or YYYYMMDDHHMMSSZ, or a date that does not exist
-13x509-signature-algorithm-mismatchthe TBSCertificate’s algorithm is not the outer one, byte for byte (RFC 5280 §4.1.1.2)
-14x509-duplicate-extensionthe same extension twice
-15x509-critical-extensiona critical extension that is not read here (§2.2)
-16x509-too-large§3.3
-17x509-namean empty RDN, an attribute without a value, a non-ASCII DNS name, an IP address of the wrong length
-18pemno END line, a character outside base64, wrong padding
-19x509-keyan RSA key that is not an RSAPublicKey with NULL parameters, an EC key with explicit parameters, or a point of the wrong length or not uncompressed

How these become docs/tls-pure.md §8’s tags. That design gave certificate parsing one tag, x509-decode, for a caller to act on: a certificate that is not well-formed is a broken or hostile peer whatever the detail. packages/tls (#205) maps -15 to x509-critical-extension, -16 to x509-chain-too-large, and every other code to x509-decode, keeping this package’s tag as the detail. The finer tags exist so that a test can say which rule refused a certificate (§5.2).


5. How it was tested

5.1 Every system root, against pyca/cryptography

scripts/x509_check.py <driver> parses /etc/ssl/certs/ca-certificates.crt and compares every field the driver prints with pyca/cryptography 49.0.0’s reading: version, serial, issuer and subject DER, both times, key algorithm, curve and size, basicConstraints, keyUsage, extendedKeyUsage, the SAN bytes, the signature algorithm, the extension count, and the leniency flags (computed by the script’s own DER walk, since pyca does not report them).

Result: 128 certificates, all accepted, 0 differences. The bundle: 61 RSA-4096, 28 RSA-2048, 35 P-384, 3 P-256 and 1 P-521 keys; 5 SHA-1 signatures; 1 GeneralizedTime before 2050; 1 serial that is not positive. To show the check is not vacuous, the same script run on the driver’s output with keyUsage changed wherever it is 96, and one notBefore changed, reports 86 differences.

The bundle is this machine’s, not committed: it changes with the system, and two of its 128 are a sandbox’s TLS inspection CAs. The committed corpus is §5.2’s.

5.2 The committed corpus, and one refusal per rule

scripts/x509_corpus.py made tests/vectors/x509/ once, with pyca/cryptography:

  • 10 good certificates. An RSA-3072 root (SHA-384), a P-384 intermediate under it with a path length of 0, name constraints and EKU, and a P-256 leaf from that (ECDSA-SHA384) with every kind of SAN entry. Also an RSA-2048 leaf signed with RSASSA-PSS that has a 20-byte serial with its top bit set; a P-521 certificate whose keyUsage needs a two-byte BIT STRING (decipherOnly); an Ed25519 certificate valid past 2050, so its notAfter is a GeneralizedTime; an RSA-4096 key with exponent 3, valid from 1999; a v1 certificate; and one certificate for each leniency. corpus.txt is the driver line pyca’s reading gives for each. For the two lenient ones, which pyca refuses or reports differently, it is the base certificate’s line with the flag set.
  • 36 damaged certificates. Each is made by editing one field of the P-256 leaf’s DER tree and re-encoding, so every other length stays right and the refusal is the rule named, not a truncation the edit caused. All 18 DER-reachable tags are covered, most more than once: for example x509-time by month 13, 29 February 2027, a UTCTime without seconds, and a fractional GeneralizedTime.

conformance/x509.rs checks the 10 lines on both backends, the 36 tags, and seven PEM blocks in one bundle: a good one, a bad character, padding after a whole group, a missing pad, 18,000 bytes into a 17,408-byte buffer, a good one again, and no END line. That shows a bad block is refused and the bundle goes on.

5.3 x509-limbo

x509-limbo is a suite of path-validation cases (#206’s), not parser cases, but each one carries certificates. scripts/x509_limbo.py parses every distinct one: 30,379 certificates from 9,802 cases (limbo.json with SHA-256 611e337b…acd4de, fetched on 2026-10-04).

  • 29,803 accepted and 576 refused, with no trap. Every refused certificate is used only by cases that expect FAILURE, with two exceptions: rfc5280::unknown-critical-extension-unrelated-root and -unrelated-intermediate expect SUCCESS because the certificate with the unknown critical extension is in the pool but not on the path. For #206: the trust store loader must skip a root it refuses, not fail the whole store, or those cases fail.
  • pyca loads every one of the 576, but it reads extensions lazily. Read, each refusal here is real:
    • 506 bettertls certificates have a SAN entry whose tag GeneralName does not allow. There are eleven tags, 46 certificates each: a primitive [0], [3], [4] or [5] (these are constructed in GeneralName), and [9] to [15] (which do not exist). They are refused as der-tag, and pyca refuses each SAN with UnexpectedTag when it is read.
    • 44 bettertls certificates have an iPAddress entry holding a DNS name or a URI (bad.example.com, http://…). They are refused as x509-name, and pyca refuses the same SAN as “not an IPv4 or IPv6 address”.
    • nc-permits-invalid-ip-san has an 8-byte iPAddress (a network, which belongs only in name constraints), and webpki::san::unicode-emoji-san has a non-ASCII dNSName. pyca reads both. These two are refused only here.
    • rfc5280::serial::negative’s certificate also has a critical cRLDistributionPoints, which is what refuses it here. Its negative serial alone would be accepted (§3.1).
  • No certificate that pyca refuses to load is accepted here.

Wycheproof has no certificate-parsing vectors; its ECDSA and RSA signature cases belong to #203 and #204.

5.4 A million damaged certificates

tests/programs/x509_fuzz.cho takes the corpus and runs rounds. Each round takes one certificate and damages it in one of six ways: 1 to 4 random bytes replaced, bits flipped, bytes set to the values DER lengths and tags are made of (0x00, 0x7f, 0x80 to 0x85, 0xff, 0x1f, 0x30, 0xa3, 0x01), a cut at a random length, a byte inserted or removed, or the PEM text damaged and decoded again. Each damaged input is parsed from a slice exactly its own length, so a read past the end would be a bounds trap, not a silent read. A round must answer one of the 20 codes. A trap ends the process, and the test fails on any exit status but 0.

1,000,000 rounds: no trap, no other code, on both backends, about 32 s each. The two backends gave identical counts:

okder-truncatedder-trailing-bytesder-non-minimal-lengthder-tagx509-timepemx509-signature-algorithm-mismatchx509-name
306,471345,59069,56150,10448,11641,53838,00934,87028,019

The rest: no block found 11,242, x509-structure 4,968, der-bit-string 4,792, x509-key 4,600, x509-critical-extension 4,433, der-indefinite-length 3,521, der-boolean 2,218, x509-version 1,580, der-integer 309, x509-duplicate-extension 59. A mutation that only touches a signature, key or string still parses, which is why “ok” is the second-largest count. der-too-deep and x509-too-large are never reached by mutation, so §5.2 covers them. The conformance test runs 20,000 rounds of the same harness.

5.5 Cost

The driver over all 30,379 limbo certificates, minus a run over the same bytes with nothing to decode, comes to 11.2 µs a certificate on LLVM and 18.3 µs on Cranelift. That figure includes base64 decoding and printing every field in hex, so the parse alone costs less. A TLS handshake parses 2 to 4 certificates.


6. Found

  • A v2 certificate was reported as v3. The version field’s value was checked, but the view was then given 3 whatever it said, so a v2 certificate with extensions was accepted. Found by reading the code while writing §5.2’s cases. Fixed (v + 1), and the “extensions in a v2 certificate” case now fails on the old code.
  • One oversized PEM block ended the bundle. limbo’s pathological::nc-dos-* roots are about 55 KB. pem_next refused the first as pem and gave no position to continue from, so the driver stopped at certificate 119 of 30,379. Now an oversized block is x509-too-large, and info[1] is past its END line on any refusal.
  • A claim made without measuring. “Some of the system roots” write an explicit DEFAULT FALSE: none of the 128 does (§3.2). “Two of the system roots” have an early GeneralizedTime: one does.

7. Not done

  • Signature verification, chain building, name matching and name constraints. Built in #206, beside this parser: docs/x509-verify.md.
  • Publishing the package to a store. #205, its first user.
  • The independent review. #209.