http-pqc-j17: validate certificate chains in the custom trust manager - #558
Conversation
HybridPqcX509TrustManager ran its own certificate checks instead of delegating, and registering it replaced the trust manager Quarkus builds from the configured truststore. Client authentication was therefore effectively unauthenticated: any self-signed certificate carrying the three Chimera extensions was accepted, as were expired certificates, and checkServerTrusted had an empty body that trusted every chain. The ML-DSA-65 signature covered only the subject DN, so it could be lifted onto another certificate along with that DN. - HybridPqcTrustManagerCustomizer: retrieve the trust manager Quarkus built from quarkus.http.ssl.certificate.trust-store-file and pass it to HybridPqcX509TrustManager as a delegate instead of discarding it. Fail startup if no truststore is configured, rather than leaving the PQC check as the only barrier. - HybridPqcX509TrustManager: delegate chain, trust anchor and validity-period validation, then verify the ML-DSA-65 signature on each certificate in the chain. Implement checkServerTrusted, and return the configured anchors from getAcceptedIssuers. - CertificatesUtil: verify the alternative signature with the issuer's ML-DSA-65 public key using X509CertificateHolder.isAlternativeSignatureValid, which covers the whole TBSCertificate. Drop the cert.verify(own public key) call, which established nothing. - HybridCertificateGenerator: add a hybrid CA holding an RSA and an ML-DSA-65 keypair, and issue the server and client certificates from it, signing each with both keys so that forging RSA alone cannot mint an accepted certificate. The truststores now hold the CA. Also add subject alternative names to the server certificate and write the CA out as ca-cert.pem. - README.adoc, PQC-EXPLANATION.adoc: correct the claims about what is validated and what is quantum-safe, use curl --cacert instead of -k, and drop the pinned BouncyCastle version. - Tests: cover an untrusted hybrid certificate, an expired one, one issued by a different CA, one carrying extensions lifted from another certificate, and an RSA-only certificate issued by the trusted CA where the missing ML-DSA-65 signature is the only fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JiriOndrusek
left a comment
There was a problem hiding this comment.
Two observations about the trust manager for anyone adapting this beyond the demo.
This review covers conventions and security correctness only — it does not replace specialized tools such as CodeRabbit, Sourcery, or SonarCloud.
This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
| * Note that this example has no revocation checking (no CRL or OCSP), which a production deployment | ||
| * would need. | ||
| */ | ||
| public class HybridPqcX509TrustManager implements X509TrustManager { |
There was a problem hiding this comment.
Observation: X509TrustManager vs X509ExtendedTrustManager
This implements X509TrustManager, not X509ExtendedTrustManager. The extended interface adds 3-arg methods (checkServerTrusted(chain, authType, SSLEngine)) that carry the SSL context needed for endpoint identification (hostname verification).
For this example it is safe:
- Server-side
checkClientTrusteddoes not do hostname verification regardless. - JSSE's
SSLContextImpl.chooseTrustManager()wraps a plainX509TrustManagerinAbstractTrustManagerWrapper, which performs endpoint identification after calling the 2-arg method.
However, that safety net does not cover non-standard deployments (e.g. Netty's OpenSSL/tcnative provider, which bypasses SSLContext.init()). If someone adapts this for client-side TLS in such a setup, checkServerTrusted(chain, authType) on the delegate runs without the SSLEngine, so hostname verification is skipped — a valid certificate for evil.com would be accepted when connecting to good.com.
Implementing X509ExtendedTrustManager instead and delegating the 3-arg methods to delegate.checkServerTrusted(chain, authType, engine) before calling validateHybridChain would close this gap for all deployment scenarios.
There was a problem hiding this comment.
Agreed, and fixed in de263d6. The class now extends X509ExtendedTrustManager and delegates all four three-argument overloads to the matching delegate method, so the Socket/SSLEngine reaches the platform trust manager that performs endpoint identification.
HybridPqcTrustManagerCustomizer now also requires the platform trust manager to be an X509ExtendedTrustManager and fails startup otherwise, rather than quietly wrapping one that cannot verify hostnames. Every JDK provider has returned the extended form since Java 7, so failing there means something unusual is in play.
A test asserts the delegation rather than just the outcome: a recording delegate checks that each three-argument overload reaches the corresponding delegate method. Forwarding them to the two-argument method would validate the certificate just as well and pass a naive test while dropping the hostname check.
I also corrected the README, which had claimed hostname verification is lost outright — your point about AbstractTrustManagerWrapper is right, and the text now says so and names the bypass case instead.
Verified with mvn clean verify (21 tests) and mvn clean verify -Dnative (native build plus 5 native integration tests).
Claude Code on behalf of James Netherton
| X500Principal issuerName = cert.getIssuerX500Principal(); | ||
|
|
||
| for (X509Certificate candidate : chain) { | ||
| if (candidate != cert && candidate.getSubjectX500Principal().equals(issuerName)) { |
There was a problem hiding this comment.
Observation: DN-only issuer matching
findIssuer matches by X500Principal (the distinguished name) alone. The platform trust manager uses Authority Key Identifier (AKI) / Subject Key Identifier (SKI) for unambiguous issuer resolution, so the PQC layer can diverge from the chain the platform actually validated.
For this example's flat hierarchy (one CA, no intermediates) it is safe.
In a production PKI it breaks under:
- CA key rollover — truststore holds both old and new root CAs with the same DN but different ML-DSA keys.
getAcceptedIssuers()returns whichever first; if it picks the wrong one, the PQC check rejects a valid certificate. - Cross-certified CAs — two CAs sharing a DN, same problem.
- Malicious chain injection — an attacker sends
[leaf, rogue-intermediate]where the rogue has the right DN but the wrong alt public key. The platform ignores it (AKI/SKI mismatch), butfindIssuerpicks it because it matches the DN and appears first.
Using the AKI extension (OID 2.5.29.35) on the certificate under test to match the issuer's SKI would resolve all three.
There was a problem hiding this comment.
Agreed, and fixed in de263d6. findIssuer now resolves in three ordered steps: a candidate whose subject key identifier matches the certificate's authority key identifier, then the certificate itself when it is self-issued, then a candidate matching the issuer name alone for certificates predating the extensions. HybridCertificateGenerator now issues certificates carrying both extensions, which the matching needs. Preferring key-identifier matches also covers your third case — a certificate carrying only the right issuer name can no longer displace the one the platform built the path through.
Working through your key-rollover case turned up something I had missed on the first attempt: the leaf is not the hard part. A self-signed anchor carries no authority key identifier, so nothing distinguishes it from a same-DN sibling — and this example's keystores put the CA in the client's chain, so validateHybridChain checks the CA on every handshake. With a rolled-over CA in the truststore twice, the CA's own ML-DSA-65 signature was verified against the other CA's key. That is what the self-issued step exists for. The regression test sends each certificate both alone and with its CA, and the CA-in-chain assertions fail without it.
One thing worth being precise about: none of this was a bypass. The delegate validates the chain first, so a mis-resolved issuer causes a valid certificate to be rejected, not an invalid one accepted — and a peer only controls its own chain. The value is keeping this layer in step with the path the platform actually built.
Verified with mvn clean verify (21 tests) and mvn clean verify -Dnative (native build plus 5 native integration tests). Each new test was confirmed to fail without its corresponding fix.
Claude Code on behalf of James Netherton
…key identifier Addresses the two review observations on PR apache#558. - HybridPqcX509TrustManager extends X509ExtendedTrustManager rather than implementing X509TrustManager, and delegates the Socket and SSLEngine overloads. Those carry the connection context the platform needs for endpoint identification. JSSE wraps a plain X509TrustManager in one of its own that performs the hostname check afterwards, so a stack built around SSLContext stays safe, but one that bypasses it -- Netty's OpenSSL provider, for instance -- applies no such wrapper. Anyone adapting the class for client-side TLS there would have accepted a certificate valid for another host. - HybridPqcTrustManagerCustomizer requires the platform trust manager to be an X509ExtendedTrustManager, failing startup instead of quietly wrapping one that cannot verify hostnames. Every JDK provider has returned the extended form since Java 7. - findIssuer resolves in three ordered steps: a candidate whose subject key identifier matches the certificate's authority key identifier, then the certificate itself when it is self-issued, then a candidate matching the issuer name alone for certificates that predate the extensions. Matching on the name alone diverges from the path the platform actually built: a CA that has rolled its key sits in the truststore twice under one DN with two different ML-DSA-65 keys, and cross-certified CAs share a DN by design. Picking the wrong one rejects a certificate the delegate just accepted. The self-issued step matters because a trust anchor need carry no authority key identifier of its own, and peers do send their CA -- the keystores this example generates put it in the chain. Preferring the key identifier also stops a certificate that merely carries the right issuer name from displacing the one the platform built the path through, which was the third case raised in review. - HybridCertificateGenerator issues certificates carrying subject and authority key identifiers, which is what the matching above needs and is standard practice besides. - Tests: a recording delegate asserts each three-argument overload reaches the matching delegate method rather than being forwarded to the two-argument one, and a key-rollover case puts two CAs sharing a DN in the truststore and requires certificates from both to validate, sent both alone and with their CA in the chain. Each fails without the corresponding fix. - README.adoc, PQC-EXPLANATION.adoc: explain why the extended interface and the key identifier matter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
HybridPqcX509TrustManagerperformed its own certificate checks instead of delegating. Because registering a customX509TrustManagerreplaces the one Quarkus builds from the configured truststore — JSSE consults only the trust manager it is given — client authentication was effectively unauthenticated:CertificatesUtildidcert.verify(cert.getPublicKey()), a self-signature check that establishes nothing, andgetAcceptedIssuers()returned an empty array. The truststore configured inapplication.propertieswas never consulted.checkServerTrustedhad an empty body — the textbook trust-all trust manager, ready to be copied into an outbound client.This matters because the example's whole subject is TLS security, so its trust manager is exactly the code a reader will lift into their own project. I reproduced each of the four points against the running application before changing anything.
Changes
HybridPqcTrustManagerCustomizerretrieves the trust manager Quarkus built fromquarkus.http.ssl.certificate.trust-store-fileand passes it toHybridPqcX509TrustManageras a delegate instead of discarding it. Startup fails if no truststore is configured, rather than leaving the PQC check as the only barrier.HybridPqcX509TrustManagerdelegates chain, trust anchor and validity-period validation, then verifies the ML-DSA-65 signature on each certificate in the chain.checkServerTrustedis implemented andgetAcceptedIssuersreturns the configured anchors. The class Javadoc explains why the ordering matters, since that is the part worth copying.CertificatesUtilverifies the alternative signature with the issuer's ML-DSA-65 public key viaX509CertificateHolder.isAlternativeSignatureValid, which covers the wholeTBSCertificate. Verifying against a key carried by the certificate under test would authenticate nothing.HybridCertificateGeneratoradds a hybrid CA holding both an RSA and an ML-DSA-65 keypair, and issues the server and client certificates from it, signing each with both keys. The truststores hold the CA. This is what gives the post-quantum signature meaning: an attacker who could forge RSA still could not mint an accepted certificate without the CA's ML-DSA-65 key. BouncyCastle'sbuild(signer, isCritical, altSigner)produces the spec-correct extensions, replacing the hand-rolled version.curl -kto--cacert, and drop the pinned BouncyCastle version.The certificates were previously self-signed while declaring
Issuer: CN=PQC Hybrid CA, which is the hierarchyPQC-EXPLANATION.adocalready described. The CA makes the code match the documentation rather than the other way round.Tests
13 → 18 tests. The RSA-only client certificate is now issued by the same CA, so it passes chain validation and the missing ML-DSA-65 signature is the only fault — previously that test could have passed with the PQC check entirely broken. New cases cover an untrusted hybrid certificate, an expired one, one issued by a different CA, one whose issuer publishes no PQC key, and one carrying extensions lifted from another certificate. Cases that chain validation would reject first are unit-tested against
CertificatesUtildirectly, so they exercise the signature check rather than passing incidentally.Verification
mvn clean verifyon Java 17: 18/18 tests passmvn clean verify -Dnative(Mandrel 25): native build plus 5 native integration tests passmvn license:check formatter:validate impsort:checkcleancurlcommands in the README run against a livequarkus:devserver, including the documented failure caseClaude Code on behalf of James Netherton
🤖 Generated with Claude Code