Skip to content

Do not cache unsigned chain CAs after a verification override - #11830

Open
miyazakh wants to merge 8 commits into
wolfSSL:masterfrom
miyazakh:f1447_chainca
Open

miyazakh wants to merge 8 commits into
wolfSSL:masterfrom
miyazakh:f1447_chainca

Conversation

@miyazakh

@miyazakh miyazakh commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Do not cache chain CAs whose signature was never confirmed - F14457

Problem

In ProcessPeerCerts(), a chain certificate can reach the verify callback with its signature unconfirmed in several ways: no signer was found, the certificate was self-signed, the signature failed against the issuer that was found, the signature algorithm did not match the issuer key, or a check that runs ahead of the signature (the RFC 5280 6.1.4(l) path length check, for one) returned first. When the application's verify callback overrides that error, DoVerifyCallback() clears ret and the certificate was added to the context's certificate manager by AddCA(..., WOLFSSL_CHAIN_CA, NO_VERIFY).

signer->type is only read by FreeSignerTableType(), so a WOLFSSL_CHAIN_CA entry is indistinguishable from an explicitly loaded trust anchor during verification. Because SSL_CM(ssl) is the WOLFSSL_CTX-wide certificate manager, the certificate became a trust anchor for every later connection from that context. Those connections verify successfully, and DoVerifyCallback() only invokes the callback on an error, so the application was never asked about that certificate again. Only an explicit wolfSSL_CTX_UnloadIntermediateCerts() removed the entry.

The WOLFSSL_ALT_CERT_CHAINS and Apple native validation bypasses already refuse to cache for two of those error codes. The verify callback path refused for none of them.

Fix

The first attempt listed the error codes that mean the signature was never confirmed. That list could not be kept complete: ParseCertRelative() has around thirty return points ahead of ConfirmSignature(), and hardware ports add their own. ASN_PATHLEN_INV_E is one that the list missed, returned from the path length check with the issuer already resolved and the signature never examined.

So the result is recorded rather than inferred. ParseCertRelative() sets DecodedCert.sigVerified once every signature the certificate needed, including the alternative signature of a dual-algorithm certificate, has been confirmed against the issuer it found. ProcessPeerCerts() then caches a chain CA only when that flag is set:

if (preCbRet != 0 && !args->dCert->sigVerified) {
    skipAddCA = 1;
}

This covers both the AddCA() path and the ProcessPeerCertAddPendingCA() path used under HAVE_CERTIFICATE_STATUS_REQUEST_V2, and it does not have to be revisited when a new error code is added ahead of the signature check.

Errors raised after the signature verified leave the flag set and still cache, because the chain CA cache is also the chain building workspace within a single handshake: ParseCertRelative() finds issuers only through GetCA(), so refusing to cache an intermediate ends the chain there. A certificate waived for its OCSP status, extended key usage, dates or revocation is rooted in a trusted anchor, so caching it creates no new trust root, and test_override_alt_cert_chain and test_wolfSSL_chain_ca_ext_key_usage keep passing.

Under WOLFSSL_SMALL_CERT_VERIFY the signature is checked separately by wc_CheckCertSignature() and ParseCertRelative() runs with VERIFY_NAME, so the flag is taken from that result in ProcessPeerCertParse().

Behavioral change

This changes what a verify callback can do with a chain CA that is not in the trust store. Accepting such a CA now decides that one certificate rather than the rest of the chain: the certificate it signed has no signer, so the callback is asked about that certificate in its turn instead of being told it verified. An application that means to accept the chain accepts each certificate, which completes the handshake exactly as before.

Unaffected: applications with no verify callback, applications using WOLFSSL_VERIFY_NONE, and callbacks that accept a chain CA the context already trusts, which is the common case of a server resending its root.

How far an acceptance reaches now depends on why the certificate was rejected, and the documentation says so. A chain CA whose signature was confirmed and which failed a later check is still cached, so accepting an expired or revoked intermediate still makes it a trust anchor for later connections from the same context and the callback is not asked about it again. That is deliberate: the date, revocation, OCSP and key usage checks all run after the signature verified, and refusing to cache for them ends the chain inside the same handshake, which is what regressed test_override_alt_cert_chain and test_wolfSSL_chain_ca_ext_key_usage when tried.

The previous behavior reported preverify_ok at depth 0 for a leaf verified against a key the application had just waved through, so the check the application believed it was making at depth 0 was relative to an unauthenticated anchor.

OCSP is the one case the verify callback does not resolve. A stapled response is verified against the certificate manager, which no longer holds the accepted certificate, so the responder's own certificate cannot be verified: ProcessCSR_ex() returns BAD_CERTIFICATE_STATUS_ERROR and DoCertificateStatus() sends a fatal alert without consulting the verify callback. Only ocspStatusVerifyCb, set with wolfSSL_CTX_SetOCSP_StatusVerifyCb(), can accept that result. Measured on one build with only the library sources swapped: a client that accepts every certificate in an untrusted chain and requests stapling completes the handshake before this change and ends with BAD_CERTIFICATE_STATUS_ERROR after it. A live OCSP lookup fails for the same reason, but its error reaches the verify callback and is accepted there, so that path completes on both. Such a configuration was asking a responder authorised by a key the application itself waved through, so the staple was never meaningful assurance, but the failure is abrupt and the callback does not see it. Restoring the handshake-scoped availability of accepted chain CAs, by way of the OcspResponse.pendingCAs mechanism that Certificate Status Request v2 already uses, is tracked separately.

Test

Three tests in tests/api/test_ssl_cert.c, each failing before the fix:

  • test_wolfSSL_chain_ca_not_cached_on_override presents certs/intermediate/server-chain-short.pem to a client that trusts only the test root, so the chain certificate has no signer. The same wolfSSL_CertManagerVerify() call on the leaf runs before and after the handshake and must fail both times.
  • test_wolfSSL_forged_chain_ca_not_cached presents an intermediate carrying the test root's issuer name and authority key identifier but signed with a key the root does not hold, so the signer lookup succeeds and ConfirmSignature() does not.
  • test_wolfSSL_chain_ca_override_is_handshake_scoped pins the behavioral change above: a callback that accepts only the chain CA ends in ASN_NO_SIGNER_E on the leaf, the same chain completes when the callback accepts the leaf as well, and the CA is not a trust anchor afterwards in either case.

make check with no failures on:

  • ./configure --enable-ocsp --enable-crl --enable-opensslextra (WOLFSSL_ALT_CERT_CHAINS off, the configuration the new code actually runs in): 7 passed, 7 skipped, 0 failed
  • ./configure --enable-all (WOLFSSL_ALT_CERT_CHAINS on): 17 passed, 7 skipped, 0 failed

Also built and run clean on ./configure (default), on --enable-all --enable-static --disable-shared CPPFLAGS="-DWOLFSSL_TEST_STATIC_BUILD", and on --enable-leantls --enable-session-ticket --enable-sni --enable-opensslextra. tests/api/check-api-guards.py origin/master reports the changed call sites covered. Skipped suites are the ones needing external network or an openssl binary.

Checklist

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

@miyazakh miyazakh self-assigned this Oct 20, 2026
Copilot AI balanced review requested due to automatic review settings October 20, 2026 22:48

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

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

Signature-failure overrides remain cacheable, and selective CA overrides can now break leaf verification within the same handshake.

2 open issues
What changed in this PR

Prevents certain certificate verification overrides from adding untrusted chain CAs to wolfSSL’s shared trust cache.

Changes:

  • Preserves the pre-callback error to block both cache-admission paths.
  • Adds and registers a regression test checking trust before and after an overridden handshake.
File Description
tests/​api/​test_ssl_cert.h Declares and registers the regression test.
tests/​api/​test_ssl_cert.c Tests that an overridden handshake does not persist trust.
src/​internal.c Blocks caching for missing-signer and self-signed errors.

Review effort: Balanced

Comment thread src/internal.c Outdated
Comment thread src/internal.c Outdated
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

MemBrowse Memory Report

gcc-arm-cortex-m0plus

  • FLASH: .text +8 B (+0.0%, 70,467 B / 262,144 B, total: 27% used)

gcc-arm-cortex-m3

  • FLASH: .text +16 B (+0.0%, 130,785 B / 262,144 B, total: 50% used)

gcc-arm-cortex-m4-openssl-compat

  • FLASH: .text +64 B (+0.0%, 796,204 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)

gcc-arm-cortex-m4-pq

  • FLASH: .text +64 B (+0.0%, 311,780 B / 1,048,576 B, total: 30% used)

gcc-arm-cortex-m4-tls13

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

Scan targets checked: wolfssl-src, wolfssl-bugs
Not reviewed (no scan target covers them): doc/dox_comments/header_files/ssl.h, tests/api/test_ssl_cert.h
Coverage: 2 of 2 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: Lite

Comment thread src/internal.c Outdated
* runs on an error. The errors left out here were raised
* after the signature verified, so the key is authentic and
* the rest of the chain needs the cached entry. */
if (preCbRet == WC_NO_ERR_TRACE(ASN_NO_SIGNER_E) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skip-cache denylist misses pre-signature errors such as ASN_PATHLEN_INV_E, so an override still caches an… · Certificate and trust chain validation bypass

ParseCertRelative returns ASN_PATHLEN_INV_E (asn.c:26084/26118) and hash errors such as HASH_TYPE_E before ConfirmSignature checks the signature, and none of these is in the four-code list. When a callback overrides one of them, skipAddCA stays 0 and AddCA stores the forged CA in the context-wide CertManager as a trust anchor for later connections.

Suggested fix: Use an allowlist instead: cache only when the signature was confirmed (record a sig-verified flag in DecodedCert), or treat every nonzero preCbRet as skip except errors known to come after the signature check.
Basis: RFC 5280 section 6.1.3(a)(1): verifying the certificate signature with the working issuer public key is a required step of basic path validation, so a certificate whose signature was not verified must not become a trust anchor.

Related known findings (similar but distinct; listed for context, not part of this finding)

  • F-16248: that finding covers the ordering in asn.c ParseCertRelative, where the path-length check runs before the signature check inside one handshake. This finding is about the new skip condition in internal.c ProcessPeerCerts, which still lets that unauthenticated CA be cached for the whole context.
  • F-14457 (assigned): File/function: both are in the ProcessPeerCerts chain-CA loop, around DoVerifyCallback and AddCA. Faulting operation: both end with AddCA caching a peer-supplied chain CA in the shared CertManager after a callback override. F-14457 reports that no skip exists, so any overridden error is cached. The candidate reports that the skip condition (skipAddCA, checked against the four-code denylist) misses pre-signature errors such as ASN_PATHLEN_INV_E and HASH_TYPE_E. Root cause: F-14457 is an override th

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed. Both return ASN_PATHLEN_INV_E sites sit inside if (type != CERT_TYPE && cert->isCA && cert->ca && ...) and run ahead of ConfirmSignature(), so the issuer is resolved, isCA is set, AddCA() is reachable and the signature was never examined.

Your structural point is the one that mattered. This was the third code the list was missing, so rather than add a fourth, the enumeration is gone. ParseCertRelative() now sets DecodedCert.sigVerified once every signature the certificate needed, including a dual-algorithm certificate's alternative signature, has been confirmed against the issuer it found, and ProcessPeerCerts() caches a chain CA only when that flag is set. That also covers the return ret at the Renesas TSIP/FSPSM key-index decode, which is likewise ahead of the signature check. Under WOLFSSL_SMALL_CERT_VERIFY the flag is taken from wc_CheckCertSignature(), since ParseCertRelative() runs with VERIFY_NAME there.

@miyazakh

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude 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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/internal.c
Comment thread doc/dox_comments/header_files/ssl.h Outdated
@miyazakh
miyazakh marked this pull request as draft October 22, 2026 07:58
A chain certificate that fails verification with either a missing
signer or a self-signed chain is not rooted in a trusted certificate.
If the application's verification callback overrides the error, the
certificate is still added to the context's certificate manager as a
chain CA.

Since these entries act as trust anchors for all subsequent connections
using the same context, and the verification callback is only invoked
on errors, the application is never asked to verify that certificate
again.

Record the verification result before invoking the callback and skip
adding the certificate for these two error codes. This prevents caching
through both the AddCA path and the pending CA path for Certificate
Status Request v2.

Other errors remain unchanged because the certificate is rooted in a
trusted anchor, and the rest of the chain is verified against the
cached entry.
The verify callback for the chain CA override test sat outside the
build condition that guards the test body. A configuration that
compiles the test out, such as --enable-leantls, which defines
WOLFSSL_NO_TLS12, left the callback defined but unused and the build
failed on -Werror=unused-function.

Name the condition once and guard both the callback and the test body
with it so the two cannot drift apart.
A chain certificate can reach the verify callback with its signature
unconfirmed in more ways than a missing signer. A forged certificate
that names a loaded root as its issuer is looked up successfully and
then fails ConfirmSignature, and a signature algorithm that does not
match the issuer key fails before the signature is checked at all.
Neither certificate has been authenticated, so neither may be cached
when the callback clears the error.

Add both error codes to the set that skips the add, and correct the
comment: the question is whether the signature verified against a
trusted anchor, not whether an issuer was found. The errors left out
are raised after the signature verified, so the key is authentic and
the rest of the chain needs the cached entry.

Add a regression test that presents an intermediate carrying the test
root's issuer name and authority key identifier but signed with a key
the root does not hold.
check-api-guards.py matches the enclosing #if chain as text, so naming
the conditions in a macro hid NO_FILESYSTEM and the other guards from
it.
Accepting a chain CA that is not in the trust store now decides that one
certificate rather than the rest of the chain, because the CA is no
longer cached. Pin that: a callback accepting only the chain CA ends in
ASN_NO_SIGNER_E on the leaf, the same chain completes when the callback
accepts the leaf as well, and the CA is not a trust anchor afterwards
either way.

Record the scope of an override in the verify callback documentation,
which described the mode flags only.
Enumerating the error codes that mean a chain certificate's signature
was never confirmed left gaps. ASN_PATHLEN_INV_E is one: the path
length check returns it before ConfirmSignature runs, so an override
cached a certificate nothing had authenticated.

Record the outcome instead. ParseCertRelative sets a flag once every
signature the certificate needed has been confirmed against the issuer
it found, and a chain CA is cached only when that flag is set. Errors
raised after the signature verified still cache, so a certificate
waived for its OCSP status, extended key usage, dates or revocation
behaves as before.

Under WOLFSSL_SMALL_CERT_VERIFY the signature is checked separately by
wc_CheckCertSignature, so the flag is taken from that result there.

The flag joins the existing bitfield run, leaving sizeof(DecodedCert)
unchanged.
Accepting a chain certificate whose signature was never confirmed does
not complete a handshake that requested OCSP stapling. The staple is
verified against the certificate manager, which does not hold the
accepted certificate, so the responder's own certificate cannot be
verified and the handshake ends with a fatal alert the verify callback
never sees. A live OCSP lookup fails for the same reason but its error
does reach the callback. Name the one hook that can accept the stapled
case.

The text also described the override without saying what happens
without one, so say that first.
The text said an acceptance applied to the handshake in progress only.
That holds for a certificate whose signature was never confirmed, which
is not cached, but not for one whose signature was confirmed and which
failed a later check: the date, revocation, OCSP and key usage checks
all run after the signature verified, so an expired or revoked
intermediate waved through still becomes a trust anchor for later
connections from the same context.

Say which case is which instead.
@miyazakh

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude 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.

Code review found no issues

No high-confidence issues detected in this change.

@miyazakh
miyazakh marked this pull request as ready for review October 27, 2026 22:37
@github-actions

Copy link
Copy Markdown

retest this please

1 similar comment
@miyazakh

Copy link
Copy Markdown
Contributor Author

retest this please

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

A critical issue allows a dual-algorithm CA into the shared cache without verifying its alternative signature when a path-length error is overridden with both certificate features enabled.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread src/internal.c
/* ParseCertRelative() ran with VERIFY_NAME and left the signature to
* wc_CheckCertSignature() above, so it could not record the result. */
if (args->dCert != NULL) {
args->dCert->sigVerified = (verify == VERIFY_NAME) && (sigRet == 0);

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

Scan targets checked: wolfcrypt-src, wolfcrypt-bugs, wolfssl-src, wolfssl-bugs
Not reviewed (no scan target covers them): doc/dox_comments/header_files/ssl.h, tests/api/test_ssl_cert.h
Coverage: 3 of 3 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 October 28, 2026 04:34

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

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.

3 participants