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
| File | What |
|---|---|
packages/x509/x509.cho, module x509 | tlv (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.cho | a PEM bundle in, one line per certificate out, with every field §5.1 compares; or hex DER lines in |
tests/programs/x509_fuzz.cho | the fuzz harness (§5.4) |
crates/cancho/tests/conformance/x509.rs | the 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.py | any PEM bundle against pyca/cryptography, field by field (§5.1) |
scripts/x509_corpus.py | made tests/vectors/x509/ (§5.2) |
scripts/x509_limbo.py | every certificate in x509-limbo (§5.3) |
scripts/x509_oids.py | prints 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].
| Slots | Name | Holds |
|---|---|---|
| 0, 1 | tbs_start, tbs_end | the whole TBSCertificate TLV: the bytes the issuer signed |
| 2 | signature_algorithm | the outer AlgorithmIdentifier’s OID code (§2.3; 0 for one not in the table) |
| 3, 4 | signature_params_start, _end | its parameters (empty when absent; RSASSA-PSS has some) |
| 5, 6 | signature_start, _end | the signature’s bits, after the unused-bits byte (which must be 0) |
| 7, 8 | serial_start, _end | the serial’s INTEGER content |
| 9, 10; 11, 12 | issuer_*, subject_* | the whole Name TLVs, for byte comparison in chain building |
| 13, 14 | not_before, not_after | seconds since 1970, UTC |
| 15, 16 | spki_start, _end | the whole SubjectPublicKeyInfo |
| 17, 18 | key_algorithm, key_curve | OID codes; the curve is 0 unless the key is EC |
| 19, 20 | key_start, _end | the key’s bits: an RSAPublicKey, an EC point, or 32 Ed25519 bytes |
| 21 | version | 1, 2 or 3 |
| 22, 23 | is_ca, path_len | basicConstraints: -1 absent, else 0 or 1; the path length or -1 |
| 24, 25 | key_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, 27 | san_start, _end | subjectAltName’s GeneralNames content; 0, 0 when absent |
| 28, 29 | name_constraints_* | nameConstraints’ content, unread here (#206 reads it) |
| 30 to 33 | ski_*, aki_* | the subject key identifier, and the authority’s keyIdentifier |
| 34 | extension_count | |
| 35 | leniency | lenient_* flags (§3.2) |
| 36 to 39 | rsa_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
| Extension | Read | Critical allowed |
|---|---|---|
| basicConstraints | cA, pathLenConstraint (non-negative, under 2^31) | yes |
| keyUsage | the BIT STRING, with its unused bits zero | yes |
| extendedKeyUsage | at least one OID, as flags | yes |
| subjectAltName | at least one entry, at most 1,024; a dNSName, rfc822Name or URI is ASCII; an iPAddress is 4 or 16 bytes | yes |
| nameConstraints | located, for #206 | yes |
| subjectKeyIdentifier, authorityKeyIdentifier | located | no |
| any other | its DER checked by the whole-certificate walk | no |
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
0x00or0xff, 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
| Limit | Value | Refusal |
|---|---|---|
| a certificate’s DER | 16,384 bytes (docs/tls-pure.md §5.2) | x509-too-large, before anything is read |
| a PEM block’s decoded bytes | the caller’s buffer | x509-too-large, and the bundle goes on |
| extensions | 32 | x509-too-large |
| subjectAltName entries | 1,024 | x509-too-large |
| DER nesting | 24 | der-too-deep |
4. Refusals
| Code | Tag | When |
|---|---|---|
| -1 | der-truncated | a length runs past its parent |
| -2 | der-trailing-bytes | bytes after the certificate |
| -3 | der-indefinite-length | a 0x80 length |
| -4 | der-non-minimal-length | a length not in its fewest bytes |
| -5 | der-tag | the high-tag-number form, or a tag where another was required |
| -6 | der-too-deep | nesting past 24 |
| -7 | der-integer | a non-minimal INTEGER, a serial over 21 bytes, a negative path length |
| -8 | der-boolean | a BOOLEAN that is not 0x00 or 0xff |
| -9 | der-bit-string | a bad unused-bits count, or nonzero unused bits |
| -10 | x509-structure | a field missing, extra or empty where RFC 5280 says it is not |
| -11 | x509-version | a version other than v1 to v3, or extensions (or unique IDs) in a version that has none |
| -12 | x509-time | not YYMMDDHHMMSSZ or YYYYMMDDHHMMSSZ, or a date that does not exist |
| -13 | x509-signature-algorithm-mismatch | the TBSCertificate’s algorithm is not the outer one, byte for byte (RFC 5280 §4.1.1.2) |
| -14 | x509-duplicate-extension | the same extension twice |
| -15 | x509-critical-extension | a critical extension that is not read here (§2.2) |
| -16 | x509-too-large | §3.3 |
| -17 | x509-name | an empty RDN, an attribute without a value, a non-ASCII DNS name, an IP address of the wrong length |
| -18 | pem | no END line, a character outside base64, wrong padding |
| -19 | x509-key | an 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.txtis 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-timeby 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-rootand-unrelated-intermediateexpect 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 asder-tag, and pyca refuses each SAN withUnexpectedTagwhen it is read. - 44 bettertls certificates have an iPAddress entry holding a DNS name or a URI (
bad.example.com,http://…). They are refused asx509-name, and pyca refuses the same SAN as “not an IPv4 or IPv6 address”. nc-permits-invalid-ip-sanhas an 8-byte iPAddress (a network, which belongs only in name constraints), andwebpki::san::unicode-emoji-sanhas 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).
- 506 bettertls certificates have a SAN entry whose tag GeneralName does not allow. There are eleven tags, 46 certificates
each: a primitive
- 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:
| ok | der-truncated | der-trailing-bytes | der-non-minimal-length | der-tag | x509-time | pem | x509-signature-algorithm-mismatch | x509-name |
|---|---|---|---|---|---|---|---|---|
| 306,471 | 345,590 | 69,561 | 50,104 | 48,116 | 41,538 | 38,009 | 34,870 | 28,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_nextrefused the first aspemand gave no position to continue from, so the driver stopped at certificate 119 of 30,379. Now an oversized block isx509-too-large, andinfo[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.