Skip to content

Reduce OpenSSL compat layer heap use and time when parsing certificates - #11914

Open
LinuxJedi wants to merge 5 commits into
wolfSSL:masterfrom
LinuxJedi:ossl-compat-mem
Open

LinuxJedi wants to merge 5 commits into
wolfSSL:masterfrom
LinuxJedi:ossl-compat-mem

Conversation

@LinuxJedi

@LinuxJedi LinuxJedi commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Description

Reduces the heap use and time the OpenSSL compatibility layer adds when certificates are parsed, mostly visible in the TLS handshake and when loading CAs on embedded targets.

With OPENSSL_EXTRA or OPENSSL_EXTRA_X509_SMALL, every certificate parse built a WOLFSSL_X509_NAME for the issuer and subject: about 1.3 KB each plus a heap ASN1_OBJECT and ASN1_STRING per attribute. That happened for CA loads, every chain certificate, and again when a verified intermediate was added as a temporary CA. The names were only read if the certificate was copied into a WOLFSSL_X509. Building them was also quadratic: each attribute was created twice, and after every add, RebuildFullName() re-ran the linear nid2obj table search for every existing entry. The leaf's names were then built a second time when copied into ssl->peerCert.

Changes:

  • Build X509_NAMEs on demand. DecodedCert gets a noX509Name flag and keeps where each encoded name is. GetCertX509Name() builds a name on demand. With the flag set, GetCertName() still checks DirectoryString lengths. It also still fails a name with more than MAX_NAME_ENTRIES attributes, the only non-OOM way building it could fail. So parsing accepts and rejects the same certificates. The flag is set by:

    • AddCA() and the CSR2 pending-signer parse;
    • TLS peer certificate parsing;
    • d2i_X509/X509_REQ and the X509 buffer load.

    The hash-directory LoadCertByIssuer and CRL lookups build the issuer name when they need it. External wc_ParseCert() callers and the original (non-template) ASN parser behave as before.

  • Add entries straight into the X509. CopyDecodedToX509() adds the entries directly to the X509's names instead of building them in the DecodedCert and copying them. It uses the same reset and issuerSet handling as wolfSSL_X509_set_issuer_name(). A failure to build the names at that point can only be a resource failure, so it is reported as MEMORY_E.

  • Linear name building. X509_NAME_add_entry_by_NID() sets the entry in place instead of creating a temporary entry and copying it. RebuildFullName() reuses the entry's object when it matches the NID. X509_NAME_add_entry() now fails if the value could not be set; previously it reported success with an unset slot.

  • OPENSSL_ALL: no parse-time public key decode. CopyDecodedToX509() still decoded every certificate's key into an EVP_PKEY (with ECC point validation) and created the algorithm object. X509CachedPubKey() and wolfSSL_X509_PUBKEY_get0_param() already create these on first use, so they are now left to those.

Results

STM32U585 (Cortex-M33 at 160 MHz, newlib-nano malloc), TLS 1.3 client handshake over memory I/O, ECC P-256 chain with two intermediates. Both builds are from current master, with and without this change, and the overhead is measured against the same build without the compat layer (113.6 ms, 449 allocations, 13.2 KB peak):

Config Handshake overhead Handshake allocations Handshake heap peak
OPENSSL_EXTRA_X509_SMALL +5.3 ms -> +0.8 ms 949 -> 449 19.5 KB -> 13.4 KB
OPENSSL_EXTRA +8.0 ms -> +2.6 ms 1111 -> 517 21.4 KB -> 16.0 KB
OPENSSL_ALL +13.4 ms -> +3.6 ms 1302 -> 535 39.5 KB -> 19.4 KB
  • With a verify callback set, the OPENSSL_ALL overhead goes from +29.0 ms to +7.9 ms.
  • Loading one CA certificate goes from 150 to 50 allocations (base 46).
  • Per-connection resident heap with OPENSSL_ALL goes from 35.4 KB to 23.3 KB.
  • newlib heap high-water mark for the whole run (client and server, including fragmentation):
    • OPENSSL_EXTRA: 61.1 KB -> 57.0 KB
    • OPENSSL_ALL: 105.5 KB -> 82.4 KB
    • OPENSSL_EXTRA_X509_SMALL: 39.1 KB -> 34.8 KB (base 34.3 KB)
  • Flash is unchanged.

Testing

  • test_wolfSSL_X509_max_name_entries (new) uses two new CAs from gen-ext-certs.sh, whose subject and issuer have MAX_NAME_ENTRIES and MAX_NAME_ENTRIES + 1 attributes. It checks that the first loads with every entry, both as an X509 and as a CA, and that the second is rejected by both. It passes with and without this change, so the accepted and rejected certificates are the same.
  • make check passed on macOS/clang for:
    • --enable-opensslextra --enable-crl --enable-ocsp
    • --enable-opensslall
    • --enable-opensslextra=x509small
    • --enable-all
    • --enable-opensslextra --enable-asn=original
    • default
  • make check passed on Linux x86_64/gcc 16 for the same configs, plus:
    • --enable-opensslextra --enable-smallstack
    • --enable-opensslall with SP math, CRL, OCSP, certgen, certreq and certext
  • --enable-all built with gcc ASan + UBSan: all API tests pass except the SipHash one. SipHash fails identically on unpatched master with the same flags.
  • White-box smoke (gcc, --enable-all static): 113 passed, 0 failed.
  • An in-memory client/server harness ran under ASan + UBSan (clang) for EXTRA, ALL and X509_SMALL. It covered three chains, with and without a verify callback, plus resumption, with no errors and no leaks.

Checklist

  • added tests
  • updated/added doxygen
  • updated appropriate READMEs
  • Updated manual and documentation

🤖 Generated with Claude Code

With OPENSSL_EXTRA or OPENSSL_EXTRA_X509_SMALL, every certificate parse
built a WOLFSSL_X509_NAME for the issuer and subject: about 1.3 KB each
plus a heap ASN1_OBJECT and ASN1_STRING per attribute. This happened for
CA loads, every chain certificate and again when a verified intermediate
was added as a temporary CA, but the names were only read when the
certificate was copied into a WOLFSSL_X509. On a Cortex-M33 this was most
of the compat layer's handshake overhead.

Parsing can now skip the X509_NAMEs: DecodedCert has a noX509Name flag and
keeps where each encoded name is, and GetCertX509Name() builds a name on
demand. GetCertName() still checks DirectoryString lengths and fails a
name with more than MAX_NAME_ENTRIES attributes when it skips building
it, so the same certificates are accepted and rejected. AddCA(), the CSR2
pending signer parse, TLS peer certificate parsing, d2i_X509/X509_REQ and
the X509 buffer load set the flag. The hash directory issuer and CRL
lookups build the issuer name when needed. CopyDecodedToX509() adds the
entries straight to the X509's names rather than building them in the
DecodedCert and copying, so the leaf's names no longer exist twice at the
handshake peak. A failure to build them then is reported as MEMORY_E.

X509_NAME_add_entry_by_NID() sets the entry in place instead of creating
a temporary entry and copying it, and RebuildFullName() uses the entry's
object when it matches the NID instead of searching the OID table again
for every entry on every add. X509_NAME_add_entry() now fails if the
value could not be set.

With OPENSSL_ALL, CopyDecodedToX509() still decoded the public key into
an EVP_PKEY and created the algorithm object for every certificate,
including ECC point validation, although X509CachedPubKey() and
wolfSSL_X509_PUBKEY_get0_param() create them on first use. Leave them to
those.

STM32U585 at 160 MHz, TLS 1.3 client handshake with an ECC chain of two
intermediates, overhead over a build without the compat layer:
OPENSSL_EXTRA 9.0 ms -> 1.8 ms, OPENSSL_ALL 16.1 ms -> 3.0 ms,
X509_SMALL 6.0 ms -> 0.2 ms. Handshake heap peak OPENSSL_EXTRA
21.4 KB -> 16.0 KB, OPENSSL_ALL 47.1 KB -> 23.2 KB.
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

MemBrowse Memory Report · Explore what changed →

gcc-arm-cortex-m4-openssl-compat

  • FLASH: .rodata -24 B, .text +448 B (+0.1%, 796,564 B / 1,048,576 B, total: 76% used)

gcc-arm-cortex-m4-pkcs7

  • FLASH: .text +64 B (+0.0%, 223,616 B / 262,144 B, total: 85% used)

zephyr-frdm_rw612

  • FLASH: text +48 B (+0.0%, 282,496 B / 67,108,864 B, total: 0% used)

No memory changes detected for:


Catch memory regressions in CI with MemBrowse. Free for open source.

Parsing that skips building the X509_NAMEs must still reject a name with
more attributes than an X509_NAME holds. Add self-signed CAs whose subject
and issuer have MAX_NAME_ENTRIES (16 with OPENSSL_EXTRA) and one more
attributes, generated by gen-ext-certs.sh. Loading the first as an X509
gives 16 entries in each name and loading it as a CA succeeds; the second
is rejected by both.
The test created a WOLFSSL_CTX from a server or client method, which the
API guard check rejects as reachable in a build with neither. Loading the
CAs through a WOLFSSL_CERT_MANAGER goes to AddCA() the same way and needs
no TLS method.
gen-ext-certs.sh writes both DER and PEM and both are committed, so list
the DER files in EXTRA_DIST too.
@philljj philljj self-assigned this Oct 28, 2026
@philljj

philljj commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Retest this please.

(fips windows test hung or something)

@philljj

philljj commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Retest this please.

(fips windows test hijinks)

@LinuxJedi

Copy link
Copy Markdown
Member Author

@claude review

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #11914

Scan targets checked: wolfcrypt-src, wolfcrypt-bugs, wolfssl-src, wolfssl-bugs
Not reviewed (no scan target covers them): certs/test/cert-max-name-entries.cfg, certs/test/cert-max-name-entries.der, certs/test/cert-max-name-entries.pem, certs/test/cert-over-max-name-entries.cfg, certs/test/cert-over-max-name-entries.der, certs/test/cert-over-max-name-entries.pem, certs/test/gen-ext-certs.sh, certs/test/include.am, tests/api/test_ossl_x509.h
Coverage: 5 of 7 in-scope changed file(s) opened by the reviewer; not opened: src/ssl_certman.c, tests/api/test_ossl_x509.c

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

Review tier: Lite

Comment thread src/internal.c
With OPENSSL_ALL, the EVP_PKEY of a certificate used to be made when the
certificate was parsed: wolfSSL_EVP_PKEY_new_raw_public_key() for Ed448
and wolfSSL_d2i_PUBKEY() for key types without a case of their own. It
is now made on first use by X509DecodePubKey(), which treated those keys
as EC keys, so X509_get_pubkey() returned NULL for an Ed448 certificate.

Import Ed448 keys in X509DecodePubKey() and decode other non-EC key
types with wolfSSL_d2i_PUBKEY(), as the parse-time code did. This also
makes X509_get_pubkey() work for these keys in OPENSSL_EXTRA builds
without OPENSSL_ALL.
Copilot AI balanced review requested due to automatic review settings October 28, 2026 19:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

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.

4 participants