Skip to content

Keep only the peer certificate's encoding until it is asked for - #12020

Open
LinuxJedi wants to merge 4 commits into
wolfSSL:masterfrom
LinuxJedi:ossl-peer-x509-mem
Open

LinuxJedi wants to merge 4 commits into
wolfSSL:masterfrom
LinuxJedi:ossl-peer-x509-mem

Conversation

@LinuxJedi

Copy link
Copy Markdown
Member

Description

With WOLFSSL_DYNAMIC_PEER_CERT (#11990, on by default with WOLFSSL_SMALL_STACK), a connection now keeps only the DER of the peer's leaf certificate when it arrives. The WOLFSSL_X509 is built from it the first time something asks for it, for example wolfSSL_get_peer_certificate(). This follows #11914, #11991, #11992 and #11990 (all merged), is based on master, and is not stacked on another PR.

On an established compat-layer client connection, most of the heap above a non-compat build was the decoded peer certificate kept by KEEP_PEER_CERT. Host harness, ECC leaf, OPENSSL_EXTRA minus no compat layer = 7128 bytes, of which 5320 is the peer certificate:

  • the WOLFSSL_X509 struct, with its two embedded names: 2936
  • one ASN1_STRING per name entry, 14 of them: 1456
  • the DER copy: 712
  • the public key, signature and key ID copies: 216

OPENSSL_ALL adds a larger struct (4032), a stack node per name entry and an ASN1_OBJECT. An application that never looks at the peer certificate paid all of this for the life of the connection.

Changes

  • ssl->peerCertDer holds the leaf's encoding until ssl->peerCert is built from it, then is freed: the object has its own copy. At most one of the two is set.
  • SetPeerCertDer() keeps the encoding. It is used when ProcessPeerCerts() keeps the leaf and when the certificate is restored from a session ticket. Without WOLFSSL_DYNAMIC_PEER_CERT it is SetPeerCert(), so those builds are unchanged.
  • GetPeerCert() is now a function in dynamic builds. When only the encoding is kept it builds the object as d2i_X509() does (NO_VERIFY, names added straight to the X509) and keeps it in its place. When memory runs out the encoding is kept so a later call can try again. A copy that fails part way is still kept, as SetPeerCert() does.
  • GetPeerCertDer() returns the encoding without building the object. Session ticket creation and wolfSSL_cmp_peer_cert_to_file() use it.
  • Verify callback. When the callback runs for the leaf, SetupStoreCtxCallback() builds the object from the already decoded leaf, not from the encoding. The callback's current certificate is still the kept object, so pointer identity and ex_data set in the callback behave as before.
  • Leaves accepted despite a decode error. Only a leaf whose own ParseCertRelative() returned no error is kept as its encoding alone (new ProcPeerCertArgs.leafDecodeErr). Some errors come back even with NO_VERIFY, such as KEYUSAGE_E for keyCertSign on a non-CA certificate, and the handshake can still accept such a leaf: the chain verify callback clears content errors, ProcessPeerCertCb can override the result, and a verify callback can accept it in builds without OPENSSL_EXTRA. Such a leaf is copied from the decoded certificate as before, since decoding it again would fail the same way.
  • PushPeerCertToChain() and CreatePeerCertChain() take a non-const WOLFSSL*, since getting the peer certificate can now build it.

No public structure changes. WOLFSSL gains one pointer.

Results

STM32U585 (Cortex-M33 at 160 MHz, newlib-nano malloc), TLS 1.3 client and server over memory I/O, bytes unless noted, master -> this PR. Heap kept by the established client connection (its CTX and CA included), no verify callback:

Chain No compat layer OPENSSL_EXTRA OPENSSL_ALL
ECC leaf 3445 9409 -> 5697 11846 -> 6652
ECC leaf + 2 intermediates 3932 10325 -> 6434 14152 -> 8779
RSA leaf + 2 intermediates 4461 11596 -> 7346 16819 -> 11086

For the ECC leaf the compat layer's extra cost goes from 5964 to 2252 bytes (OPENSSL_EXTRA) and from 8401 to 3207 (OPENSSL_ALL).

ECC leaf OPENSSL_EXTRA OPENSSL_ALL
Client handshake heap peak 14822 -> 12925 16304 -> 13294
Client handshake allocations 387 -> 365 403 -> 365
Client handshake time 69.6 -> 69.1 ms 70.1 -> 69.3 ms
First wolfSSL_get_peer_certificate() 1.2 ms, 35 allocations 1.3 ms, 51 allocations
Flash -112 -128

With a verify callback set, or once the certificate has been asked for, the numbers are the same as before (+8 bytes for the pointer).

Host (macOS arm64), same harness, ECC leaf, no verify callback: resident 11201 -> 6601 (OPENSSL_EXTRA) and 14558 -> 7724 (OPENSSL_ALL); handshake peak 14134 -> 10498 and 17352 -> 11482.

Not in this PR

An application that sets a verify callback or asks for the peer certificate still holds the whole WOLFSSL_X509, about 4 KB on the M33 with OPENSSL_EXTRA. The remaining large parts are each name's 330-byte one-line string, the fixed entry[MAX_NAME_ENTRIES] array and a heap ASN1_STRING per name entry. Shrinking those touches much of x509.c and is left for a separate change.

Testing

New tests:

  • test_wolfSSL_peer_cert_on_demand (TLS 1.3 and 1.2, dynamic builds):
    • only the encoding is kept after the handshake, and cmp_peer_cert_to_file() builds nothing;
    • out of memory, no object is returned and the encoding stays;
    • get_peer_certificate() builds an object with the same DER, names and contents as the certificate sent, and the encoding is dropped;
    • wolfSSL_clear() drops the encoding;
    • with a verify callback, the callback's leaf is the kept object.
  • test_wolfSSL_peer_cert_kept_decode_err: a generated leaf with keyCertSign but not a CA, accepted by the chain verify callback, is kept as an object. It fails with the decode-error handling disabled.

test_wolfSSL_get_peer_certificate_ref no longer calls GetPeerCert() from the test, as it is now a hidden symbol in dynamic builds. The ticket re-verify test also checks that no encoding is kept.

make check passes on macOS (clang):

  • --enable-opensslextra, --enable-opensslall, --enable-all, --enable-opensslextra=x509small, default;
  • with the new path on: --enable-opensslextra, --enable-opensslall and --enable-all with -DWOLFSSL_DYNAMIC_PEER_CERT, --enable-opensslextra=x509small with -DKEEP_PEER_CERT -DWOLFSSL_DYNAMIC_PEER_CERT, --enable-opensslextra --enable-smallstack, and --enable-opensslextra --enable-asn=original with -DWOLFSSL_DYNAMIC_PEER_CERT.

Also:

  • Linux (gcc 16): --enable-all -DWOLFSSL_DYNAMIC_PEER_CERT builds with -Wall -Wextra -Wdeclaration-after-statement -Werror, and make check passes for --enable-opensslextra --enable-smallstack.
  • ASan + UBSan, --enable-all -DWOLFSSL_DYNAMIC_PEER_CERT: the whole unit test passes. The only UBSan report is a misaligned load in wolfssl/test.h, which this PR does not touch.

With WOLFSSL_DYNAMIC_PEER_CERT a connection now keeps only the DER of the
peer's leaf certificate when it arrives, in ssl->peerCertDer. GetPeerCert()
builds the WOLFSSL_X509 from it the first time it is used, for example by
wolfSSL_get_peer_certificate(), and frees the encoding as the object has its
own copy. A connection whose application never asks for the certificate no
longer holds the decoded object, its two names and an ASN1_STRING per name
entry.

- SetPeerCertDer() keeps the encoding (without the flag it is SetPeerCert())
  and is used when the leaf is kept in ProcessPeerCerts() and when the
  certificate is restored from a session ticket.
- When a verify callback runs for the leaf, the object is built from the
  decoded leaf as before, so the callback's current certificate is still the
  kept object.
- The object is built as d2i_X509() does. When memory runs out the encoding
  is kept for a later call.
- GetPeerCertDer() gives the encoding without building the object; session
  ticket creation and wolfSSL_cmp_peer_cert_to_file() use it.
- Only a leaf that parsed without error is kept as its encoding alone. One
  accepted despite an error, such as keyCertSign without being a CA cleared
  by the chain verify callback, is copied from the decoded leaf as before,
  as decoding it again would fail the same way.

Established client connection with an ECC leaf on an STM32U585 (Cortex-M33,
160 MHz): resident heap 9409 -> 5697 bytes with OPENSSL_EXTRA and
11846 -> 6652 bytes with OPENSSL_ALL (3445 without the compat layer);
handshake peak 14822 -> 12925 and 16304 -> 13294 bytes. Unchanged when a
verify callback is set or once the certificate has been asked for; the
first wolfSSL_get_peer_certificate() then takes about 1.2 ms.
Copilot AI balanced review requested due to automatic review settings November 5, 2026 18:12
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

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.

🟡 Changes recommended

The tests do not exercise wolfSSL_clear() while peerCert is NULL and peerCertDer is non-NULL, leaving DER-only cleanup unverified.

1 open issue
What changed in this PR

Reduces per-connection memory use in dynamic peer-certificate builds by retaining only the certificate’s DER encoding until an X509 object is requested.

Changes:

  • Adds lazy X509 construction, with retry support after allocation failure.
  • Uses DER directly for certificate comparison and session tickets while preserving verification-callback behavior.
  • Adds tests for on-demand decoding, allocation failure, callback identity, and certificates accepted despite decode errors.
File Description
wolfssl/​internal.h Adds DER storage, decode-error tracking, and internal accessors.
tests/​api/​test_tls13.c Checks that ticket re-verification leaves no retained DER.
tests/​api/​test_ssl_cert.h Registers the new peer-certificate tests.
tests/​api/​test_ssl_cert.c Tests lazy construction and accepted decode-error handling.
src/​ssl_api_cert.c Compares DER directly and adapts chain helpers to lazy construction.
src/​internal.c Implements DER retention, lazy decoding, cleanup, and handshake/ticket integration.

Review effort: Balanced

Comment thread src/internal.c Resolved
check-api-guards.py cannot see that TEST_PEER_CERT_COPY_OOM is only
defined with USE_WOLFSSL_MEMORY, so the out-of-memory part of
test_wolfSSL_peer_cert_on_demand now names it next to the macro.
test_wolfSSL_peer_cert_on_demand cleared the connection only after the
certificate object had been built from the encoding. A further handshake
now clears it while only the encoding is kept, and checks that both the
object and the encoding are gone.

@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 #12020

Scan targets checked: wolfssl-src, wolfssl-bugs
Not reviewed (no scan target covers them): tests/api/test_ssl_cert.h
Coverage: 5 of 5 in-scope changed file(s) opened by the reviewer

Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Reported findings require changes before merge.

Review tier: Max

Comment thread tests/api/test_ssl_cert.c Resolved
In test_wolfSSL_peer_cert_kept_decode_err, wc_InitCert() is skipped when
an earlier expectation fails, and with WOLFSSL_CERT_GEN_CACHE
wc_SetCert_Free() would then free an uninitialized Cert. A certInit flag
now guards it, as keyInit and rngInit do for the key and RNG.

@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 #12020

Scan targets checked: wolfssl-src, wolfssl-bugs
Not reviewed (no scan target covers them): tests/api/test_ssl_cert.h
Coverage: 1 of 1 in-scope changed file(s) opened by the reviewer

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

@wolfSSL-Fenrir-bot
wolfSSL-Fenrir-bot dismissed their stale review November 5, 2026 18:58

Fenrir's latest completed scan found no issues; clearing the prior automated change request.

@LinuxJedi LinuxJedi assigned wolfSSL-Bot and unassigned LinuxJedi Nov 5, 2026
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