Pretty much everything I deal with requires parsing ASN.1 encodings. ASN.1 definitions published as part of internet RFCs: certificates are encoded using DER, LDAP exchanges use BER, Kerberos packets are using DER as well. ASN.1 use is a never ending source of security issues in pretty much all applications. Having safer ASN.1 processing is important to any application developer.

In FreeIPA we are using three separate ASN.1 libraries: pyasn1 and x509 (part of PyCA) for Python code, and asn1c code generator for C code. In fact, we use more: LDAP server plugins also use OpenLDAP’s lber library, while Kerberos KDC plugins also use internal MIT Kerberos parsers.

The PyCA developers noted in their State of OpenSSL statement:

[…] when pyca/cryptography migrated X.509 certificate parsing from OpenSSL to our own Rust code, we got a 10x performance improvement relative to OpenSSL 3 (n.b., some of this improvement is attributable to advantages in our own code, but much is explainable by the OpenSSL 3 regressions). Later, moving public key parsing to our own Rust code made end-to-end X.509 path validation 60% faster — just improving key loading led to a 60% end-to-end improvement, that’s how extreme the overhead of key parsing in OpenSSL was.

That’s 16x performance improvement over the OpenSSL 3. OpenSSL did improve their performance since then but it still pays an overhead for a very flexible design to allow loading cryptographic implementations from dynamic modules (providers). Enablement for externally-provided modules is essential to allow adding new primitives and support for government-enforced standards (such as FIPS 140) where implementations have to be validated in advance and code changes cannot come without expensive and slow re-validation process.

Nevertheless, in FreeIPA we focus on integrating with Linux distributions. Fedora, CentOS Stream, and RHEL enforce crypto consolidation rules, where all packaged applications must be using the same crypto primitives provided by the operating system. We can process metadata ourselves but all cryptographic operations still have to go through OpenSSL and NSS. And paying large performance costs during metadata processing would be hurting to infrastructure components such as FreeIPA.

FreeIPA is a large beast. Aside from its management component, written in Python, it has more than a dozen plugins for 389-ds LDAP server, plugins for MIT Kerberos KDC, plugins for Samba, and tight integration with SSSD, all written in C. Its default certificate authority software, Dogtag PKI, is written in Java and relies on own stack of Java and C dependencies. We are using PyCA’s x509 module for certificate processing in Python code but we cannot use it and underlying ASN.1 libraries in C as those libraries aren’t exposed to C applications or intentionally limited in their functionality to PKI-related tasks.

For the 2026-2028, I’m focusing on enabling FreeIPA to handle post-quantum cryptography (PQC), as a part of the Quantum-Resistant Cryptography in Practice (QARC) project. The project is funded by the European Union under the Horizon Europe framework programme (Grant Agreement No. 101225691) and supported by the European Cybersecurity Competence Centre. One of well publicized aspects of moving to PQC certificates is their sizes. The following table 5 is from Post-Quantum Cryptography for Engineers IETF draft summarizes it well:

PQ Security Level Algorithm Public key size (bytes) Private key size (bytes) Signature size(bytes)
Traditional RSA2048 256 256 256
Traditional ECDSA-P256 64 32 64
1 FN-DSA-512 897 1281 666
2 ML-DSA-44 1312 2560 2420
3 ML-DSA-65 1952 4032 3309
5 FN-DSA-1024 1793 2305 1280
5 ML-DSA-87 2592 4896 4627

Public keys for ML-DSA-65 certificates 7.6x bigger than RSA-2048 ones. You need to handle public keys in multiple situations: when performing certificates’ verification against known certificate authorities (CAs), when matching their properties for validation and identity derivation during authorization, when storing them. FreeIPA uses LDAP as a backend, so storing 7.6 times more data directly affects your scalability when number of users or machines (or Kerberos services) grow up. And since certificates are all ASN.1 encoded, I naturally wanted to establish a performance baseline to ASN.1 parsing.

Synta, ASN.1 library

I started with a small task: created a Rust library, synta, to decode and encode ASN.1 with the help of AI tooling. It quickly grew up to have its own ASN.1 schema parser and code generation tool. With those in place, I started generating more code, this time to process X.509 certificates, handle Kerberos packet structures, and so on. Throwing different tasks at Claude Code led to iterative improvements. Over couple months we progressed to a project with more than 60K lines of Rust code.

Language files blank comment code
Rust 207 9993 17492 67284
Markdown 52 5619 153 18059
Python 41 2383 2742 7679
C 17 852 889 4333
Bourne Shell 8 319 482 1640
C/C++ Header 4 319 1957 1138
TOML 20 196 97 896
YAML 1 20 46 561
make 4 166 256 493
CMake 3 36 25 150
JSON 6 0 0 38
diff 1 6 13 29
SUM 364 19909 24152 102300

I published some of the synta crates yesterday on crates.io, the whole project is available at codeberg.org/abbra/synta. In total, there are 11 crates, though only seven are published (and synta-python is also available at PyPI):

Crate Lines (src/ only)
synta 10572
synta-derive 2549
synta-codegen 17578
synta-certificate 4549
synta-python 8953
synta-ffi 7843
synta-krb5 2765
synta-mtc 7876
synta-tools 707
synta-bench 0
synta-fuzz 3551

Benchmarking, fuzzer, and tools aren’t published. They only needed for development purposes.

Performance

The numbers below were obtained on Lenovo ThinkPad P1 Gen 5, 12th Gen Intel(R) Core(TM) i7-12800H, 64 GB RAM, on Fedora 42. This is pretty much a 3-4 years old hardware.

Benchmarking is what brought this project to life, let’s look at the numbers. When dealing with certificates, ASN.1 encoding can be parsed in different ways: you can visit every structure or stop at outer shells and only visit the remaining nested structures when you really need them. The former is “parse+fields” and the latter is “parse-only” in the following table that summarizes comparison between synta and various Rust crates (and OpenSSL/NSS which were accessible through their Rust FFI bindings):

Library Parse-only Parse+fields vs synta (parse-only) vs synta (parse+fields)
synta 0.48 µs 1.32 µs
cryptography-x509 1.45 µs 1.43 µs 3.0× slower 1.1× slower
x509-parser 2.01 µs 1.99 µs 4.2× slower 1.5× slower
x509-cert 3.16 µs 3.15 µs 6.6× slower 2.4× slower
NSS 7.90 µs 7.99 µs 16× slower 6.1× slower
rust-openssl 15.4 µs 15.1 µs 32× slower 11× slower
ossl 16.1 µs 15.8 µs 33× slower 12× slower

“Parse+fields” tests access every named field: serial number, issuer/subject DNs, signature algorithm OID, signature bytes, validity period, public key algorithm OID, public key bytes, and version. The “parse+fields” speedup is the fair end-to-end comparison: synta’s parse-only advantage is large because most fields are stored as zero-copy slices deferred until access, while other libraries must materialise all fields eagerly at parse time.

The dominant cost in X.509 parsing is Distinguished Name traversal: a certificate’s issuer and subject each contain a SEQUENCE OF SET OF SEQUENCE with per-attribute OID lookup. synta defers this entirely by storing the Name as a RawDer<'a> — a pointer+length into the original input with no decoding. cryptography-x509 takes a similar deferred approach. The nom-based and RustCrypto libraries decode Names eagerly. NSS goes further and formats them into C strings, which is the dominant fraction of its 16× parse overhead.

For benchmarking I used certificates from PyCA test vectors. There are few certificates with different properties, so we parse them multiple times and then average numbers:

Certificate synta cryptography-x509 x509-parser x509-cert NSS
cert_00 (NoPolicies) 1333.7 ns 1386.7 ns 1815.9 ns 2990.6 ns 7940.3 ns
cert_01 (SamePolicies-1) 1348.8 ns 1441.0 ns 2033.4 ns 3174.3 ns 7963.8 ns
cert_02 (SamePolicies-2) 1338.6 ns 1440.1 ns 2120.1 ns 3205.6 ns 8206.8 ns
cert_03 (anyPolicy) 1362.4 ns 1468.3 ns 2006.2 ns 3194.5 ns 7902.4 ns
cert_04 (AnyPolicyEE) 1232.9 ns 1424.7 ns 1968.6 ns 3168.1 ns 7913.1 ns
Average 1323 ns 1432 ns 1989 ns 3147 ns 7985 ns

The gap between synta (1.32 µs) and cryptography-x509 (1.43 µs) is tighter here than in parse-only (3.0×) because synta’s field access includes two format_dn() calls (~800 ns combined) that cryptography-x509 does for effectively free (its offsets were computed at parse time). Synta leads by ~8% overall.

Now, when parsing PQC certificates, an interesting thing happens. First, it is faster to parse ML-DSA than traditional certificates.

Certificate synta cryptography-x509 x509-parser x509-cert NSS
ML-DSA-44 1030.9 ns 1256.4 ns 1732.2 ns 2666.0 ns 7286.9 ns
ML-DSA-65 1124.9 ns 1237.5 ns 1690.5 ns 2664.2 ns 7222.1 ns
ML-DSA-87 1102.6 ns 1226.5 ns 1727.2 ns 2696.6 ns 7284.6 ns
Average 1086 ns 1240 ns 1717 ns 2675 ns 7265 ns

synta’s ML-DSA parse+fields (1.09 µs) is faster than its traditional parse+fields (1.32 µs) because ML-DSA test certificates have shorter Distinguished Names (one attribute each in issuer and subject vs multiple attributes in traditional certificates in the test above). The signature BIT STRING — which is 2,420–4,627 bytes for ML-DSA — is accessed as a zero-copy slice with no size-dependent cost.

Processing CA databases

Imaging your app needs to test whether the certificate presented by a client is known to you (e.g. belongs to a trusted CAs set). A library like OpenSSL looks at the client’s certificate, extracts identifiers of the certificate issuer, looks up whether such issuer is known in the CA database. That would require looking up properties of the certificates in the database. The fast we can do that, the better.

All those numbers in the previous section are for a single certificate being parsed millions of times. In a real app we often need to validate the certificate against a system-wide database of certificate authorities. The database used by Fedora and other Linux distributions comes from Firefox. It contains 180 self-signed root CA certificates for all public CAs with diverse key types (RSA 2048/4096, ECDSA P-256/P-384) and DN structures. The median cert by DER size is “Entrust.net Premium 2048 Secure Server CA” (1,070 bytes); the benchmark uses this cert for single-certificate and field-access sub-benchmarks to get stable results that are not sensitive to certificate-size outliers.

Another data I tried to benchmark against is 9,898 certificates from the Common CA Database (CCADB), covering the full multi-level hierarchy used by Mozilla, Chrome, Apple, and Microsoft:

Depth Count Description
0 919 Root CAs (self-signed)
1 6,627 Intermediates issued directly by roots
2 2,212 Two levels deep
3 137 Three levels deep
4 3 Four levels deep

Intermediate CA certificates tend to have more complex DNs and more extensions than the root CAs in the Mozilla store. The CCADB median cert is “Bayerische SSL-CA-2014-01” (10,432 bytes). These certificates from CCADB cover past 30 years of certificate issuance on the internet.

To see how those benchmarks would behave if CA roots database would be built with post quantum cryptography, I rebuilt the CCADB corpus as ML-DSA certificates. Nine CCADB certificates were skipped: OpenSSL’s x509 -x509toreq -copy_extensions copy step failed to convert them to CSR form, typically because those certs use non-standard DER encodings or critical extensions that the x509toreq pipeline cannot copy into a PKCS#10 request. (The failures are in OpenSSL’s cert→CSR conversion; synta parses all 9,898 original CCADB certs without error.) This leaves 9,889 of the original 9,898 certs in the synthetic database.

The median cert by DER size is “TrustCor Basic Secure Site (CA1)” (6,705 bytes). ML-DSA certs range from 5,530 B to 16,866 B; the distribution is shifted left relative to the CCADB RSA/ECDSA median (10,432 B) because the smallest CCADB certs (compact root CAs with few extensions) become the new median position after ML-DSA key replacement enlarges all certs uniformly.

Benchmark Library Dataset Time Throughput
synta_parse_all synta Mozilla (180 certs) 87.8 µs 2.0 M/sec
nss_parse_all NSS Mozilla (180 certs) 1.577 ms 114 K/sec
openssl_parse_all rust-openssl Mozilla (180 certs) 3.552 ms 50.7 K/sec
ossl_parse_all ossl Mozilla (180 certs) 3.617 ms 49.8 K/sec
synta_parse_and_access synta Mozilla (180 certs) 261 µs 690 K/sec
synta_build_trust_chain synta Mozilla (180 certs) 11.6 µs
synta_parse_all synta CCADB (9,898 certs) 5.10 ms 1.94 M/sec
nss_parse_all NSS CCADB (9,898 certs) 106 ms 93 K/sec
openssl_parse_all rust-openssl CCADB (9,898 certs) 203 ms 48.8 K/sec
ossl_parse_all ossl CCADB (9,898 certs) 214 ms 46.3 K/sec
synta_parse_and_access synta CCADB (9,898 certs) 16.1 ms 615 K/sec
synta_parse_roots synta CCADB (919 roots) 457.7 µs 2.01 M/sec
synta_parse_intermediates synta CCADB (8,979 intermediates) 4.735 ms 1.90 M/sec
synta_build_dependency_tree synta CCADB (9,898 certs) 559 µs
synta_parse_all synta ML-DSA synth (9,889 certs) 5.78 ms 1.71 M/sec
nss_parse_all NSS ML-DSA synth (9,889 certs) 103 ms 96.4 K/sec
openssl_parse_all rust-openssl ML-DSA synth (9,889 certs) 239 ms 41.4 K/sec
ossl_parse_all ossl ML-DSA synth (9,889 certs) 256 ms 38.6 K/sec
synta_parse_and_access synta ML-DSA synth (9,889 certs) 17.5 ms 566 K/sec
synta_parse_roots synta ML-DSA synth (919 roots) 463 µs 1.98 M/sec
synta_parse_intermediates synta ML-DSA synth (8,970 ints.) 5.10 ms 1.76 M/sec
synta_build_dependency_tree synta ML-DSA synth (9,889 certs) 549 µs

NSS is 18–21× slower than synta across all three datasets; rust-openssl is 40–41× slower and ossl is 41–44× slower. All three C-backed libraries successfully parse ML-DSA certificates (NSS 3.120+ and OpenSSL 3.4+ support ML-DSA natively). NSS’s absolute parse time is nearly identical across CCADB traditional certs (106 ms) and ML-DSA synthetic certs (103 ms) — confirming that NSS’s dominant cost is eager DN formatting at parse time, which depends on DN attribute count rather than the signature algorithm. The slightly lower relative slowdown for NSS on ML-DSA (18× vs 21×) is entirely because synta is slower on ML-DSA (5.78 ms vs 5.10 ms), not because NSS is faster.

synta’s throughput is consistent at ~1.7–2.0 M certs/sec across all three datasets, confirming linear O(n) scaling. Parse rate is slightly lower for the ML-DSA synthetic hierarchy (1.71 M/sec) than for the CCADB traditional hierarchy (1.94 M/sec) because the larger ML-DSA SubjectPublicKeyInfo and signature BIT STRING fields add bytes to the tag+length-header scan that synta performs at parse time. The intermediates-only sub-benchmark is slightly lower than roots-only in each dataset (1.76 M/sec vs 1.98 M/sec for ML-DSA; 1.90 M/sec vs 2.01 M/sec for CCADB) because intermediate CAs tend to have more complex DNs and extension lists.

Finally, individual property access for a pre-parsed certificate, single field read, no allocation unless noted:

Field Mozilla (1,070 B) CCADB (10,432 B) ML-DSA (6,705 B) Notes
issuer_raw / subject_raw 4.1 / 4.1 ns 4.2 / 4.1 ns 4.5 / 4.4 ns Zero-copy slice
public_key_bytes / signature_bytes 4.1 / 4.1 ns 4.2 / 4.2 ns 4.6 / 4.4 ns Zero-copy slice
signature_algorithm / public_key_algorithm 5.9 / 5.4 ns 5.9 / 5.5 ns 6.3 / 6.4 ns OID → &'static str
serial_number 10.9 ns 6.8 ns 7.5 ns Integer → i64, length-dependent
validity 180 ns 206 ns 231 ns Two time-string allocations
issuer_dn 401 ns 224 ns 246 ns format_dn()String
subject_dn 404 ns 292 ns 324 ns format_dn()String

Zero-copy fields (issuer_raw, subject_raw, public_key_bytes, signature_bytes) cost ~4–5 ns — the price of reading a pointer and length from a struct field. The slightly higher cost for CCADB and ML-DSA fields vs Mozilla is within measurement noise.

identify_signature_algorithm() and identify_public_key_algorithm() match the OID component array against a static table and return &'static str — no allocation, no string formatting. The ~5–6 ns cost is a few comparisons and a pointer return.

serial_number cost depends on the integer’s byte length: the Entrust Mozilla cert carries a 16-byte serial number (parsed via SmallVec<[u8; 16]>), while the CCADB and ML-DSA synthetic medians have shorter serials. At 10.9, 6.8, and 7.5 ns respectively, all are negligible.

validity (~180–231 ns) allocates two strings: UTCTime and GeneralizedTime are formatted from their raw DER bytes into owned Strings. The two calls account for essentially all of the cost; the YYMMDDHHMMSSZ to RFC 3339 formatting is the dominant work.

format_dn() is the most variable field: it walks the Name DER bytes, decodes each SEQUENCE OF SET OF SEQUENCE, looks up each attribute OID by name, and formats the result into an owned String. The Mozilla cert’s issuer DN is more complex (multiple attributes, longer values: 401 ns) than the CCADB median (224 ns) or the ML-DSA synthetic median (246 ns). The ML-DSA synthetic median’s subject DN (324 ns) is slightly more expensive than the CCADB median (292 ns) because a different cert occupies the median position after key replacement. format_dn() cost is proportional to the DN’s attribute count and string lengths.

Why C Libraries Are Slower

CERT_NewTempCertificate (NSS) and OpenSSL’s d2i_X509 perform significantly more work per certificate than synta:

  1. Eager DN formatting — NSS formats the issuer and subject Distinguished Names into internal C strings during CERT_NewTempCertificate, even when the caller never reads them. Distinguished Name formatting is the single most expensive operation in certificate parsing; doing it unconditionally at parse time accounts for roughly 80% of NSS’s total parse cost. OpenSSL decodes DN structure eagerly as well.

  2. Arena and heap allocation — each NSS certificate allocates a PLArena block and copies the full DER buffer into it (copyDER = 1). OpenSSL allocates from the C heap. These allocations are additional work beyond decoding.

  3. Library state and locking — NSS acquires internal locks on every CERT_NewTempCertificate call to update the certificate cache, even when the resulting certificate is marked as temporary. This serialises concurrent parsing in multi-threaded applications.

  4. FFI boundary costs — the rust-openssl and ossl measurements include the overhead of crossing from Rust into the C library via extern "C" calls and pointer marshalling.

synta defers all of (1): issuer and subject are stored as RawDer<'a> (borrowed byte spans) and decoded only when the caller calls format_dn(). There is no locking, no arena, and no FFI boundary.

In these tests I also found out that PyCA’s cryptography-x509 doesn’t have optimizations for multiple accesses to the same fields. It is typically not a problem if you are just loading a certificate and use it once. If you have to return back to it multiple times, that becomes visible and hurts your performance. So I submitted a pull request to apply some of the optimizations I found with synta. The pull request had to be split into smaller ones and few of them were already merged, so performance to access issuer, subject, and public key in certificates and to some attributes in CSRs was improved 100x. The rest waits for improvements in PyO3 to save some of memory use.