Skip to content

Add an allocation-free X.509 view parser and lazy X509 representation - #3450

Draft
jakemas wants to merge 2 commits into
aws:mainfrom
jakemas:x509-c-view-parser
Draft

Add an allocation-free X.509 view parser and lazy X509 representation#3450
jakemas wants to merge 2 commits into
aws:mainfrom
jakemas:x509-c-view-parser

Conversation

@jakemas

@jakemas jakemas commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Context and motivation

Parsing a certificate with AWS-LC is dominated by object construction, not by reading DER. d2i_X509 expands the ASN.1 templates in crypto/asn1/tasn_dec.c and allocates a C object for nearly every sequence, string, integer, OID, name entry, and extension; crypto/x509/x_name.c builds a second name representation and canonicalizes every string; crypto/x509/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 µ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 X509 representation. There is no build option: the parsed view is unconditional, so struct x509_st has one layout everywhere and there is no untested build configuration. The compatibility path is chosen at run time in d2i_X509, not at build time.

Offset-only view (crypto/x509/x509_view.h). AWSLC_X509_CERTIFICATE_VIEW is 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 retained CRYPTO_BUFFER and every range is an offset into it. Layout is pinned by static asserts.

Parser (crypto/x509/x509_c_view_parser.c). x509_parse_der_view walks 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. Extension extnValue contents stay lazily decoded, matching current behavior.

View-backed X509 (crypto/x509/x_x509.c). Two states: X509_VIEW_STATE_EAGER, the existing representation used by X509_new for issuance, and X509_VIEW_STATE_PARSED, a retained buffer plus inline view plus lazy caches with no X509_CINF graph. Fields materialize individually through x509_get_cached_*, so direct ->cert_info access moved behind accessors. A mutating API calls x509_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_X509 and X509_digest read the retained bytes; X509_verify verifies over the original TBSCertificate range; X509_get0_pubkey hands the SPKI range to EVP_parse_public_key without the re-encode; x509v3_cache_extensions uses the extension summary rather than building X509_EXTENSION objects.

Deliberately no build knob. Gating this behind an option means the macro has to reach crypto/x509/internal.h, which makes struct 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 own include/openssl/base.h is 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_X509 falls 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.cc asserts this directly. Inputs that currently take the fallback include an explicitly encoded critical FALSE, critical TRUE spelled 0x01, a UTCTime with a timezone offset, an unsorted multi-valued RDN SET OF, and an empty RDN SET.

X509_it becomes an ASN1_ITYPE_EXTERN item whose callbacks delegate to an internal X509_LEGACY ASN1_SEQUENCE_ref item, which keeps reference counting and the ASN1_AUX callbacks working. Its purpose is to make generic ASN.1 use of X509 correct against view-backed objects: x509_ex_d2i calls x509_ensure_legacy before reusing an object, and x509_ex_i2d serves 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 only d2i_X509 and X509_parse_from_buffer apply the view parser's stricter grammar. Narrowing that difference is follow-up. d2i_X509 also records why it fell back, exposed to tests through x509_view_fallback_count_for_testing, so fallback frequency can be measured against a corpus rather than guessed at.

Testing

24 new X509ViewParserTest cases in crypto/x509/x509_view_test.cc cover selective materialization, mutation after materialization, retryable materialization failure, comparison behavior on cache failure, concurrent first materialization, and both directions of the subset invariant. The cert fuzzer 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.com chain.

Pre-submit on Apple arm64: full crypto_test passes (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 baseline main and this branch.

Performance. One machine (Apple arm64, macOS 26.6, Apple clang, Release), median of 7 runs, allocations via CRYPTO_set_mem_functions, baseline main at 1f371a8d9. Not comparable to the s2n-tls table above — different machine, different certificates.

crypto/ocsp/test/aws/ocsp_cert.pem, 1402-byte RSA-2048 leaf:

operation main view speedup allocations bytes requested
X509_parse_from_buffer + free 10.01 µs 0.250 µs 40× 202 → 1 8227 → 632
parse + i2d_X509 10.03 µs 0.262 µs 38× 202 → 1 8227 → 632
d2i_X509 + free 9.89 µs 0.313 µs 32× 203 → 3 9093 → 2066
parse + X509_get0_pubkey 9.77 µs 1.11 µs 8.8× 202 → 15 8227 → 2829
parse ×2 + X509_cmp 29.42 µs 14.81 µs 2.0× 432 → 302 20032 → 9592
parse + subject/issuer X509_NAME 9.66 µs 5.81 µs 1.7× 202 → 133 8227 → 4413
parse + X509_verify 51.47 µs 37.90 µs 1.4× 226 → 32 14773 → 6474

crypto/ocsp/test/ND1_Cross_Root.pem, 1082-byte self-signed RSA-2048 root (real verify):

operation main view speedup
X509_parse_from_buffer + free 10.46 µs 0.219 µs 48×
parse + i2d_X509 10.81 µs 0.236 µs 46×
parse + X509_get0_pubkey 10.86 µs 1.06 µs 10×
parse + X509_verify 22.95 µs 11.09 µs 2.1×
parse + subject/issuer X509_NAME 10.50 µs 7.22 µs 1.5×
parse ×2 + X509_cmp 37.54 µs 24.25 µs 1.5×

crypto/x509/test/some_names1.pem, 10 KB name-heavy certificate:

operation main view speedup
X509_parse_from_buffer + free 194.4 µs 2.51 µs 77×
parse + X509_get0_pubkey 195.7 µs 3.14 µs 62×
parse + X509_verify 209.1 µs 16.59 µs 13×
parse + subject/issuer X509_NAME 193.3 µs 176.5 µs 1.1×
parse ×2 + X509_cmp 468.1 µs 424.0 µs 1.1×

Validator cost alone, with no X509 object: 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 is X509 bookkeeping — one 632-byte allocation, mutex init, ex-data init, buffer ref.

Review considerations

No public API or ABI change; struct x509_st is 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:

how time allocations
main, public API 25.32 µs 206
this PR, public API 7.20 µs 156
this PR, read straight off the view's ranges 0.530 µs 0

The 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 which x_name.c canonicalization and x509v3_cache_extensions are 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 outside crypto/x509: ASN1_INTEGER_cmp returns -2 for a NULL operand instead of dereferencing it, OCSP_cert_to_id and pkcs7_cmp_ri bail out, and in cert_crl a materialization failure is reported as X509_V_ERR_OUT_OF_MEM rather 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 the x509_ensure_legacy ownership 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.

github-actions[bot]

This comment was marked as outdated.

github-actions[bot]

This comment was marked as outdated.

@jakemas jakemas changed the title Add allocation-free X.509 view parser behind ENABLE_X509_VIEW Add an allocation-free X.509 view parser and lazy X509 representation Aug 26, 2026
github-actions[bot]

This comment was marked as outdated.

@jakemas
jakemas force-pushed the x509-c-view-parser branch from b922ea4 to 56b00c7 Compare August 26, 2026 06:08
github-actions[bot]

This comment was marked as outdated.

@jakemas
jakemas force-pushed the x509-c-view-parser branch from 56b00c7 to 8a81d08 Compare August 26, 2026 06:18
github-actions[bot]

This comment was marked as outdated.

@jakemas
jakemas force-pushed the x509-c-view-parser branch 2 times, most recently from 9378afe to 5d29d22 Compare August 26, 2026 06:38
@github-actions

Copy link
Copy Markdown
Contributor

🔒 Security ReviewView Report

Please review before merging.

@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.75465% with 164 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.27%. Comparing base (408de5e) to head (cc15f93).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crypto/x509/x_x509.c 90.25% 60 Missing ⚠️
crypto/x509/x_all.c 78.12% 21 Missing ⚠️
crypto/x509/x509_c_view_parser.c 97.46% 14 Missing ⚠️
crypto/x509/v3_purp.c 87.00% 13 Missing ⚠️
crypto/x509/x509_cmp.c 65.71% 12 Missing ⚠️
crypto/x509/x509_set.c 74.35% 10 Missing ⚠️
crypto/x509/x509_view_test.cc 97.94% 8 Missing and 1 partial ⚠️
crypto/x509/x509_vfy.c 70.00% 6 Missing ⚠️
crypto/x509/x509_ext.c 87.50% 4 Missing ⚠️
crypto/x509/x509_lu.c 66.66% 4 Missing ⚠️
... and 10 more
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jakemas
jakemas force-pushed the x509-c-view-parser branch 3 times, most recently from afc8ad1 to 44a010d Compare August 26, 2026 15:51
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
jakemas force-pushed the x509-c-view-parser branch from 5a058c3 to cc15f93 Compare August 27, 2026 02:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants