Add an allocation-free X.509 view parser and lazy X509 representation - #3450
Draft
jakemas wants to merge 2 commits into
Draft
Add an allocation-free X.509 view parser and lazy X509 representation#3450jakemas wants to merge 2 commits into
jakemas wants to merge 2 commits into
Conversation
jakemas
force-pushed
the
x509-c-view-parser
branch
from
August 26, 2026 06:08
b922ea4 to
56b00c7
Compare
jakemas
force-pushed
the
x509-c-view-parser
branch
from
August 26, 2026 06:18
56b00c7 to
8a81d08
Compare
jakemas
force-pushed
the
x509-c-view-parser
branch
2 times, most recently
from
August 26, 2026 06:38
9378afe to
5d29d22
Compare
Contributor
|
🔒 Security Review — View Report Please review before merging. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3450 +/- ##
==========================================
+ Coverage 78.06% 78.27% +0.20%
==========================================
Files 700 702 +2
Lines 124704 126583 +1879
Branches 17325 17591 +266
==========================================
+ Hits 97356 99083 +1727
- Misses 26481 26630 +149
- Partials 867 870 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jakemas
force-pushed
the
x509-c-view-parser
branch
3 times, most recently
from
August 26, 2026 15:51
afc8ad1 to
44a010d
Compare
Certificate parsing is dominated by ASN.1 object construction rather than by
reading DER. The template decoder allocates an object for nearly every field,
x_name.c builds a second name representation and canonicalizes every string,
and x_pubkey.c re-encodes the parsed X509_PUBKEY only to re-parse it with
EVP_parse_public_key. Most callers never touch most of those objects: a 1.4 KB
leaf costs 202 allocations and roughly 10 us to parse and free. Consumers
notice; the s2n-tls metrics subscriber hand-rolled a Rust parser rather than
call into AWS-LC.
Add x509_parse_der_view, a single-pass, allocation-free, non-recursive DER
validator that records {offset, length} ranges into the retained CRYPTO_BUFFER,
and a view-backed X509 state that defers legacy object construction until a
field is requested. i2d_X509, X509_digest, X509_verify, X509_get0_pubkey and
x509v3_cache_extensions read the retained bytes directly. Mutating APIs call
x509_ensure_legacy, which decodes once and moves already-materialized fields
into the legacy graph so previously returned pointers stay valid. X509_it
becomes an ASN1_ITYPE_EXTERN item delegating to an internal X509_LEGACY
ASN1_SEQUENCE_ref item, so generic ASN1_item_d2i callers route through the same
implementation while reference counting and the ASN1_AUX callbacks keep working.
Parse and free drops from 10.01 us to 0.242 us and from 202 allocations to one
on a 1402-byte leaf. The win comes from not building the object graph, so it
narrows to roughly 1.5x once a caller needs a legacy X509_NAME, which is still
the next bottleneck.
There is no build option: the parsed view is unconditional, so struct x509_st
has one layout in every translation unit and there is no untested build
configuration. Compatibility rests on the view parser's accepted language being
a subset of the legacy decoder's. On reject, d2i_X509 falls back to the legacy
decoder, so acceptance is unchanged; on accept, every deferred decode must also
succeed. fuzz/cert.cc asserts that invariant directly.
Because a lazily materialized field can fail to decode, getters that could not
previously fail can now return NULL, and the extension accessors report failure
rather than reporting zero extensions. Callers were hardened to fail closed,
including cert_crl, which now reports X509_V_ERR_OUT_OF_MEM rather than leaving
a failure indistinguishable from "not revoked".
x509_verify_view_signature keyed its ERR_R_EVP_LIB push off ctx.pctx, which is non-NULL after a failed x509_digest_verify_init as well as after a failed EVP_DigestVerify. That clobbered the specific error the init path had already set (unsupported algorithm OID, invalid key type) with a generic one. Match ASN1_item_verify exactly: let an init failure propagate its own error and append ERR_R_EVP_LIB only when EVP_DigestVerify fails.
jakemas
force-pushed
the
x509-c-view-parser
branch
from
August 27, 2026 02:26
5a058c3 to
cc15f93
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context and motivation
Parsing a certificate with AWS-LC is dominated by object construction, not by reading DER.
d2i_X509expands the ASN.1 templates incrypto/asn1/tasn_dec.cand allocates a C object for nearly every sequence, string, integer, OID, name entry, and extension;crypto/x509/x_name.cbuilds a second name representation and canonicalizes every string;crypto/x509/x_pubkey.cre-encodes the parsedX509_PUBKEYonly to re-parse it withEVP_parse_public_key. Most callers never touch most of those objects. A 1.4 KB leaf costs 202 allocations and roughly 10 µs to parse and free.This is visible to consumers. The s2n-tls metrics subscriber hand-rolled a Rust X.509 parser rather than call into AWS-LC, citing roughly 25× (rough numbers, 2026-04-17: webpki 0.333 µs without key/sig, s2n-codec 0.222 µs, x509-parser 5.7 µs, x509-cert 13.8 µs, aws-lc 21.7 µs).
The goal is to stop building the object graph at parse time while keeping the public API and ABI unchanged. Behavior is preserved except for two deliberate changes, documented under review considerations.
Description of changes
Adds an allocation-free, single-pass certificate validator and a lazy
X509representation. There is no build option: the parsed view is unconditional, sostruct x509_sthas one layout everywhere and there is no untested build configuration. The compatibility path is chosen at run time ind2i_X509, not at build time.Offset-only view (
crypto/x509/x509_view.h).AWSLC_X509_CERTIFICATE_VIEWis a POD struct of{u32 offset, u32 length}ranges — one per top-level field, plus a summary of the nine extensions verification consults. No pointers, no destructor, so lifetime is explicit: C owns the retainedCRYPTO_BUFFERand every range is an offset into it. Layout is pinned by static asserts.Parser (
crypto/x509/x509_c_view_parser.c).x509_parse_der_viewwalks the certificate once, allocation-free and without recursion, recording ranges. It validates the outer grammar: tags, DER length minimality, integer minimality, BIT STRING unused bits, OIDs, time syntax, names, AlgorithmIdentifier, extension wrappers, version constraints, and absence of trailing data. ExtensionextnValuecontents stay lazily decoded, matching current behavior.View-backed
X509(crypto/x509/x_x509.c). Two states:X509_VIEW_STATE_EAGER, the existing representation used byX509_newfor issuance, andX509_VIEW_STATE_PARSED, a retained buffer plus inline view plus lazy caches with noX509_CINFgraph. Fields materialize individually throughx509_get_cached_*, so direct->cert_infoaccess moved behind accessors. A mutating API callsx509_ensure_legacy, which decodes the buffer once and moves already-materialized fields into the legacy graph, so pointers handed out earlier stay valid.Paths that skip materialization.
i2d_X509andX509_digestread the retained bytes;X509_verifyverifies over the originalTBSCertificaterange;X509_get0_pubkeyhands the SPKI range toEVP_parse_public_keywithout the re-encode;x509v3_cache_extensionsuses the extension summary rather than buildingX509_EXTENSIONobjects.Deliberately no build knob. Gating this behind an option means the macro has to reach
crypto/x509/internal.h, which makesstruct x509_st's layout depend on build configuration — and plenty of tooling preprocesses AWS-LC internal headers without the CMake include path, including the symbol registry checker (cc -E -P -I include -I .), ABI diffing, and aws-lc-rs's own build of the vendored tree. Any of those would then see a different struct than the library. BoringSSL avoids build-time configuration that reshapes a type for exactly this reason; where OpenSSL does generate a config header it installs it into the public include directory, and AWS-LC's owninclude/openssl/base.his generated and tracked so everything can find it. Keeping the representation unconditional removes the hazard outright and means the existing CI matrix exercises the real path rather than a disabled one.Compatibility rests on one invariant: the view parser's accepted language is a subset of the legacy decoder's. If the view parser rejects an input,
d2i_X509falls back to the legacy decoder, so acceptance is unchanged. If it accepts, every deferred legacy decode must also succeed, or a certificate could parse and then fail on field access.fuzz/cert.ccasserts this directly. Inputs that currently take the fallback include an explicitly encodedcritical FALSE,critical TRUEspelled0x01, a UTCTime with a timezone offset, an unsorted multi-valued RDNSET OF, and an empty RDNSET.X509_itbecomes anASN1_ITYPE_EXTERNitem whose callbacks delegate to an internalX509_LEGACYASN1_SEQUENCE_refitem, which keeps reference counting and theASN1_AUXcallbacks working. Its purpose is to make generic ASN.1 use ofX509correct against view-backed objects:x509_ex_d2icallsx509_ensure_legacybefore reusing an object, andx509_ex_i2dserves an untagged encode from the retained bytes. Decode through the generic item still takes the legacy eager path, so a certificate embedded in an OCSP response or PKCS#7 blob does not get the view, and onlyd2i_X509andX509_parse_from_bufferapply the view parser's stricter grammar. Narrowing that difference is follow-up.d2i_X509also records why it fell back, exposed to tests throughx509_view_fallback_count_for_testing, so fallback frequency can be measured against a corpus rather than guessed at.Testing
24 new
X509ViewParserTestcases incrypto/x509/x509_view_test.cccover selective materialization, mutation after materialization, retryable materialization failure, comparison behavior on cache failure, concurrent first materialization, and both directions of the subset invariant. Thecertfuzzer gained the subset assertion and 13 corpus entries built from the public Amazon Root CA 1 / Amazon RSA 2048 M04 /sqs.us-east-2.amazonaws.comchain.Pre-submit on Apple arm64: full
crypto_testpasses (2942 passed, 2 skipped, the 2 being unrelated entropy tests); 3M mutated inputs through the parser under ASan+UBSan with every returned range bounds-checked, no findings; 2M mutated inputs across a 2,880-certificate corpus with no case where the view parser accepted an input the legacy decoder rejected.No benchmark harness is added here. AWS-LC benchmarks live in
tool/speed.cc(bssl speed), and adding an X.509 case there is left as follow-up so results feed the existing continuous tracking rather than a one-off binary. The numbers below come from a local harness run against baselinemainand this branch.Performance. One machine (Apple arm64, macOS 26.6, Apple clang,
Release), median of 7 runs, allocations viaCRYPTO_set_mem_functions, baselinemainat1f371a8d9. Not comparable to the s2n-tls table above — different machine, different certificates.crypto/ocsp/test/aws/ocsp_cert.pem, 1402-byte RSA-2048 leaf:X509_parse_from_buffer+ freei2d_X509d2i_X509+ freeX509_get0_pubkeyX509_cmpX509_NAMEX509_verifycrypto/ocsp/test/ND1_Cross_Root.pem, 1082-byte self-signed RSA-2048 root (real verify):X509_parse_from_buffer+ freei2d_X509X509_get0_pubkeyX509_verifyX509_NAMEX509_cmpcrypto/x509/test/some_names1.pem, 10 KB name-heavy certificate:X509_parse_from_buffer+ freeX509_get0_pubkeyX509_verifyX509_NAMEX509_cmpValidator cost alone, with no
X509object: 0.070 µs (317 B), 0.127 µs (1082 B), 0.147 µs (1402 B), 2.33 µs (10 KB). Of the 0.250 µs parse-and-free, roughly 60% is DER validation and the rest isX509bookkeeping — one 632-byte allocation, mutex init, ex-data init, buffer ref.Review considerations
No public API or ABI change;
struct x509_stis private and only libcrypto allocates it. Two deliberate behavior changes are documented at the end of this section. X.509 is outside the FIPS boundary.The speedup comes from not building the object graph, so it decays as soon as a caller needs a legacy
X509_NAME— the only public way to read a name is to materialize one, at 133 allocations. Taking the exact field set the s2n-tls metrics subscriber extracts (serial bytes, issuer CN, subject CN, key type, signature algorithm) on the 1402-byte leaf:main, public APIThe last row is not reachable through any public API here; it is what the same view supports if the ranges are exposed. So this change alone does not close the gap for that consumer. Likely follow-up is an accessor returning borrowed ranges (serial bytes, issuer/subject DER, an RDN attribute by NID, SPKI and signature
AlgorithmIdentifier) instead of legacy objects, after whichx_name.ccanonicalization andx509v3_cache_extensionsare the next bottlenecks.Two documented behavior changes. First, the extension accessors (
X509_get_ext_count,X509_get_ext_by_NID,X509_get_ext_by_OBJ,X509_get_ext_by_critical,X509_get_ext,X509_get0_extensions) now report failure when extension decoding fails rather than reporting zero extensions. Second, because a lazily materialized field can fail to decode, getters that previously could not fail can now return NULL, so callers were hardened to fail closed. That reaches slightly outsidecrypto/x509:ASN1_INTEGER_cmpreturns-2for a NULL operand instead of dereferencing it,OCSP_cert_to_idandpkcs7_cmp_ribail out, and incert_crla materialization failure is reported asX509_V_ERR_OUT_OF_MEMrather than being indistinguishable from "not revoked" — the one place where treating NULL as success would have been a soundness bug.Focused review is most valuable on the subset invariant, the accessor migration across
crypto/x509/, and thex509_ensure_legacyownership transfer.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license and the ISC license.