Skip to content

Keep the TLS peer certificate as a reference-counted heap object - #11925

Closed
LinuxJedi wants to merge 9 commits into
wolfSSL:masterfrom
LinuxJedi:ossl-peercert-ref
Closed

LinuxJedi wants to merge 9 commits into
wolfSSL:masterfrom
LinuxJedi:ossl-peercert-ref

Conversation

@LinuxJedi

Copy link
Copy Markdown
Member

Description

Builds on #11914. Only the last commit, "Keep the TLS peer certificate as a reference-counted heap object", is new here; the others are #11914's and drop out of this diff once it merges.

With KEEP_PEER_CERT, which OPENSSL_EXTRA turns on, every WOLFSSL embedded a whole WOLFSSL_X509 for the peer's certificate: 3.4 KB on Cortex-M33, 4.3 KB with OPENSSL_ALL. InitSSL() zeroed it. Every connection paid for it, including ones that never have a peer certificate: servers that don't ask for client authentication, PSK connections, and any object before its handshake. wolfSSL_get_peer_certificate() returned wolfSSL_X509_dup() of it, which parses the certificate DER again on every call.

Changes:

  • ssl->peerCert is now a WOLFSSL_X509*, NULL until a certificate is kept. The WOLFSSL holds one reference to it.
  • KeepPeerCert() replaces the kept certificate with a new object each time instead of reusing it. It is used for the leaf in SetupStoreCtxCallback(), in ProcessPeerCerts() and when restoring a certificate from a ticket. A reference handed out stays valid across renegotiation, wolfSSL_clear() and a ticket restore, and after wolfSSL_free(). A copy that fails part way is still kept and its error returned, as before.
  • wolfSSL_get_peer_certificate() returns the kept object with a new reference when WOLFSSL_X509 has a reference count (OPENSSL_EXTRA or OPENSSL_EXTRA_X509_SMALL), as OpenSSL does. Without one (for example --enable-sep) it still returns a copy. Either way the caller frees it with wolfSSL_X509_free(), as before.
  • With SESSION_CERTS, a resumed connection's certificate is decoded from the session chain into a new kept object.
  • ReinitX509() only existed for the embedded object and is removed.
  • Dual-algorithm certificates: DoTls13CertificateVerify() returns PEER_KEY_ERROR when no peer certificate is kept, instead of reading the alternative key from an empty struct.

Behaviour change for applications: repeated calls to wolfSSL_get_peer_certificate() now return the same object, and changes made to it are seen through the WOLFSSL, as with OpenSSL.

Results

STM32U585 (Cortex-M33 at 160 MHz, newlib-nano malloc), TLS 1.3 client and server over memory I/O, ECC P-256 chain with two intermediates, server not asking for a client certificate. Before is #11914, after is this change on top of it.

OPENSSL_EXTRA OPENSSL_ALL
sizeof(WOLFSSL) 4560 -> 1144 B 5576 -> 1304 B
Server heap per connection 6842 -> 3426 B 12366 -> 8094 B
Server wolfSSL_new() time 2.43 -> 2.10 ms 2.93 -> 2.50 ms
Client wolfSSL_new() heap 7726 -> 4310 B 14914 -> 10642 B
Client handshake peak, from before wolfSSL_new() 23694 -> 23692 B 34290 -> 34289 B
Client handshake time 116.4 -> 116.4 ms 116.9 -> 117.0 ms
newlib heap high-water mark, whole run 57.0 -> 50.1 KB 82.4 -> 77.6 KB
Flash -1.3 KB -1.6 KB

A client that gets a certificate keeps it either way, so its handshake peak and resident heap are unchanged: the certificate is allocated during the handshake instead of being part of wolfSSL_new(). A client handshake makes one more allocation, or two with a verify callback, since the leaf is copied once for the callback and once to keep, as before.

Testing

  • test_wolfSSL_get_peer_certificate_ref (new) checks that:
    • the client is handed the kept object, with its reference count going 2, 3, 2 across two calls and a free;
    • a server that did not ask for a client certificate keeps none;
    • a held reference keeps its certificate after wolfSSL_clear() and a new handshake, which keeps a new object;
    • a held reference stays valid after wolfSSL_free().
  • test_tls13_ticket_peer_cert_reverify now checks that no certificate is kept, instead of checking the embedded issuer size.
  • make check passed on macOS/clang for default, --enable-opensslextra, --enable-opensslall and --enable-all on this branch.
  • On Reduce OpenSSL compat layer heap use and time when parsing certificates #11914 before its latest commits:
    • make check passed on macOS/clang for CFLAGS="-DKEEP_PEER_CERT -DSESSION_CERTS" (the copy path) and --enable-experimental --enable-dual-alg-certs --enable-mldsa. --enable-opensslextra=x509small and --enable-sep built.
    • make check passed on Linux x86_64/gcc for --enable-opensslextra and --enable-all. Valgrind on the peer certificate API tests reported no errors and no leaks.
    • --enable-all with clang ASan + UBSan: the peer certificate, ticket, chain and mutual authentication API tests passed with no reports.

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.
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.
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.
GetCertX509Name() parses the name again in a temporary DecodedCert, and
GetCertName() hashes the name with the hash of the signature algorithm.
The temporary object had no signature algorithm, so the default hash
was used. With SM2 and SM3 but without the default hash, building the
name failed and loading the certificate through the X509 APIs failed.

Copy the signature algorithm into the temporary object.
With OPENSSL_ALL, the EVP_PKEY and the X509_ALGOR of a certificate were
made when it was parsed. They are now made on first use, so threads
reading a shared X509 could each make one and store it, replacing a key
already handed out and leaking the others.

Store the cached key and algorithm with an atomic compare and exchange,
freeing the object made by a thread that loses, and read them with an
atomic load. Set the key OID of the X509_PUBKEY when parsing so the
getters never write it.
check-source-text rejects __ATOMIC_ACQUIRE in a preprocessor condition
as an unrecognized macro. Test __ATOMIC_SEQ_CST instead, as wc_port.h
does for the same check.
With KEEP_PEER_CERT, which OPENSSL_EXTRA turns on, every WOLFSSL embedded
a whole WOLFSSL_X509 for the peer's certificate (3.4 KB on Cortex-M33,
4.3 KB with OPENSSL_ALL) and InitSSL() zeroed it. It was paid for when
there is no peer certificate: servers that do not ask for client
authentication, PSK connections, and objects before the handshake.
wolfSSL_get_peer_certificate() returned wolfSSL_X509_dup() of it, which
parses the certificate DER again on every call.

ssl->peerCert is now a pointer, NULL until a certificate is kept, and
the WOLFSSL holds one reference to it. KeepPeerCert() replaces it with a
new object each time instead of reusing it, so a reference handed out
stays valid across renegotiation, wolfSSL_clear() and a certificate
restored from a ticket, and after wolfSSL_free(). A copy that fails part
way is still kept and its error returned, as before.

wolfSSL_get_peer_certificate() returns the kept object with a new
reference when the X509 has a reference count (OPENSSL_EXTRA or
OPENSSL_EXTRA_X509_SMALL), as OpenSSL does. Without one it still
returns a copy. ReinitX509() only existed for the embedded object and
is removed.
@LinuxJedi

Copy link
Copy Markdown
Member Author

Closing this as it is going to be difficult to review until #11914 is merged

@LinuxJedi LinuxJedi closed this Oct 29, 2026
@github-actions

Copy link
Copy Markdown

MemBrowse Memory Report · Explore what changed →

gcc-arm-cortex-m3

  • FLASH: .text -44 B (-0.0%, 130,773 B / 262,144 B, total: 50% used)

gcc-arm-cortex-m4-openssl-compat

  • FLASH: .rodata -24 B, .text -576 B (-0.1%, 792,964 B / 1,048,576 B, total: 76% used)

gcc-arm-cortex-m4-pq

  • FLASH: .text -64 B (-0.0%, 309,008 B / 1,048,576 B, total: 29% used)

gcc-arm-cortex-m4-rsa-only

  • FLASH: .text -64 B (-0.0%, 340,360 B / 1,048,576 B, total: 32% used)

gcc-arm-cortex-m4-tls12

  • FLASH: .text -64 B (-0.0%, 131,537 B / 262,144 B, total: 50% used)

gcc-arm-cortex-m4-tls13

  • FLASH: .text -64 B (-0.0%, 246,498 B / 262,144 B, total: 94% used)

gcc-arm-cortex-m7-tls13

  • FLASH: .text -64 B (-0.0%, 246,498 B / 262,144 B, total: 94% used)

No memory changes detected for:


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

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.

1 participant