tls: expose issuer cert hash and serial from peer certificate chain#43723
tls: expose issuer cert hash and serial from peer certificate chain#43723stias wants to merge 11 commits intoenvoyproxy:mainfrom
Conversation
|
Hi @stias, welcome and thank you for your contribution. We will try to review your Pull Request as quickly as possible. In the meantime, please take a look at the contribution guidelines if you have not done so already. |
|
related issue: #43722 |
Add issuerPeerCertificateHash() and issuerPeerCertificateSerial() to the SSL connection info stack, exposing SHA256 fingerprint and serial number of the direct issuer (CA) certificate from the peer's TLS certificate chain (chain index 1). Changes: - envoy/ssl/connection.h: add pure virtual declarations - source/common/tls/connection_info_impl_base: implement via SSL_get_peer_full_cert_chain + sk_X509_value(chain, 1) - source/common/quic/quic_ssl_connection_info.h: stub returning - source/common/tls/utility: add getSha256DigestFromCertificate() and getSha1DigestFromCertificate() helpers; refactor existing callers - source/common/tls/BUILD: add hex_lib dependency to utility_lib - source/extensions/filters/common/lua/wrappers: expose as issuerPeerCertificateHash() and issuerPeerCertificateSerial() in Lua - source/common/formatter/stream_info_formatter: add DOWNSTREAM_PEER_ISSUER_FINGERPRINT_256 and DOWNSTREAM_PEER_ISSUER_SERIAL access log formatter commands - test/mocks/ssl/mocks.h: add mock methods - test/common/tls/utility_test: add 4 unit tests - test/common/tls/ssl_socket_test: add GetIssuerPeerCertificateDigest integration test; extend TestUtilOptions - test/extensions/filters/http/lua/lua_filter_test: fix function names and add assertions - test/common/formatter/substitution_formatter_test: add 6 test cases - docs: update substitution_formatter.rst and lua_filter.rst Signed-off-by: stias <seokjun.yang@mycraft.kr>
ad698ca to
db82740
Compare
…in index
Previously, issuerPeerCertificateHash() and issuerPeerCertificateSerial()
returned data from the peer leaf certificate itself, not the issuer CA cert.
Also, the implementation assumed the issuer is always at index 1 in the chain,
which is not reliable.
This commit introduces getIssuerFromValidatedChain(), a helper that identifies
the true issuer CA certificate from the validated peer cert chain by:
1. Requiring a successfully validated chain (via SslExtendedSocketInfo) to
prevent returning data from an unvalidated/attacker-controlled chain.
2. Using AKI/SKI byte comparison as a cheap pre-filter to skip non-issuers
without a crypto operation. X509_check_issued() is deliberately avoided
because its strict DER name comparison can fail on semantically equivalent
names encoded with different ASN.1 string types (e.g. PrintableString vs
UTF8String).
3. Falling back to X509_verify() (public key cryptographic check) as the
authoritative issuer confirmation.
The access log formatters %DOWNSTREAM_PEER_ISSUER_FINGERPRINT_256% and
%DOWNSTREAM_PEER_ISSUER_SERIAL%, as well as the Lua API methods
issuerPeerCertificateHash() and issuerPeerCertificateSerial(), now correctly
return attributes of the CA certificate that signed the peer leaf cert.
Updated documentation and tests to reflect the new behavior and requirements:
requires a validated peer cert chain (e.g., mTLS with
require_client_certificate: true and a validation_context).
Signed-off-by: stias <seokjun.yang@mycraft.kr>
Rename issuerPeerCertificateHash() → sha256PeerCertificateIssuerDigest() and issuerPeerCertificateSerial() → serialNumberPeerCertificateIssuer() to eliminate naming ambiguity. The old names were misleading because issuerPeerCertificate() returns the Issuer DN field of the peer certificate (a string), so issuerPeerCertificateHash() appeared to be sha256(issuerPeerCertificate()) — i.e. a hash of that DN string — which is not what it computes. The new names follow the existing Envoy convention: sha256PeerCertificateDigest() → SHA-256 of the peer cert sha256PeerCertificateIssuerDigest() → SHA-256 of the issuer CA cert serialNumberPeerCertificate() → serial of the peer cert serialNumberPeerCertificateIssuer() → serial of the issuer CA cert No behavioral change; rename only. Signed-off-by: stias <seokjun.yang@mycraft.kr>
Two issues in the test: - require_client_certificate was not set, so ClientValidationStatus was never set to Validated, causing getIssuerFromValidatedChain() to always return nullptr and the issuer assertions to fail. - setExpectedSerialNumber() was missing; serialNumberPeerCertificate() is checked unconditionally in testUtil(), so omitting it caused != TEST_SAN_DNS3_CERT_SERIAL failures. Fix by adding require_client_certificate: true to the server context and setting setExpectedSerialNumber(TEST_SAN_DNS3_CERT_SERIAL). Signed-off-by: stias <seokjun.yang@mycraft.kr>
…suerFromValidatedChain Previously getIssuerFromValidatedChain() returned nullptr when the client sent only a leaf certificate (no intermediate chain), making sha256PeerCertificateIssuerDigest() and serialNumberPeerCertificateIssuer() return empty strings in that case. Add a second lookup path: after exhausting the peer-supplied chain, use X509_STORE_CTX_get1_issuer() to locate the issuer directly in the trusted CA store. The candidate is confirmed with X509_verify() for consistency with Path 1. This covers the common case where the issuer is the immediate trust anchor already loaded via validation_context.trusted_ca. Because X509_STORE_CTX_get1_issuer() returns a new reference (owned by the caller), change the return type from X509* to bssl::UniquePtr<X509> to unify ownership across both paths. Path 1 now calls X509_up_ref() on the chain candidate before wrapping it. Signed-off-by: stias <seokjun.yang@mycraft.kr>
|
For reference, I built Envoy locally and verified that it works as expected. mtls_issuer_test.yaml curl scenario Envoy execution: Actual Output |
Add a test that verifies the AKI/SKI pre-filter in getIssuerFromValidatedChain() correctly skips a decoy CA cert that does not match the leaf's Authority Key Identifier, and selects the correct Intermediate CA that does match. Chain sent by client (san_dns3_with_decoy_chain.pem): [0] leaf [1] Root CA (decoy, AKI mismatch) [2] Intermediate CA (correct) Expected: sha256PeerCertificateIssuerDigest() and serialNumberPeerCertificateIssuer() return Intermediate CA values, not Root CA values. Together with GetIssuerPeerCertificateDigest (leaf + chain, Path 1) and GetIssuerPeerCertificateDigestLeafOnly (leaf only, Path 2), this completes coverage for all three significant code paths in getIssuerFromValidatedChain(). Signed-off-by: stias <seokjun.yang@mycraft.kr>
|
@ggreenway Could you please review it once more? |
Replace the manual AKI/SKI + X509_verify issuer search with X509_verify_cert to build a proper validated chain, then extract chain[1] as the direct issuer. The previous approach only checked whether a candidate certificate's public key could verify the leaf signature, without confirming that the candidate itself chains to a trust anchor. A client could craft a certificate containing the real CA's public key (publicly available) that would pass signature verification but is not part of the validated trust path. The new implementation: - Builds an X509_STORE_CTX with the peer chain as untrusted intermediates and the Envoy trust store - Calls X509_STORE_CTX_set_default and X509_VERIFY_PARAM_set1 to mirror the handshake verification (purpose checks, PARTIAL_CHAIN, CRL flags) - Runs X509_verify_cert to construct the validated chain - Returns chain[1] which is guaranteed to be on the verified path from leaf to trust anchor Signed-off-by: stias <seokjun.yang@mycraft.kr>
|
I have updated the code to retrieve the issuer only after narrowing the scope to what can be guaranteed as a validated chain by OpenSSL. I understand you may be busy, but I would appreciate it if you could review it once again. |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces new functionality to expose the issuer certificate's SHA-256 hash and serial number from the peer's TLS certificate chain. The changes are exposed through access log formatters (%DOWNSTREAM_PEER_ISSUER_FINGERPRINT_256%, %DOWNSTREAM_PEER_ISSUER_SERIAL%) and the Lua filter API.
The implementation is robust, using X509_verify_cert to reconstruct the validated chain and reliably identify the issuer certificate. The code is well-structured, with new utility functions to avoid duplication, and is accompanied by a comprehensive set of unit and integration tests that cover various scenarios, including complex certificate chains.
My only feedback is a minor suggestion to improve the precision of the documentation for the new features to more accurately reflect the implementation logic.
Note: Security Review did not run due to the size of the PR.
ggreenway
left a comment
There was a problem hiding this comment.
I'm uncomfortable with the approach of re-verifying the chain, because the certificate validator is an extension, and there's no way to know how it validates and builds a chain, and thus there's no way to ensure that this code performs the same validation.
It would be safer to store the validated chain in the validator, but that will have tradeoffs of extra memory use, although that may be acceptable. If you pursue that route, it would be helpful to have some measurement of the memory overhead involved.
/wait
Previously, sha256PeerCertificateIssuerDigest and serialNumberPeerCertificateIssuer re-verified the peer certificate chain in getIssuerFromValidatedChain() to obtain the issuer cert. This is unsafe because CertValidator is an extension point and custom validators may build chains differently than the re-verification logic. Instead, capture the validated chain at verification time and store it per-connection. Each CertValidator now populates validated_chain in ValidationResults after successful X509_verify_cert(). The chain is passed through SslExtendedSocketInfo to SslHandshakerImpl, where ConnectionInfoImplBase::validatedPeerIssuer() retrieves the issuer. Changes: - Add validated_chain field to ValidationResults - Add setValidatedCertChain() to SslExtendedSocketInfo interface - Store validated chain in SslHandshakerImpl, expose via validatedPeerIssuer() override - Capture verified chain in DefaultCertValidator and SPIFFEValidator - Remove getIssuerFromValidatedChain() re-verification logic Signed-off-by: stias <seokjun.yang@mycraft.kr>
Memory overhead measurement
The X509 objects themselves are already held in memory by BoringSSL's SSL session state; we only add a reference. Benchmark validationA micro-benchmark (
The measured overhead is ~56 bytes/connection — slightly above the theoretical 48 bytes due to tcmalloc's allocation size class rounding (the vector's internal buffer of 24 bytes is rounded up to 32 bytes by the allocator).
|
Add a micro-benchmark to measure per-connection memory overhead of storing the validated certificate chain via X509_up_ref(). This provides concrete numbers to justify the memory tradeoff of caching the chain instead of re-verifying it. Measured ~56 bytes/connection for a typical 3-cert chain (leaf + intermediate + root), which amounts to ~590KB at 10K concurrent mTLS connections. Signed-off-by: stias <seokjun.yang@mycraft.kr>
|
@ggreenway Could you please take another look at the PR at your convenience? |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces new functionalities to expose the issuer certificate hash and serial number from the peer certificate chain in TLS connections. It includes changes across multiple files, adding new methods to retrieve the issuer's SHA256 fingerprint and serial number, exposing these in Lua scripts, and adding access log formatter commands. The changes also include new unit tests and updates to documentation.
Note: Security Review is unavailable for this PR.
I am having trouble creating individual review comments. Click here to see my feedback.
source/common/tls/connection_info_impl_base.cc (125-130)
This logic to compute the SHA256 digest is duplicated in Utility::getSha256DigestFromCertificate. Consider calling the utility function directly to avoid code duplication.
if (!cert) {
return std::string{};
}
std::vector<uint8_t> computed_hash(SHA256_DIGEST_LENGTH);
unsigned int n;
X509_digest(cert.get(), EVP_sha256(), computed_hash.data(), &n);
RELEASE_ASSERT(n == computed_hash.size(), "");
return Hex::encode(computed_hash);References
- Avoid code duplication by reusing existing utility functions.
source/common/tls/connection_info_impl_base.cc (125)
Call Utility::getSha256DigestFromCertificate(*cert) instead.
if (!cert) {
return std::string{};
}
return Utility::getSha256DigestFromCertificate(*cert);References
- Avoid code duplication by reusing existing utility functions.
source/common/tls/connection_info_impl_base.cc (150)
Call Utility::getSha1DigestFromCertificate(*cert) instead.
if (!cert) {
return std::string{};
}
return Utility::getSha1DigestFromCertificate(*cert);References
- Avoid code duplication by reusing existing utility functions.
source/common/tls/connection_info_impl_base.cc (177-182)
This logic to compute the SHA1 digest is duplicated in Utility::getSha1DigestFromCertificate. Consider calling the utility function directly to avoid code duplication.
return Utility::mapX509Stack(*cert_chain, [](X509& cert) -> std::string {
std::vector<uint8_t> computed_hash(SHA_DIGEST_LENGTH);
unsigned int n;
X509_digest(&cert, EVP_sha1(), computed_hash.data(), &n);
RELEASE_ASSERT(n == computed_hash.size(), "");
return Hex::encode(computed_hash);
});References
- Avoid code duplication by reusing existing utility functions.
ggreenway
left a comment
There was a problem hiding this comment.
This is looking much better; I like the approach of saving the validated chain. It removes that entire class of security issue.
/gemini review
/wait
configs/mtls_issuer_test.yaml
Outdated
| @@ -0,0 +1,94 @@ | |||
| static_resources: | |||
There was a problem hiding this comment.
This doesn't need to be added to the repo
| ``%DOWNSTREAM_PEER_ISSUER_FINGERPRINT_256%`` | ||
| HTTP/TCP/THRIFT | ||
| The hex-encoded SHA256 fingerprint of the direct issuer (CA) certificate from the downstream | ||
| TLS peer certificate chain. The issuer is identified by searching the chain for a certificate |
There was a problem hiding this comment.
Update docs; this is the verified issuer
| X509* cert = sk_X509_value(verified_chain, i); | ||
| X509_up_ref(cert); | ||
| validated_chain.emplace_back(cert); |
There was a problem hiding this comment.
| X509* cert = sk_X509_value(verified_chain, i); | |
| X509_up_ref(cert); | |
| validated_chain.emplace_back(cert); | |
| X509* cert = sk_X509_value(verified_chain, i); | |
| validated_chain.emplace_back(bssl::UpRef(cert)); |
| } | ||
|
|
||
| STACK_OF(X509)* verified_chain = X509_STORE_CTX_get0_chain(ctx.get()); | ||
| if (verified_chain != nullptr) { |
There was a problem hiding this comment.
Remove this check; this cannot be nullptr if verification succeeded
| // There must be an version of this function for each type possible in variant `CachedValue`. | ||
| bool shouldRecalculateCachedEntry(const std::string& str) { return str.empty(); } | ||
| bool shouldRecalculateCachedEntry(const std::vector<std::string>& vec) { return vec.empty(); } | ||
|
|
| X509_up_ref(cert); | ||
| validated_chain.emplace_back(cert); |
| return cert; | ||
| } | ||
|
|
||
| // Simulate what the implementation does: for each connection, store a |
There was a problem hiding this comment.
I think the bigger question is whether the X509 cert chain is kept in memory either way, or if this change causes the certificates to be kept in memory longer.
There was a problem hiding this comment.
This PR does not cause the certificates to live longer in memory. The verified chain from X509_STORE_CTX_get0_chain contains references to X509 objects that are already owned elsewhere, peer certs by BoringSSL's SSL session, and root CA certs by the X509_STORE. These objects already live for the connection lifetime or longer. The bssl::UpRef only bumps their reference counts. The only new per-connection cost is the std::vector of 3 pointers (~48-64 bytes for a typical leaf + intermediate + root chain).
…llptr check, update docs - Replace X509_up_ref + emplace_back with bssl::UpRef in default_validator.cc and spiffe_validator.cc - Remove unnecessary nullptr check on X509_STORE_CTX_get0_chain after successful X509_verify_cert in default_validator.cc - Update DOWNSTREAM_PEER_ISSUER_FINGERPRINT_256 and DOWNSTREAM_PEER_ISSUER_SERIAL docs to clarify these use the verified issuer from the validated chain - Remove stray blank line in connection_info_impl_base.cc Signed-off-by: stias <seokjun.yang@mycraft.kr>
Add issuerPeerCertificateHash() and issuerPeerCertificateSerial() to the SSL connection info stack, exposing SHA256 fingerprint and serial number of the direct issuer (CA) certificate from the peer's TLS certificate chain.
Changes:
Commit Message:
Additional Description:
Risk Level:
Testing:
Docs Changes:
Release Notes:
Platform Specific Features:
[Optional Runtime guard:]
[Optional Fixes #Issue]
[Optional Fixes commit #PR or SHA]
[Optional Deprecated:]
[Optional API Considerations:]