Skip to content

http-pqc-j17: validate certificate chains in the custom trust manager - #558

Merged
jamesnetherton merged 2 commits into
apache:camel-quarkus-mainfrom
jamesnetherton:http-pqc-j17-trust-manager-chain-validation
Aug 7, 2026
Merged

http-pqc-j17: validate certificate chains in the custom trust manager#558
jamesnetherton merged 2 commits into
apache:camel-quarkus-mainfrom
jamesnetherton:http-pqc-j17-trust-manager-chain-validation

Conversation

@jamesnetherton

Copy link
Copy Markdown
Contributor

Problem

HybridPqcX509TrustManager performed its own certificate checks instead of delegating. Because registering a custom X509TrustManager replaces the one Quarkus builds from the configured truststore — JSSE consults only the trust manager it is given — client authentication was effectively unauthenticated:

  • Any self-signed certificate carrying the three Chimera extensions was accepted. CertificatesUtil did cert.verify(cert.getPublicKey()), a self-signature check that establishes nothing, and getAcceptedIssuers() returned an empty array. The truststore configured in application.properties was never consulted.
  • Expired certificates were accepted; nothing checked the validity period.
  • checkServerTrusted had an empty body — the textbook trust-all trust manager, ready to be copied into an outbound client.
  • The ML-DSA-65 signature was computed over the subject DN only, so the PQC extensions could be lifted onto another certificate along with that DN by someone who never held the ML-DSA key.

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

  • HybridPqcTrustManagerCustomizer retrieves the trust manager Quarkus built from quarkus.http.ssl.certificate.trust-store-file and passes it to HybridPqcX509TrustManager as a delegate instead of discarding it. Startup fails if no truststore is configured, rather than leaving the PQC check as the only barrier.
  • HybridPqcX509TrustManager delegates chain, trust anchor and validity-period validation, then verifies the ML-DSA-65 signature on each certificate in the chain. checkServerTrusted is implemented and getAcceptedIssuers returns the configured anchors. The class Javadoc explains why the ordering matters, since that is the part worth copying.
  • CertificatesUtil verifies the alternative signature with the issuer's ML-DSA-65 public key via X509CertificateHolder.isAlternativeSignatureValid, which covers the whole TBSCertificate. Verifying against a key carried by the certificate under test would authenticate nothing.
  • HybridCertificateGenerator adds 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's build(signer, isCritical, altSigner) produces the spec-correct extensions, replacing the hand-rolled version.
  • Docs correct the claims about what is validated and what is quantum-safe (the transport remains classical on Java 17 — only the certificate authentication is hybrid), switch curl -k to --cacert, and drop the pinned BouncyCastle version.

The certificates were previously self-signed while declaring Issuer: CN=PQC Hybrid CA, which is the hierarchy PQC-EXPLANATION.adoc already 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 CertificatesUtil directly, so they exercise the signature check rather than passing incidentally.

Verification

  • mvn clean verify on Java 17: 18/18 tests pass
  • mvn clean verify -Dnative (Mandrel 25): native build plus 5 native integration tests pass
  • mvn license:check formatter:validate impsort:check clean
  • Both curl commands in the README run against a live quarkus:dev server, including the documented failure case

Claude Code on behalf of James Netherton

🤖 Generated with Claude Code

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 JiriOndrusek 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.

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 {

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.

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 checkClientTrusted does not do hostname verification regardless.
  • JSSE's SSLContextImpl.chooseTrustManager() wraps a plain X509TrustManager in AbstractTrustManagerWrapper, 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.

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.

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)) {

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.

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), but findIssuer picks 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.

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.

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>
@jamesnetherton
jamesnetherton merged commit b1301e9 into apache:camel-quarkus-main Aug 7, 2026
4 checks passed
@jamesnetherton
jamesnetherton deleted the http-pqc-j17-trust-manager-chain-validation branch August 7, 2026 14:01
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.

2 participants