This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch camel-quarkus-main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus-examples.git
The following commit(s) were added to refs/heads/camel-quarkus-main by this
push:
new b1301e9d http-pqc-j17: validate certificate chains in the custom trust
manager
b1301e9d is described below
commit b1301e9d233ce2c31a8e2cdaf587971f87a8eec2
Author: James Netherton <[email protected]>
AuthorDate: Fri Aug 7 15:01:22 2026 +0100
http-pqc-j17: validate certificate chains in the custom trust manager
* http-pqc-j17: validate certificate chains in the custom trust manager
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) <[email protected]>
* http-pqc-j17: extend X509ExtendedTrustManager and resolve issuers by key
identifier
Addresses the two review observations on PR #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) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
http-pqc-j17/PQC-EXPLANATION.adoc | 65 ++--
http-pqc-j17/README.adoc | 95 ++++--
.../main/java/org/acme/http/pqc/PqcCamelRoute.java | 11 +-
.../certificates/HybridCertificateGenerator.java | 326 ++++++++++++---------
.../pqc/certificates/SecurityConfiguration.java | 4 +-
.../pqc/certificates/util/CertificatesUtil.java | 174 ++++-------
.../java/org/acme/http/pqc/crypto/ChimeraOids.java | 26 +-
.../HybridPqcTrustManagerCustomizer.java | 68 ++++-
.../trustmanager/HybridPqcX509TrustManager.java | 274 +++++++++++++++--
.../src/main/resources/application.properties | 4 +-
.../org/acme/http/pqc/CertificatesUtilTest.java | 121 ++++++++
.../java/org/acme/http/pqc/ForgedCertificates.java | 169 +++++++++++
.../test/java/org/acme/http/pqc/HttpPqcTest.java | 128 +++++---
.../http/pqc/HybridPqcX509TrustManagerTest.java | 269 +++++++++++++++++
14 files changed, 1317 insertions(+), 417 deletions(-)
diff --git a/http-pqc-j17/PQC-EXPLANATION.adoc
b/http-pqc-j17/PQC-EXPLANATION.adoc
index f29ef7e1..06ed5f83 100644
--- a/http-pqc-j17/PQC-EXPLANATION.adoc
+++ b/http-pqc-j17/PQC-EXPLANATION.adoc
@@ -191,21 +191,26 @@ This is a **workaround approach** when you can't modify
TLS behavior - demonstra
│ │ Serial: 1234567890 │ │
│ │ │ │
│ ┌─────────▼──────────┐ ┌─────────▼──────────┐ │
-│ │ RSA-2048 │ │ Extension OID │ │
-│ │ Public Key │ │ 2.5.29.72: │ │
-│ │ (Standard field) │ │ ML-DSA-65 Public │ │
-│ └────────────────────┘ │ Key │ │
+│ │ RSA-2048 │ │ CA Extension OID │ │
+│ │ Public Key │ │ 2.5.29.72: the │ │
+│ │ (Standard field) │ │ CA's ML-DSA-65 │ │
+│ └────────────────────┘ │ Public Key │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ SHA256withRSA │ │ Extension OID │ │
│ │ Signature │ │ 2.5.29.74: │ │
│ │ [RSA signature │ │ ML-DSA-65 │ │
-│ │ bytes] │ │ Signature bytes │ │
-│ └────────────────────┘ └────────────────────┘ │
+│ │ bytes] by the CA │ │ Signature bytes │ │
+│ └────────────────────┘ │ by the CA │ │
+│ └────────────────────┘ │
│ │
│ Verification: BOTH signatures must be valid! │
│ - Old systems: Check RSA only ✓ │
│ - PQC systems: Check RSA ✓ AND ML-DSA ✓ │
+│ │
+│ Both are made by the CA, and the ML-DSA one is verified │
+│ with the CA's key from the CA certificate. Forging RSA │
+│ alone is therefore not enough to mint a certificate. │
└─────────────────────────────────────────────────────────────┘
----
@@ -260,13 +265,6 @@ This is a **workaround approach** when you can't modify
TLS behavior - demonstra
│ ┌─────────────────────────────┐ │
│ │ Extensions: │ │
│ │ ┌─────────────────────────┐ │ │
-│ │ │ OID 2.5.29.72: │ │ │
-│ │ │ altSubjectPublicKeyInfo │ │ │
-│ │ │ Algorithm: ML-DSA-65 │ │ ✓ Post-Quantum │
-│ │ │ Key: [3082071E...] │ │ │
-│ │ │ Size: 1976 bytes │ │ │
-│ │ └─────────────────────────┘ │ │
-│ │ ┌─────────────────────────┐ │ │
│ │ │ OID 2.5.29.73: │ │ │
│ │ │ altSignatureAlgorithm │ │ │
│ │ │ Algorithm: ML-DSA-65 │ │ │
@@ -276,8 +274,15 @@ This is a **workaround approach** when you can't modify
TLS behavior - demonstra
│ │ │ altSignatureValue │ │ │
│ │ │ Signature: [D1L1...] │ │ ✓ Post-Quantum │
│ │ │ Size: 3309 bytes │ │ │
+│ │ │ Made by the CA's │ │ │
+│ │ │ ML-DSA-65 key over │ │ │
+│ │ │ the whole cert body │ │ │
│ │ └─────────────────────────┘ │ │
│ └─────────────────────────────┘ │
+│ │
+│ The CA certificate carries OID 2.5.29.72 │
+│ (subjectAltPublicKeyInfo) holding the CA's │
+│ ML-DSA-65 public key, which verifies the above. │
│ ┌─────────────────────────────┐ │
│ │ Signature (PRIMARY): │ │
│ │ Algorithm: SHA256withRSA │ ✓ Classical │
@@ -324,8 +329,8 @@ Endpoint: https://localhost:8443/pqc/secure
┌──────────────┴──────────────┐
▼ ▼
┌───────────────┐ ┌────────────────┐
- │ Validate RSA │ │ Validate │
- │ Signature ✓ │ │ ML-DSA-65 ✓ │
+ │ Validate chain│ │ Validate │
+ │ to anchor ✓ │ │ ML-DSA-65 ✓ │
└───────┬───────┘ └────────┬───────┘
│ │
└──────────────┬──────────────┘
@@ -371,11 +376,11 @@ Endpoint: https://localhost:8443/pqc/secure
|**Protection Level**
|⚠️ Demo only, TLS is vulnerable
-|✅ Real security at TLS layer
+|⚠️ Authentication is hybrid, but the key exchange is still classical
|**When to use**
|Learning, experimenting
-|Production-ready hybrid solution
+|Migrating certificates ahead of a PQC-capable TLS stack
|**Code complexity**
|Simple (app endpoints)
@@ -387,12 +392,14 @@ Endpoint: https://localhost:8443/pqc/secure
* **Approach #2** is like having a regular lock on your door (RSA TLS) but
demonstrating fancy new locks inside your house (PQC demos)
* **Approach #3** is like installing a dual-lock system ON YOUR DOOR (Chimera
certificates validated at TLS handshake)
-Approach #3 provides actual security because:
+Approach #3 strengthens authentication because:
. Invalid certificates are **rejected during TLS handshake** before any data
is exchanged
-. Both RSA and ML-DSA-65 signatures must be valid to establish the connection
+. A certificate must chain to a trust anchor, be within its validity period,
**and** carry a valid ML-DSA-65 signature to establish the connection
. Works with the existing TLS infrastructure (backward compatible)
+It does **not** make the connection quantum-safe: the key exchange that
protects the session is still classical, so a recorded session can be decrypted
by a future quantum computer. Only the certificate authentication gains
post-quantum protection. Use `http-pqc-j21` for quantum-safe transport.
+
'''
== Why Chimera is the Best Migration Path
@@ -450,12 +457,16 @@ ADVANTAGE: One certificate works for everyone!
|✅ Yes
|**HybridPqcX509TrustManager**
-|Validates both RSA and ML-DSA-65 signatures at TLS layer
+|Delegates chain, trust anchor, expiry and endpoint identification checks to
the platform trust manager, then adds the ML-DSA-65 signature check
|✅ Yes
+|**HybridPqcTrustManagerCustomizer**
+|Registers the custom TrustManager with the Vert.x HTTP server, passing it the
platform trust manager to delegate to
+|N/A
+
|**SecurityConfiguration**
-|Configures custom TrustManager and client authentication
-|✅ Yes
+|Registers the BouncyCastle provider and generates the keystores at startup
+|N/A
|**/pqc/secure endpoint**
|Accessible only with valid hybrid PQC certificates
@@ -465,12 +476,12 @@ ADVANTAGE: One certificate works for everyone!
**Think of it like this:**
* The **transport tunnel** (HTTPS/TLS) still uses classical encryption (Java
17 limitation)
-* But the **authentication** (certificate validation) is quantum-safe with
dual signatures
+* But the **authentication** requires a second, post-quantum signature from
the CA, so forging RSA is not enough to be let in
* The **Chimera certificate** is like a dual-lock ID card: RSA works today,
ML-DSA-65 protects against future quantum attacks
-* Only clients with valid hybrid certificates can access the secure endpoint
+* Only clients with valid hybrid certificates that chain to the trusted CA can
access the secure endpoint
**When Java 21+ becomes standard:**
-* The transport itself can be upgraded to full PQC (e.g., Kyber for key
exchange)
-* Chimera certificates will work perfectly with full PQC TLS
-* Zero code changes needed - we're already PQC-ready!
+* The transport itself can be upgraded to a post-quantum key exchange (for
example ML-KEM)
+* Chimera certificates continue to work, since the PQC data rides in X.509
extensions
+* The custom TrustManager is no longer needed once the TLS stack validates PQC
signatures itself
diff --git a/http-pqc-j17/README.adoc b/http-pqc-j17/README.adoc
index 9f93ab4d..590606d2 100644
--- a/http-pqc-j17/README.adoc
+++ b/http-pqc-j17/README.adoc
@@ -19,25 +19,35 @@ Since **Java 17 doesn't support PQC algorithms natively in
TLS**, this example d
* **Chimera hybrid certificates**: Combines RSA-2048 + ML-DSA-65 (NIST FIPS
204)
* **X.509 extensions**: Standard extension OIDs (2.5.29.72-74) for alternative
signatures
-* **Application-level validation**: Custom X509TrustManager validates both RSA
and ML-DSA-65 signatures during TLS handshake
-* **BouncyCastle 1.83**: Provides ML-DSA-65 post-quantum signature algorithm
(JCA provider only)
+* **Application-level validation**: A custom X509ExtendedTrustManager adds an
ML-DSA-65 signature check on top of the standard certificate validation
performed during the TLS handshake
+* **BouncyCastle**: Provides the ML-DSA-65 post-quantum signature algorithm
(JCA provider only)
-This approach validates PQC signatures at the application level rather than
relying on Java's TLS stack. For native PQC TLS support (hybrid cipher suites
like X25519Kyber768), use Java 21+ with BouncyCastle JSSE provider.
+This approach validates PQC signatures at the application level rather than
relying on Java's TLS stack. For native PQC TLS support (hybrid key exchange
such as X25519MLKEM768), use Java 21+ with the BouncyCastle JSSE provider.
+
+IMPORTANT: Only the certificate authentication is hybrid here. The connection
itself is still protected by classical cryptography, because Java 17 cannot
negotiate a post-quantum key exchange. A recorded session remains vulnerable to
"harvest now, decrypt later"; see the `http-pqc-j21` example for quantum-safe
transport.
NOTE: For detailed diagrams and architecture explanation, see
link:PQC-EXPLANATION.adoc[PQC Visual Guide].
== Certificate Generation
-Hybrid certificates are automatically generated on every application startup.
No manual steps required.
+A hybrid certificate authority and the certificates it issues are generated on
every application startup. No manual steps required.
+
+The CA holds both an RSA and an ML-DSA-65 keypair, and signs every certificate
it issues with both. It publishes its ML-DSA-65 public key in its own
`subjectAltPublicKeyInfo` extension (OID 2.5.29.72), which is what relying
parties use to verify the alternative signature on the certificates it issued.
Generated files in `target/certs/`:
-* `server-hybrid-keystore.p12` - Server certificate with RSA + ML-DSA-65
-* `server-hybrid-truststore.p12` - Truststore for validating clients
+* `server-hybrid-keystore.p12` - Server certificate and key, with the CA
certificate in the chain
+* `server-hybrid-truststore.p12` - Trust anchor the server validates client
certificates against, holding the CA certificate
* `client-hybrid-keystore.p12` - Client certificate with PQC (for success test)
-* `client-hybrid-keystore-cert.pem` - Client certificate in PEM format (for
curl testing)
-* `client-hybrid-keystore-key.pem` - Client private key in PEM format (for
curl testing)
-* `client-rsa-only-keystore.p12` - Client certificate without PQC (for failure
test)
+* `client-hybrid-truststore.p12` - Trust anchor clients validate the server
against, holding the CA certificate
+* `client-rsa-only-keystore.p12` - Client certificate issued by the same CA
but without PQC (for failure test)
+* `ca-cert.pem` - CA certificate in PEM format, for clients that cannot read
PKCS12 truststores (for example `curl --cacert`)
+* `client-hybrid-cert.pem` - Client certificate in PEM format (for curl
testing)
+* `client-hybrid-key.pem` - Client private key in PEM format (for curl testing)
+
+The server certificate is issued for `CN=localhost` with subject alternative
names `DNS:localhost` and `IP:127.0.0.1`, so clients can verify the server
identity rather than having to disable verification.
+
+Because the CA certificate is the trust anchor, an attacker who could forge
RSA signatures still could not mint a certificate this example accepts: they
would also need the CA's ML-DSA-65 private key. That is the property the hybrid
certificate is there to provide.
== Prerequisites
@@ -68,23 +78,38 @@ NOTE: With `client-auth=required`, the TLS handshake
rejects connections without
PEM files are automatically generated during application startup in
`target/certs/`:
-* `client-hybrid-keystore-cert.pem` - Hybrid certificate (RSA + ML-DSA-65)
-* `client-hybrid-keystore-key.pem` - Private key for hybrid certificate
+* `client-hybrid-cert.pem` - Hybrid certificate (RSA + ML-DSA-65)
+* `client-hybrid-key.pem` - Private key for hybrid certificate
+* `ca-cert.pem` - CA certificate, used by curl to verify the server
Test with hybrid certificate (should succeed):
[source,shell]
----
-curl --cert target/certs/client-hybrid-keystore-cert.pem \
- --key target/certs/client-hybrid-keystore-key.pem \
- -k \
+curl --cert target/certs/client-hybrid-cert.pem \
+ --key target/certs/client-hybrid-key.pem \
+ --cacert target/certs/ca-cert.pem \
https://localhost:8443/pqc/secure
✓ Hybrid PQC certificate validated at TLS layer!
-Your connection is quantum-safe.
-Both RSA and ML-DSA-65 signatures were validated during TLS handshake.
+
+Your certificate chained to a configured trust anchor and carried a valid
+ML-DSA-65 alternative signature. Both were checked during the TLS handshake.
+...
----
+Test with the RSA-only certificate. It is issued by the same CA, so it passes
chain validation; the handshake fails purely because it carries no ML-DSA-65
alternative signature:
+
+[source,shell]
+----
+curl --cert-type P12 \
+ --cert target/certs/client-rsa-only-keystore.p12:changeit \
+ --cacert target/certs/ca-cert.pem \
+ https://localhost:8443/pqc/secure
+----
+
+curl reports a TLS alert such as `certificate unknown` or `bad certificate`;
the exact wording depends on the TLS library it was built against. The
application log records the reason the certificate was refused.
+
== Package and Run the Application
Once you are done with developing you may want to package and run the
application.
@@ -130,7 +155,12 @@ The test suite validates:
* Hybrid keystores are generated automatically
* `/pqc/secure` rejects requests without client certificates
* `/pqc/secure` accepts hybrid certificates (RSA + ML-DSA-65)
-* `/pqc/secure` rejects RSA-only certificates missing PQC extensions
+* `/pqc/secure` rejects an RSA-only certificate issued by the same CA, where
the missing ML-DSA-65 signature is the only fault
+* `/pqc/secure` rejects a self-signed hybrid certificate that no trust anchor
vouches for, even though its PQC extensions are valid
+* `/pqc/secure` rejects an expired hybrid certificate
+* The ML-DSA-65 signature check rejects a certificate carrying PQC extensions
copied from another certificate
+* The ML-DSA-65 signature check rejects a certificate issued by a different
CA, and one whose issuer publishes no PQC public key
+* The trust manager rejects untrusted server certificates as well as client
ones, and advertises the configured trust anchors
For native mode testing:
@@ -141,7 +171,7 @@ mvn clean verify -Dnative
== How It Works (Java 17 Application-Level Validation)
-This example works around Java 17's lack of native PQC support by implementing
custom certificate validation at the application level, integrated into the TLS
handshake via a custom `X509TrustManager`.
+This example works around Java 17's lack of native PQC support by implementing
custom certificate validation at the application level, integrated into the TLS
handshake via a custom `X509ExtendedTrustManager`.
=== Certificate Structure
@@ -157,23 +187,32 @@ Both RSA and ML-DSA-65 signatures must validate for
authentication to succeed.
1. Client connects with TLS client certificate
2. Java's TLS stack invokes our custom `HybridPqcX509TrustManager` during
handshake
-3. `CertificatesUtil.validateHybridCertificate()` performs application-level
checks:
- - RSA signature (standard X.509 validation via Java crypto)
- - ML-DSA-65 algorithm OID (2.5.29.73) - manual extraction
- - ML-DSA-65 public key (2.5.29.72) - manual extraction
- - ML-DSA-65 signature (2.5.29.74) - manual verification via BouncyCastle
-4. If all checks pass, TLS handshake continues
-5. If any check fails, TLS handshake is rejected
+3. The trust manager delegates to the platform trust manager that Quarkus
built from the configured truststore, which performs the standard X.509 checks:
chain building, trust anchor lookup and validity period
+4. Only if those pass, `CertificatesUtil.validateHybridCertificate()` adds the
post-quantum checks for each certificate in the chain:
+ - ML-DSA-65 algorithm OID (2.5.29.73) is present and names ML-DSA-65
+ - ML-DSA-65 signature (2.5.29.74) is present
+ - The issuer's ML-DSA-65 public key (2.5.29.72) is located, either from the
next certificate in the chain or from the trust anchors, matching on the
authority key identifier rather than the issuer name alone
+ - The signature is verified by BouncyCastle over the whole certificate
body, using that issuer key
+5. If all checks pass, TLS handshake continues
+6. If any check fails, TLS handshake is rejected
-NOTE: This validation happens at the application level, not within Java's TLS
implementation. Java 17's TLS stack only handles the standard RSA certificate;
our custom code validates the PQC extensions.
+The verification key deliberately comes from the *issuer*, not from the
certificate being checked. Verifying an alternative signature against a key
carried by the same certificate authenticates nothing, since whoever produced
the certificate chose both. Finding that issuer by distinguished name alone is
not enough either: a CA that has rolled its key appears in the truststore twice
under one name with two different ML-DSA-65 keys, so the authority key
identifier is what tells them apart.
+
+IMPORTANT: The ordering in steps 3 and 4 is the point of this example.
Registering a custom trust manager *replaces* the platform one rather than
adding to it, because JSSE consults only the trust manager it is given. A
custom trust manager that performs its own checks without delegating therefore
accepts any certificate that satisfies those checks — including a self-signed
one that no trust anchor vouches for. Delegating first, then layering the PQC
check on top, is what keeps chain, an [...]
+
+NOTE: `HybridPqcX509TrustManager` extends `X509ExtendedTrustManager`, not
`X509TrustManager`. Only the extended interface receives the `Socket` or
`SSLEngine` for the connection, and that context is what the platform needs to
perform endpoint identification — hostname verification, for a client checking
a server. A wrapper implementing the plain interface loses it. JSSE compensates
by wrapping such a trust manager in one of its own that performs the check
afterwards, so a stack built aro [...]
+
+NOTE: The PQC part of the validation happens at the application level, not
within Java's TLS implementation. Java 17's TLS stack only understands the
standard RSA certificate; our custom code validates the PQC extensions.
== Important Notes
* **Java 17 Only**: This example is specifically designed for Java 17, which
lacks native PQC support in its TLS stack. It demonstrates application-level
validation as a workaround. Do not use this approach if you can upgrade to Java
21+.
-* **Development Only**: Certificates are regenerated on every startup. For
production, use persistent certificates from a trusted CA.
+* **Development Only**: The CA and the certificates it issues are regenerated
on every startup, and the CA is self-signed. For production, use persistent
certificates from a real CA, and keep the CA's ML-DSA-65 private key protected
as carefully as its RSA one — the post-quantum guarantee rests on it.
+
+* **No revocation checking**: This example performs no CRL or OCSP checks. A
production deployment needs them.
-* **BouncyCastle 1.83**: Uses standardized ML-DSA-65 naming per NIST FIPS 204
(effective August 2024). This example uses JCA provider only, not JSSE. Note
that ML-DSA (standardized) and Dilithium (pre-standard) are NOT interoperable.
+* **BouncyCastle**: Uses standardized ML-DSA-65 naming per NIST FIPS 204
(effective August 2024). This example uses the JCA provider only, not JSSE.
Note that ML-DSA (standardized) and Dilithium (pre-standard) are NOT
interoperable.
== Additional Resources
diff --git a/http-pqc-j17/src/main/java/org/acme/http/pqc/PqcCamelRoute.java
b/http-pqc-j17/src/main/java/org/acme/http/pqc/PqcCamelRoute.java
index 2d8f95ac..ae8fd795 100644
--- a/http-pqc-j17/src/main/java/org/acme/http/pqc/PqcCamelRoute.java
+++ b/http-pqc-j17/src/main/java/org/acme/http/pqc/PqcCamelRoute.java
@@ -33,10 +33,13 @@ public class PqcCamelRoute extends EndpointRouteBuilder {
.log("Processing request with validated hybrid PQC
certificate")
.setBody(constant(
"✓ Hybrid PQC certificate validated at TLS layer!\n\n"
+
- "Your connection is quantum-safe.\n" +
- "Both RSA and ML-DSA-65 signatures were
validated during TLS handshake.\n\n" +
- "This demonstrates TLS-layer validation using
a custom X509TrustManager.\n" +
- "Invalid or RSA-only certificates are rejected
during the TLS handshake.\n"))
+ "Your certificate chained to a configured
trust anchor and carried a valid\n" +
+ "ML-DSA-65 alternative signature. Both were
checked during the TLS handshake.\n\n" +
+ "This demonstrates TLS-layer validation using
a custom trust manager that\n" +
+ "adds a post-quantum signature check on top of
the standard X.509 checks.\n\n" +
+ "Note: the connection itself is protected by
classical cryptography. Java 17\n" +
+ "cannot negotiate a post-quantum key exchange,
so only the certificate\n" +
+ "authentication is hybrid here. See
http-pqc-j21 for quantum-safe transport.\n"))
.to(log("pqc-secure").showExchangePattern(false).showBodyType(false));
}
}
diff --git
a/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/HybridCertificateGenerator.java
b/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/HybridCertificateGenerator.java
index e5f7d8db..bc32becc 100644
---
a/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/HybridCertificateGenerator.java
+++
b/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/HybridCertificateGenerator.java
@@ -26,18 +26,20 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.SecureRandom;
-import java.security.Signature;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.acme.http.pqc.crypto.ChimeraOids;
-import org.bouncycastle.asn1.DERBitString;
import org.bouncycastle.asn1.x500.X500Name;
-import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.ContentSigner;
@@ -46,24 +48,40 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Utility class for generating and persisting Chimera hybrid certificates.
- * These certificates combine classical RSA with post-quantum ML-DSA-65
signatures
- * using X.509 extensions as specified in the BouncyCastle PQC Almanac.
+ * Generates a small Chimera hybrid certificate hierarchy: a certificate
authority holding both an RSA
+ * and an ML-DSA-65 keypair, which issues the server and client certificates.
*
* <p>
- * Certificates are automatically generated at application startup by
- * {@link SecurityConfiguration} if they don't already exist.
+ * Every certificate the CA issues is signed twice. The RSA signature goes in
the standard signature
+ * field, and the ML-DSA-65 signature goes in the {@code altSignatureValue}
extension, produced by
+ * BouncyCastle's {@link X509v3CertificateBuilder#build(ContentSigner,
boolean, ContentSigner)} which
+ * computes it over the {@code TBSCertificate}. Do not hand-roll that
signature over a single field
+ * such as the subject DN: a signature that does not cover the body proves
nothing about the rest of
+ * the certificate.
+ *
+ * <p>
+ * The CA publishes its ML-DSA-65 public key in its own {@code
subjectAltPublicKeyInfo} extension,
+ * which is what relying parties use to verify the alternative signature on
the certificates it
+ * issued. Because the CA certificate is the trust anchor, an attacker who can
forge RSA signatures
+ * still cannot mint a certificate that validates: they would also need the
CA's ML-DSA-65 private
+ * key. That is the property this example exists to demonstrate.
+ *
+ * <p>
+ * Certificates are generated at application startup by {@link
SecurityConfiguration}.
*/
public class HybridCertificateGenerator {
private static final Logger LOG =
LoggerFactory.getLogger(HybridCertificateGenerator.class);
- // Demo keystore password - DO NOT use in production
+ // WARNING: This password is hardcoded for DEMONSTRATION purposes only.
+ // In production, use environment variables, secrets management, or secure
configuration.
private static final String KEYSTORE_PASSWORD = "changeit";
- private static final String KEYSTORES_DIR = "target/certs";
+ private static final String CERT_DIR = "target/certs";
+ private static final String CA_DN = "CN=PQC Hybrid CA,O=Apache Camel
Quarkus,C=US";
/**
- * Certificate data holder for keypairs and certificates.
+ * A certificate together with the keypairs that belong to its subject.
For the CA, the ML-DSA-65
+ * keypair is the one used to sign the certificates it issues.
*/
public static class CertificateData {
public final KeyPair rsaKeyPair;
@@ -78,170 +96,210 @@ public class HybridCertificateGenerator {
}
/**
- * Generates a Chimera hybrid certificate combining RSA and ML-DSA-65.
+ * Generates the CA, the certificates it issues, and the keystores and
truststores holding them.
+ */
+ public static void generateKeystores() throws Exception {
+ CertificateData ca = generateCertificateAuthority();
+ LOG.info("Hybrid CA created: {}",
ca.certificate.getSubjectX500Principal());
+
+ // The server certificate carries subject alternative names for
localhost and 127.0.0.1 so that
+ // clients can verify the server identity instead of having to disable
verification
+ GeneralNames serverAltNames = new GeneralNames(new GeneralName[] {
+ new GeneralName(GeneralName.dNSName, "localhost"),
+ new GeneralName(GeneralName.iPAddress, "127.0.0.1") });
+
+ CertificateData server = issueCertificate(ca, "localhost", true,
serverAltNames);
+ CertificateData clientHybrid = issueCertificate(ca, "client-hybrid",
true, null);
+
+ // Issued by the same CA, but without the ML-DSA-65 alternative
signature. It therefore passes
+ // chain validation and is rejected solely by the post-quantum check.
+ CertificateData clientRsaOnly = issueCertificate(ca,
"client-rsa-only", false, null);
+
+ saveKeyStore(Paths.get(CERT_DIR, "server-hybrid-keystore.p12"),
server, ca, "server");
+ saveKeyStore(Paths.get(CERT_DIR, "client-hybrid-keystore.p12"),
clientHybrid, ca, "client");
+ saveKeyStore(Paths.get(CERT_DIR, "client-rsa-only-keystore.p12"),
clientRsaOnly, ca, "client");
+
+ // Both sides validate the peer's certificate chain against the CA,
which is the trust anchor
+ saveTrustStore(Paths.get(CERT_DIR, "server-hybrid-truststore.p12"),
ca.certificate, "pqc-hybrid-ca");
+ saveTrustStore(Paths.get(CERT_DIR, "client-hybrid-truststore.p12"),
ca.certificate, "pqc-hybrid-ca");
+
+ // PEM files for tools that cannot read PKCS12, such as curl
+ savePem(Paths.get(CERT_DIR, "ca-cert.pem"), ca.certificate);
+ savePem(Paths.get(CERT_DIR, "client-hybrid-cert.pem"),
clientHybrid.certificate);
+ savePem(Paths.get(CERT_DIR, "client-hybrid-key.pem"),
clientHybrid.rsaKeyPair.getPrivate());
+ }
+
+ /**
+ * Generates the self-signed hybrid certificate authority.
*
- * @param commonName The CN for the certificate subject
- * @param includeAltSignature Whether to include the ML-DSA-65
alternative signature
- * @return CertificateData containing keypairs and
certificate
+ * <p>
+ * The CA publishes its ML-DSA-65 public key in the {@code
subjectAltPublicKeyInfo} extension so
+ * that verifiers can check the alternative signature on the certificates
it issues.
+ *
+ * @return CertificateData whose keypairs are the CA's signing keys
*/
- public static CertificateData generateChimeraCertificate(String
commonName, boolean includeAltSignature)
- throws Exception {
- LOG.debug("Generating Chimera hybrid certificate for CN={},
includeAltSignature={}",
- commonName, includeAltSignature);
+ public static CertificateData generateCertificateAuthority() throws
Exception {
+ LOG.debug("Generating hybrid certificate authority");
- // Generate RSA keypair (classical algorithm)
- KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA");
- rsaKpg.initialize(2048, new SecureRandom());
- KeyPair rsaKeyPair = rsaKpg.generateKeyPair();
+ KeyPair rsaKeyPair = generateRsaKeyPair();
+ KeyPair mlDsaKeyPair = generateMlDsaKeyPair();
+ X500Name caName = new X500Name(CA_DN);
+
+ X509v3CertificateBuilder certBuilder = certificateBuilder(caName,
caName, rsaKeyPair.getPublic());
+ certBuilder.addExtension(Extension.basicConstraints, true, new
BasicConstraints(true));
+
+ // The subject key identifier is how relying parties tell two CAs
apart when they share a
+ // distinguished name, which happens whenever a CA rolls its key
+ certBuilder.addExtension(Extension.subjectKeyIdentifier, false,
+ new
JcaX509ExtensionUtils().createSubjectKeyIdentifier(rsaKeyPair.getPublic()));
+
+ // Publish the CA's ML-DSA-65 public key: this is the key that
verifies the alternative
+ // signature on every certificate the CA issues
+ certBuilder.addExtension(ChimeraOids.SUBJECT_ALT_PUBLIC_KEY_INFO,
false,
+
SubjectPublicKeyInfo.getInstance(mlDsaKeyPair.getPublic().getEncoded()));
- // Generate ML-DSA-65 keypair (PQC algorithm - NIST FIPS 204)
- KeyPairGenerator mlDsaKpg = KeyPairGenerator.getInstance("ML-DSA-65",
"BC");
- KeyPair mlDsaKeyPair = mlDsaKpg.generateKeyPair();
+ // Self-signed with both of its own keys
+ X509CertificateHolder certHolder = certBuilder.build(
+ rsaSigner(rsaKeyPair), false, mlDsaSigner(mlDsaKeyPair));
+
+ return new CertificateData(rsaKeyPair, mlDsaKeyPair,
toX509Certificate(certHolder));
+ }
+
+ /**
+ * Issues a certificate signed by the given authority.
+ *
+ * @param issuer The CA whose RSA and ML-DSA-65 keys sign
the certificate
+ * @param commonName The CN for the certificate subject
+ * @param includeAltSignature Whether to add the CA's ML-DSA-65
alternative signature. Passing
+ * {@code false} produces a classical,
RSA-only certificate.
+ * @param subjectAltNames Subject alternative names to add, or {@code
null} for none
+ * @return CertificateData holding the subject's own
RSA keypair
+ */
+ public static CertificateData issueCertificate(CertificateData issuer,
String commonName,
+ boolean includeAltSignature, GeneralNames subjectAltNames) throws
Exception {
+ LOG.debug("Issuing certificate for CN={}, includeAltSignature={}",
commonName, includeAltSignature);
+
+ KeyPair rsaKeyPair = generateRsaKeyPair();
- // Build certificate
- X500Name issuer = new X500Name("CN=PQC Hybrid CA,O=Apache Camel
Quarkus,C=US");
X500Name subject = new X500Name("CN=" + commonName + ",O=Apache Camel
Quarkus,C=US");
- BigInteger serial = BigInteger.valueOf(System.currentTimeMillis());
+ X500Name issuerName =
X500Name.getInstance(issuer.certificate.getSubjectX500Principal().getEncoded());
+
+ X509v3CertificateBuilder certBuilder = certificateBuilder(issuerName,
subject, rsaKeyPair.getPublic());
+ certBuilder.addExtension(Extension.basicConstraints, true, new
BasicConstraints(false));
+
+ // The authority key identifier names the key that signed this
certificate, so a verifier can
+ // pick the right issuer instead of guessing from the distinguished
name
+ JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils();
+ certBuilder.addExtension(Extension.authorityKeyIdentifier, false,
+
extensionUtils.createAuthorityKeyIdentifier(issuer.certificate));
+ certBuilder.addExtension(Extension.subjectKeyIdentifier, false,
+
extensionUtils.createSubjectKeyIdentifier(rsaKeyPair.getPublic()));
+
+ if (subjectAltNames != null) {
+ certBuilder.addExtension(Extension.subjectAlternativeName, false,
subjectAltNames);
+ }
+
+ X509CertificateHolder certHolder;
+ if (includeAltSignature) {
+ // BouncyCastle adds the altSignatureAlgorithm and
altSignatureValue extensions itself,
+ // signing the TBSCertificate with the CA's ML-DSA-65 key so that
the signature covers the
+ // whole certificate body
+ certHolder = certBuilder.build(
+ rsaSigner(issuer.rsaKeyPair), false,
mlDsaSigner(issuer.mlDsaKeyPair));
+ } else {
+ certHolder = certBuilder.build(rsaSigner(issuer.rsaKeyPair));
+ }
+
+ return new CertificateData(rsaKeyPair, null,
toX509Certificate(certHolder));
+ }
+
+ private static X509v3CertificateBuilder certificateBuilder(X500Name
issuer, X500Name subject,
+ java.security.PublicKey subjectPublicKey) {
Date notBefore = new Date();
Date notAfter = new Date(System.currentTimeMillis() + 365L * 24 * 60 *
60 * 1000); // 1 year
- X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
+ return new JcaX509v3CertificateBuilder(
issuer,
- serial,
+ new BigInteger(64, new SecureRandom()),
notBefore,
notAfter,
subject,
- rsaKeyPair.getPublic());
+ subjectPublicKey);
+ }
- if (includeAltSignature) {
- // Add ML-DSA-65 alternative public key (Chimera extension)
- SubjectPublicKeyInfo mlDsaPubKeyInfo = SubjectPublicKeyInfo
- .getInstance(mlDsaKeyPair.getPublic().getEncoded());
- certBuilder.addExtension(ChimeraOids.SUBJECT_ALT_PUBLIC_KEY_INFO,
false, mlDsaPubKeyInfo);
-
- // Add alternative signature algorithm (Chimera extension)
- AlgorithmIdentifier mlDsaSigAlg = new
AlgorithmIdentifier(ChimeraOids.ML_DSA_65);
- certBuilder.addExtension(ChimeraOids.ALT_SIGNATURE_ALGORITHM,
false, mlDsaSigAlg);
-
- // Generate ML-DSA-65 alternative signature (Chimera extension)
- Signature mlDsaSig = Signature.getInstance("ML-DSA-65", "BC");
- mlDsaSig.initSign(mlDsaKeyPair.getPrivate());
- mlDsaSig.update(subject.getEncoded()); // Sign subject DN as per
Chimera spec
- byte[] mlDsaSignature = mlDsaSig.sign();
- certBuilder.addExtension(ChimeraOids.ALT_SIGNATURE_VALUE, false,
new DERBitString(mlDsaSignature));
-
- LOG.debug("ML-DSA-65 extensions added to certificate");
- }
+ private static KeyPair generateRsaKeyPair() throws Exception {
+ KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA");
+ rsaKpg.initialize(2048, new SecureRandom());
+ return rsaKpg.generateKeyPair();
+ }
- // Sign with RSA (primary signature)
- ContentSigner rsaSigner = new
JcaContentSignerBuilder("SHA256withRSA").build(rsaKeyPair.getPrivate());
- X509CertificateHolder certHolder = certBuilder.build(rsaSigner);
+ private static KeyPair generateMlDsaKeyPair() throws Exception {
+ // ML-DSA-65 - NIST FIPS 204. Java 17 has no built-in implementation,
so BouncyCastle provides it
+ return KeyPairGenerator.getInstance("ML-DSA-65",
"BC").generateKeyPair();
+ }
- // Convert to X509Certificate
- X509Certificate certificate = new JcaX509CertificateConverter()
- .setProvider("BC")
- .getCertificate(certHolder);
+ private static ContentSigner rsaSigner(KeyPair keyPair) throws Exception {
+ return new
JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
+ }
- LOG.debug("Chimera certificate generated successfully for CN={}",
commonName);
+ private static ContentSigner mlDsaSigner(KeyPair keyPair) throws Exception
{
+ return new
JcaContentSignerBuilder("ML-DSA-65").setProvider("BC").build(keyPair.getPrivate());
+ }
- return new CertificateData(rsaKeyPair, mlDsaKeyPair, certificate);
+ private static X509Certificate toX509Certificate(X509CertificateHolder
holder) throws Exception {
+ return new
JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
}
/**
- * Generates a keystore with the specified certificate type.
- *
- * @param commonName The CN for the certificate
- * @param includeAltSignature Whether to include ML-DSA-65 extensions
- * @param keystorePath Path where keystore will be saved
- * @param alias Keystore entry alias
- * @param includeTruststore Whether to also create a truststore
- * @param savePemFiles Whether to also save certificate and key as
PEM files
+ * Saves a private key with its certificate chain. The CA certificate is
included in the chain so
+ * that the peer receives the full path during the handshake.
*/
- private static void generateKeystore(
- String commonName,
- boolean includeAltSignature,
- String keystorePath,
- String alias,
- boolean includeTruststore,
- boolean savePemFiles) throws Exception {
-
- CertificateData certData = generateChimeraCertificate(commonName,
includeAltSignature);
-
+ private static void saveKeyStore(Path path, CertificateData certData,
CertificateData ca, String alias)
+ throws Exception {
KeyStore keyStore = KeyStore.getInstance("PKCS12", "BC");
keyStore.load(null, null);
keyStore.setKeyEntry(alias,
certData.rsaKeyPair.getPrivate(),
KEYSTORE_PASSWORD.toCharArray(),
- new X509Certificate[] { certData.certificate });
-
- saveKeyStore(keyStore, keystorePath, KEYSTORE_PASSWORD);
- LOG.info("Keystore created: {}", keystorePath);
-
- if (includeTruststore) {
- String trustPath = keystorePath.replace("-keystore.p12",
"-truststore.p12");
- KeyStore trustStore = KeyStore.getInstance("PKCS12", "BC");
- trustStore.load(null, null);
- trustStore.setCertificateEntry(alias + "-ca",
certData.certificate);
- saveKeyStore(trustStore, trustPath, KEYSTORE_PASSWORD);
- LOG.info("Truststore created: {}", trustPath);
- }
+ new X509Certificate[] { certData.certificate, ca.certificate
});
- if (savePemFiles) {
- // Save certificate as PEM
- String certPemPath = keystorePath.replace(".p12", "-cert.pem");
- try (FileWriter certWriter = new FileWriter(certPemPath);
- JcaPEMWriter pemWriter = new JcaPEMWriter(certWriter)) {
- pemWriter.writeObject(certData.certificate);
- }
- LOG.info("Certificate PEM file created: {}", certPemPath);
-
- // Save private key as PEM
- String keyPemPath = keystorePath.replace(".p12", "-key.pem");
- try (FileWriter keyWriter = new FileWriter(keyPemPath);
- JcaPEMWriter pemWriter = new JcaPEMWriter(keyWriter)) {
- pemWriter.writeObject(certData.rsaKeyPair.getPrivate());
- }
- LOG.info("Private key PEM file created: {}", keyPemPath);
- }
+ store(keyStore, path);
+ LOG.info("Keystore created: {}", path);
}
- /**
- * Generates server hybrid keystore with RSA + ML-DSA-65 certificate.
- */
- public static void generateServerKeystore() throws Exception {
- generateKeystore("localhost", true, KEYSTORES_DIR +
"/server-hybrid-keystore.p12",
- "server", true, false);
+ private static void saveTrustStore(Path path, X509Certificate cert, String
alias) throws Exception {
+ KeyStore trustStore = KeyStore.getInstance("PKCS12", "BC");
+ trustStore.load(null, null);
+ trustStore.setCertificateEntry(alias, cert);
+
+ store(trustStore, path);
+ LOG.info("Truststore created: {}", path);
}
/**
- * Generates client hybrid keystore with RSA + ML-DSA-65 certificate.
- * Also saves PEM files for manual curl testing.
+ * Saves a KeyStore to disk, overwriting any existing file.
*/
- public static void generateClientHybridKeystore() throws Exception {
- generateKeystore("client-hybrid", true, KEYSTORES_DIR +
"/client-hybrid-keystore.p12",
- "client", false, true);
+ public static void store(KeyStore keyStore, Path path) throws Exception {
+ createParentDirectory(path);
+ try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
+ keyStore.store(fos, KEYSTORE_PASSWORD.toCharArray());
+ }
}
- /**
- * Generates client RSA-only keystore (no PQC extensions - for failure
test).
- */
- public static void generateClientRsaOnlyKeystore() throws Exception {
- generateKeystore("client-rsa-only", false, KEYSTORES_DIR +
"/client-rsa-only-keystore.p12",
- "client", false, false);
+ private static void savePem(Path path, Object object) throws Exception {
+ createParentDirectory(path);
+ try (FileWriter writer = new FileWriter(path.toFile());
+ JcaPEMWriter pemWriter = new JcaPEMWriter(writer)) {
+ pemWriter.writeObject(object);
+ }
+ LOG.info("PEM file created: {}", path);
}
- /**
- * Saves a KeyStore to disk, overwriting any existing file.
- */
- public static void saveKeyStore(KeyStore keyStore, String path, String
password) throws Exception {
- Path dirPath = Paths.get(path).getParent();
+ private static void createParentDirectory(Path path) throws Exception {
+ Path dirPath = path.getParent();
if (!Files.exists(dirPath)) {
Files.createDirectories(dirPath);
LOG.info("Created directory: {}", dirPath);
}
-
- try (FileOutputStream fos = new FileOutputStream(path)) {
- keyStore.store(fos, password.toCharArray());
- }
}
}
diff --git
a/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/SecurityConfiguration.java
b/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/SecurityConfiguration.java
index 44309e07..2710681e 100644
---
a/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/SecurityConfiguration.java
+++
b/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/SecurityConfiguration.java
@@ -42,9 +42,7 @@ public class SecurityConfiguration {
private void generateKeystores() {
try {
LOG.info("Generating fresh hybrid PQC keystores...");
- HybridCertificateGenerator.generateServerKeystore();
- HybridCertificateGenerator.generateClientHybridKeystore();
- HybridCertificateGenerator.generateClientRsaOnlyKeystore();
+ HybridCertificateGenerator.generateKeystores();
LOG.info("Hybrid PQC keystores generated successfully");
} catch (Exception e) {
LOG.error("Failed to generate hybrid PQC keystores", e);
diff --git
a/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/util/CertificatesUtil.java
b/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/util/CertificatesUtil.java
index 84c4d8c3..8fe018a3 100644
---
a/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/util/CertificatesUtil.java
+++
b/http-pqc-j17/src/main/java/org/acme/http/pqc/certificates/util/CertificatesUtil.java
@@ -16,31 +16,31 @@
*/
package org.acme.http.pqc.certificates.util;
-import java.io.IOException;
-import java.security.InvalidKeyException;
-import java.security.KeyFactory;
-import java.security.NoSuchAlgorithmException;
-import java.security.NoSuchProviderException;
-import java.security.PublicKey;
-import java.security.Signature;
-import java.security.SignatureException;
-import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.X509EncodedKeySpec;
import org.acme.http.pqc.crypto.ChimeraOids;
-import org.bouncycastle.asn1.ASN1BitString;
-import org.bouncycastle.asn1.ASN1OctetString;
-import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
+import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.operator.ContentVerifierProvider;
+import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Utility class for validating Chimera hybrid certificates.
- * Validates both RSA and ML-DSA-65 signatures using static methods.
+ * Utility class for validating the post-quantum half of a Chimera hybrid
certificate.
+ *
+ * <p>
+ * This covers <em>only</em> the ML-DSA-65 alternative signature. Chain
building, trust anchors and
+ * validity periods are the job of the platform trust manager, which
+ * {@code org.acme.http.pqc.trustmanager.HybridPqcX509TrustManager} runs first.
+ *
+ * <p>
+ * The alternative signature is verified with the <em>issuer's</em> ML-DSA-65
public key, taken from
+ * the issuer's {@code subjectAltPublicKeyInfo} extension. Verifying it
against a key carried by the
+ * certificate under test would authenticate nothing, since whoever produced
the certificate chose
+ * that key.
*/
public final class CertificatesUtil {
@@ -51,34 +51,31 @@ public final class CertificatesUtil {
}
/**
- * Validates a hybrid certificate by checking both RSA and ML-DSA-65
signatures.
+ * Verifies that a certificate carries a valid ML-DSA-65 alternative
signature made by its issuer.
*
* @param cert The certificate to validate
- * @throws CertificateValidationException if validation fails
+ * @param issuer The certificate of the issuing
authority, which must
+ * publish its ML-DSA-65 public key
in
+ * {@code subjectAltPublicKeyInfo}
+ * @throws CertificateValidationException if the PQC extensions are
missing, name a different
+ * algorithm, or the alternative
signature does not verify
*/
- public static void validateHybridCertificate(X509Certificate cert) throws
CertificateValidationException {
- LOG.debug("Validating hybrid certificate for subject: {}",
cert.getSubjectX500Principal());
+ public static void validateHybridCertificate(X509Certificate cert,
X509Certificate issuer)
+ throws CertificateValidationException {
+ LOG.debug("Validating hybrid certificate for subject: {} issued by:
{}",
+ cert.getSubjectX500Principal(),
issuer.getSubjectX500Principal());
try {
- // Verify RSA signature (standard X.509 verification)
- if (!verifyRsaSignature(cert)) {
- throw new CertificateValidationException("RSA signature
validation failed");
- }
+ X509CertificateHolder holder = new
X509CertificateHolder(cert.getEncoded());
- LOG.debug("RSA signature verified");
-
- // Verify alternative signature algorithm extension exists
- byte[] altSigAlgExt =
cert.getExtensionValue(ChimeraOids.ALT_SIGNATURE_ALGORITHM.getId());
- if (altSigAlgExt == null) {
+ // Verify the alternative signature algorithm extension exists and
is ML-DSA-65
+ Extension altSigAlg =
holder.getExtension(ChimeraOids.ALT_SIGNATURE_ALGORITHM);
+ if (altSigAlg == null) {
throw new CertificateValidationException(
"PQC signature algorithm extension missing (OID
2.5.29.73)");
}
- // Validate it's ML-DSA-65
- ASN1Primitive primitive =
ASN1Primitive.fromByteArray(altSigAlgExt);
- byte[] octets = ((ASN1OctetString) primitive).getOctets();
- AlgorithmIdentifier algId =
AlgorithmIdentifier.getInstance(octets);
-
+ AlgorithmIdentifier algId =
AlgorithmIdentifier.getInstance(altSigAlg.getParsedValue());
if (!ChimeraOids.ML_DSA_65.equals(algId.getAlgorithm())) {
throw new CertificateValidationException(
"Expected ML-DSA-65 algorithm OID, found: " +
algId.getAlgorithm());
@@ -86,27 +83,26 @@ public final class CertificatesUtil {
LOG.debug("ML-DSA-65 algorithm OID validated");
- // Extract and verify ML-DSA-65 signature
- PublicKey mlDsaPublicKey = extractMlDsaPublicKey(cert);
- if (mlDsaPublicKey == null) {
- throw new CertificateValidationException(
- "PQC public key extension missing (OID 2.5.29.72)");
- }
-
- byte[] mlDsaSignature = extractMlDsaSignature(cert);
- if (mlDsaSignature == null) {
+ if (holder.getExtension(ChimeraOids.ALT_SIGNATURE_VALUE) == null) {
throw new CertificateValidationException(
"PQC signature extension missing (OID 2.5.29.74)");
}
- if (!verifyMlDsaSignature(cert, mlDsaPublicKey, mlDsaSignature)) {
+ // The verification key comes from the issuer, not from the
certificate being checked
+ SubjectPublicKeyInfo mlDsaPublicKey = issuerAltPublicKey(issuer);
+
+ // isAlternativeSignatureValid checks the signature against the
DER-encoded TBSCertificate
+ // with the altSignatureValue extension removed, so it covers the
whole certificate body
+ ContentVerifierProvider verifier = new
JcaContentVerifierProviderBuilder()
+ .setProvider("BC")
+ .build(mlDsaPublicKey);
+
+ if (!holder.isAlternativeSignatureValid(verifier)) {
throw new CertificateValidationException("ML-DSA-65 signature
validation failed");
}
LOG.debug("ML-DSA-65 signature verified - hybrid certificate
valid");
- } catch (IOException e) {
- throw new CertificateValidationException("Failed to parse PQC
extensions", e);
} catch (CertificateValidationException e) {
LOG.warn("Certificate validation failed: {}", e.getMessage());
throw e;
@@ -118,83 +114,19 @@ public final class CertificatesUtil {
}
/**
- * Verifies the RSA signature using standard X.509 verification.
+ * Extracts the issuer's ML-DSA-65 public key from its {@code
subjectAltPublicKeyInfo} extension.
*/
- private static boolean verifyRsaSignature(X509Certificate cert) {
- try {
- // Self-signed certificate - verify with its own public key
- cert.verify(cert.getPublicKey());
- return true;
- } catch (CertificateException | NoSuchAlgorithmException |
InvalidKeyException | SignatureException
- | NoSuchProviderException e) {
- LOG.error("RSA signature verification failed", e);
- return false;
+ private static SubjectPublicKeyInfo issuerAltPublicKey(X509Certificate
issuer) throws Exception {
+ Extension altPublicKey = new X509CertificateHolder(issuer.getEncoded())
+ .getExtension(ChimeraOids.SUBJECT_ALT_PUBLIC_KEY_INFO);
+
+ if (altPublicKey == null) {
+ throw new CertificateValidationException(
+ "Issuer " + issuer.getSubjectX500Principal()
+ + " publishes no PQC public key (OID 2.5.29.72),
so the alternative signature "
+ + "cannot be verified");
}
- }
-
- /**
- * Extracts the ML-DSA-65 public key from the altSubjectPublicKeyInfo
extension.
- */
- private static PublicKey extractMlDsaPublicKey(X509Certificate cert) {
- try {
- byte[] extensionValue =
cert.getExtensionValue(ChimeraOids.SUBJECT_ALT_PUBLIC_KEY_INFO.getId());
- if (extensionValue == null) {
- return null;
- }
-
- // Extension value is wrapped in OCTET STRING
- ASN1Primitive primitive =
ASN1Primitive.fromByteArray(extensionValue);
- byte[] octets = ((ASN1OctetString) primitive).getOctets();
-
- // Parse SubjectPublicKeyInfo
- SubjectPublicKeyInfo spki =
SubjectPublicKeyInfo.getInstance(octets);
- // Convert to PublicKey using X509EncodedKeySpec
- KeyFactory keyFactory = KeyFactory.getInstance("ML-DSA-65", "BC");
- return keyFactory.generatePublic(new
X509EncodedKeySpec(spki.getEncoded()));
-
- } catch (IOException | NoSuchAlgorithmException |
InvalidKeySpecException | NoSuchProviderException e) {
- LOG.error("Failed to extract ML-DSA-65 public key", e);
- return null;
- }
- }
-
- /**
- * Extracts the ML-DSA-65 signature from the altSignatureValue extension.
- */
- private static byte[] extractMlDsaSignature(X509Certificate cert) {
- try {
- byte[] extensionValue =
cert.getExtensionValue(ChimeraOids.ALT_SIGNATURE_VALUE.getId());
- if (extensionValue == null) {
- return null;
- }
-
- // Extension value is wrapped in OCTET STRING
- ASN1Primitive primitive =
ASN1Primitive.fromByteArray(extensionValue);
- byte[] octets = ((ASN1OctetString) primitive).getOctets();
-
- // Parse as BIT STRING
- ASN1BitString bitString = ASN1BitString.getInstance(octets);
- return bitString.getBytes();
-
- } catch (IOException e) {
- LOG.error("Failed to extract ML-DSA-65 signature", e);
- return null;
- }
- }
-
- /**
- * Verifies the ML-DSA-65 signature.
- */
- private static boolean verifyMlDsaSignature(X509Certificate cert,
PublicKey pqcKey, byte[] signature) {
- try {
- Signature mlDsaVerify = Signature.getInstance("ML-DSA-65", "BC");
- mlDsaVerify.initVerify(pqcKey);
- mlDsaVerify.update(cert.getSubjectX500Principal().getEncoded());
- return mlDsaVerify.verify(signature);
- } catch (NoSuchAlgorithmException | InvalidKeyException |
SignatureException | NoSuchProviderException e) {
- LOG.error("ML-DSA-65 signature verification failed", e);
- return false;
- }
+ return SubjectPublicKeyInfo.getInstance(altPublicKey.getParsedValue());
}
}
diff --git
a/http-pqc-j17/src/main/java/org/acme/http/pqc/crypto/ChimeraOids.java
b/http-pqc-j17/src/main/java/org/acme/http/pqc/crypto/ChimeraOids.java
index d5fc254c..7f058fd0 100644
--- a/http-pqc-j17/src/main/java/org/acme/http/pqc/crypto/ChimeraOids.java
+++ b/http-pqc-j17/src/main/java/org/acme/http/pqc/crypto/ChimeraOids.java
@@ -17,6 +17,8 @@
package org.acme.http.pqc.crypto;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
+import org.bouncycastle.asn1.x509.Extension;
/**
* X.509 extension OIDs (Object Identifiers) for Chimera hybrid certificate
format.
@@ -41,9 +43,14 @@ import org.bouncycastle.asn1.ASN1ObjectIdentifier;
* </pre>
*
* <p>
- * <b>Why this matters:</b> Both signatures must be valid for authentication.
- * This provides quantum resistance (via ML-DSA-65) while maintaining backward
- * compatibility with standard TLS (via RSA).
+ * <b>Why this matters:</b> Both signatures must be valid for the certificate
to be accepted, so a
+ * certificate remains tamper-evident even against an attacker who can forge
RSA signatures. Standard
+ * TLS stacks that do not understand these extensions still verify the RSA
signature as usual, which
+ * is what makes the format backward compatible.
+ *
+ * <p>
+ * The constants below simply alias the equivalent BouncyCastle definitions;
they exist to gather the
+ * relevant references in one place rather than to redefine the OIDs.
*
* <h2>OID Hierarchy Explained:</h2>
* <ul>
@@ -89,7 +96,7 @@ public final class ChimeraOids {
* RFC 5280 - X.509 Certificate Extensions</a></li>
* </ul>
*/
- public static final ASN1ObjectIdentifier SUBJECT_ALT_PUBLIC_KEY_INFO = new
ASN1ObjectIdentifier("2.5.29.72");
+ public static final ASN1ObjectIdentifier SUBJECT_ALT_PUBLIC_KEY_INFO =
Extension.subjectAltPublicKeyInfo;
/**
* OID 2.5.29.73 - Alternative Signature Algorithm extension.
@@ -106,15 +113,16 @@ public final class ChimeraOids {
* <li><a href="https://oidref.com/2.5.29.73">OID Repository:
2.5.29.73</a></li>
* </ul>
*/
- public static final ASN1ObjectIdentifier ALT_SIGNATURE_ALGORITHM = new
ASN1ObjectIdentifier("2.5.29.73");
+ public static final ASN1ObjectIdentifier ALT_SIGNATURE_ALGORITHM =
Extension.altSignatureAlgorithm;
/**
* OID 2.5.29.74 - Alternative Signature Value extension.
* Contains the actual ML-DSA-65 digital signature bytes.
*
* <p>
- * This is the post-quantum signature that proves the certificate is
authentic
- * and hasn't been tampered with, computed using the ML-DSA-65 private key.
+ * This is the post-quantum signature over the certificate body (the
DER-encoded
+ * {@code TBSCertificate} with this extension removed), computed with the
ML-DSA-65 private key.
+ * Because it covers the whole body, it detects tampering with any field
of the certificate.
*
* <p>
* <b>Official References:</b>
@@ -124,7 +132,7 @@ public final class ChimeraOids {
* <li><a href="https://oidref.com/2.5.29.74">OID Repository:
2.5.29.74</a></li>
* </ul>
*/
- public static final ASN1ObjectIdentifier ALT_SIGNATURE_VALUE = new
ASN1ObjectIdentifier("2.5.29.74");
+ public static final ASN1ObjectIdentifier ALT_SIGNATURE_VALUE =
Extension.altSignatureValue;
/**
* OID 2.16.840.1.101.3.4.3.18 - ML-DSA-65 algorithm identifier (NIST FIPS
204).
@@ -159,7 +167,7 @@ public final class ChimeraOids {
* BouncyCastle Source: NIST ML-DSA OID definitions</a></li>
* </ul>
*/
- public static final ASN1ObjectIdentifier ML_DSA_65 = new
ASN1ObjectIdentifier("2.16.840.1.101.3.4.3.18");
+ public static final ASN1ObjectIdentifier ML_DSA_65 =
NISTObjectIdentifiers.id_ml_dsa_65;
private ChimeraOids() {
throw new AssertionError("Constants class cannot be instantiated");
diff --git
a/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcTrustManagerCustomizer.java
b/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcTrustManagerCustomizer.java
index c4c48dbf..343b7111 100644
---
a/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcTrustManagerCustomizer.java
+++
b/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcTrustManagerCustomizer.java
@@ -16,43 +16,83 @@
*/
package org.acme.http.pqc.trustmanager;
-import javax.net.ssl.X509TrustManager;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509ExtendedTrustManager;
import io.quarkus.vertx.http.HttpServerOptionsCustomizer;
+import io.vertx.core.Vertx;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.net.TrustOptions;
import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Quarkus CDI bean that customizes the Vert.x HTTP server to use a custom
TrustManager.
*
- * This customizer registers {@link HybridPqcX509TrustManager} with the HTTP
server,
- * enabling TLS-layer validation of hybrid PQC certificates (RSA + ML-DSA-65)
during
- * the TLS handshake.
- *
- * Uses Quarkus {@link HttpServerOptionsCustomizer} interface to integrate
with Vert.x.
+ * <p>
+ * Setting trust options replaces whatever the server would otherwise have
used, so the trust manager
+ * Quarkus built from {@code quarkus.http.ssl.certificate.trust-store-file} is
taken out of the
+ * handshake. To keep the standard chain and expiry checks, that trust manager
is retrieved here and
+ * passed to {@link HybridPqcX509TrustManager} as its delegate rather than
being discarded.
*/
@ApplicationScoped
public class HybridPqcTrustManagerCustomizer implements
HttpServerOptionsCustomizer {
private static final Logger LOG =
LoggerFactory.getLogger(HybridPqcTrustManagerCustomizer.class);
+ @Inject
+ Vertx vertx;
+
@Override
public void customizeHttpsServer(HttpServerOptions options) {
LOG.info("Registering custom hybrid PQC TrustManager for TLS-layer
validation...");
- // Create custom TrustManager
- X509TrustManager customTrustManager = new HybridPqcX509TrustManager();
+ X509ExtendedTrustManager platformTrustManager =
platformTrustManager(options);
+ X509ExtendedTrustManager customTrustManager = new
HybridPqcX509TrustManager(platformTrustManager);
- // Wrap the X509TrustManager into Vert.x TrustOptions
- TrustOptions trustOptions = TrustOptions.wrap(customTrustManager);
-
- // Register with Vert.x HTTP server using setTrustOptions
- options.setTrustOptions(trustOptions);
+ // Wrap the trust manager into Vert.x TrustOptions and register it
with the HTTP server
+ options.setTrustOptions(TrustOptions.wrap(customTrustManager));
LOG.info("Custom hybrid PQC TrustManager registered successfully");
- LOG.info(" Client certificates will be validated at TLS layer (RSA +
ML-DSA-65)");
+ LOG.info(" Client certificates must chain to a configured trust
anchor and carry a valid ML-DSA-65 signature");
+ }
+
+ /**
+ * Returns the trust manager Quarkus built from the configured truststore,
which performs the
+ * standard chain, trust anchor and validity-period checks.
+ *
+ * <p>
+ * An {@link X509ExtendedTrustManager} is required rather than a plain
+ * {@link javax.net.ssl.X509TrustManager}, because only the extended
interface can be handed the
+ * {@code Socket} or {@code SSLEngine} that endpoint identification needs.
Every JDK provider has
+ * returned the extended form since Java 7, so failing here means
something unusual is in play and
+ * is better than quietly wrapping a trust manager that cannot verify
hostnames.
+ */
+ private X509ExtendedTrustManager platformTrustManager(HttpServerOptions
options) {
+ TrustOptions trustOptions = options.getTrustOptions();
+ if (trustOptions == null) {
+ // Without a truststore there is nothing to validate certificate
chains against. Failing
+ // here is deliberate: continuing would leave the PQC check as the
only barrier, and that
+ // check cannot establish trust on its own.
+ throw new IllegalStateException(
+ "No truststore configured. Set
quarkus.http.ssl.certificate.trust-store-file so that "
+ + "client certificate chains can be validated
against a trust anchor.");
+ }
+
+ try {
+ TrustManagerFactory trustManagerFactory =
trustOptions.getTrustManagerFactory(vertx);
+ for (TrustManager trustManager :
trustManagerFactory.getTrustManagers()) {
+ if (trustManager instanceof X509ExtendedTrustManager) {
+ return (X509ExtendedTrustManager) trustManager;
+ }
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not obtain a trust manager
from the configured truststore", e);
+ }
+
+ throw new IllegalStateException("The configured truststore yielded no
X509ExtendedTrustManager");
}
}
diff --git
a/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcX509TrustManager.java
b/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcX509TrustManager.java
index 7d85ec9e..64f75a76 100644
---
a/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcX509TrustManager.java
+++
b/http-pqc-j17/src/main/java/org/acme/http/pqc/trustmanager/HybridPqcX509TrustManager.java
@@ -16,62 +16,276 @@
*/
package org.acme.http.pqc.trustmanager;
+import java.net.Socket;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.Objects;
-import javax.net.ssl.X509TrustManager;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.X509ExtendedTrustManager;
+import javax.security.auth.x500.X500Principal;
import org.acme.http.pqc.certificates.util.CertificateValidationException;
import org.acme.http.pqc.certificates.util.CertificatesUtil;
+import org.bouncycastle.asn1.ASN1OctetString;
+import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.SubjectKeyIdentifier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * Custom X509TrustManager that validates hybrid PQC certificates at the TLS
layer.
+ * Custom {@link X509ExtendedTrustManager} that adds hybrid PQC validation on
top of the standard
+ * checks.
*
- * This TrustManager validates both RSA and ML-DSA-65 signatures during the
TLS handshake,
- * rejecting connections with invalid or RSA-only certificates before the
application layer
- * sees the request.
+ * <p>
+ * The important part of this class is what it does <em>not</em> do itself.
Chain building, trust
+ * anchor lookup and validity-period checking are delegated to the platform
trust manager that
+ * Quarkus builds from the configured truststore; only once that has passed is
the ML-DSA-65
+ * alternative signature verified on top. Getting this ordering wrong is the
classic way to write a
+ * trust manager that accepts anything: a custom check on its own replaces the
platform checks rather
+ * than adding to them, because the JSSE handshake asks this class and nothing
else.
+ *
+ * <p>
+ * The effect is that a client certificate must chain to a trust anchor, be
inside its validity
+ * period, <em>and</em> carry a valid ML-DSA-65 alternative signature made by
its issuer. A
+ * self-signed certificate carrying well-formed PQC extensions is rejected,
because no anchor vouches
+ * for it, and a certificate issued by the trusted CA without an alternative
signature is rejected
+ * too.
+ *
+ * <p>
+ * {@link X509ExtendedTrustManager} is extended rather than {@link
javax.net.ssl.X509TrustManager}
+ * implemented, so that the {@code Socket} and {@code SSLEngine} overloads are
delegated as well.
+ * Those carry the connection context the platform needs for endpoint
identification, which is how
+ * hostname verification happens on the client side. A plain {@code
X509TrustManager} only gets the
+ * two-argument methods, so a wrapper that implements it silently drops
hostname verification for any
+ * TLS stack that does not apply the JSSE wrapper that would otherwise
compensate.
+ *
+ * <p>
+ * Note that this example has no revocation checking (no CRL or OCSP), which a
production deployment
+ * would need.
*/
-public class HybridPqcX509TrustManager implements X509TrustManager {
+public class HybridPqcX509TrustManager extends X509ExtendedTrustManager {
private static final Logger LOG =
LoggerFactory.getLogger(HybridPqcX509TrustManager.class);
+ private final X509ExtendedTrustManager delegate;
+
+ /**
+ * @param delegate the platform trust manager to perform chain, anchor,
expiry and endpoint
+ * identification checks
+ */
+ public HybridPqcX509TrustManager(X509ExtendedTrustManager delegate) {
+ this.delegate = Objects.requireNonNull(delegate, "delegate trust
manager is required");
+ }
+
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
- if (chain == null || chain.length == 0) {
- throw new CertificateException("Client certificate chain is
empty");
- }
+ requireChain(chain, "Client");
- X509Certificate clientCert = chain[0];
- LOG.debug("Validating client certificate at TLS layer: {}",
clientCert.getSubjectX500Principal());
+ // Standard X.509 validation first: chain, trust anchor, validity
period
+ delegate.checkClientTrusted(chain, authType);
- try {
- // Validate hybrid certificate - throws
CertificateValidationException on failure
- CertificatesUtil.validateHybridCertificate(clientCert);
- LOG.debug("Client certificate validated successfully at TLS layer
(RSA + ML-DSA-65)");
- } catch (CertificateValidationException e) {
- LOG.error("Hybrid PQC certificate validation failed: {}",
e.getMessage());
- throw new CertificateException("Validation failed: " +
e.getMessage(), e);
- }
+ validateHybridChain(chain, "Client");
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType,
Socket socket)
+ throws CertificateException {
+ requireChain(chain, "Client");
+ delegate.checkClientTrusted(chain, authType, socket);
+ validateHybridChain(chain, "Client");
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType,
SSLEngine engine)
+ throws CertificateException {
+ requireChain(chain, "Client");
+ delegate.checkClientTrusted(chain, authType, engine);
+ validateHybridChain(chain, "Client");
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType)
throws CertificateException {
- // Not implemented - this example only validates client certificates
- // (server-to-client authentication, not client-to-server)
- //
- // In mutual TLS where client validates server's hybrid certificate,
- // this method would implement similar validation logic.
- LOG.debug("Server certificate validation not implemented (client-auth
only)");
+ requireChain(chain, "Server");
+
+ // Standard X.509 validation first: chain, trust anchor, validity
period
+ delegate.checkServerTrusted(chain, authType);
+
+ validateHybridChain(chain, "Server");
+ }
+
+ /**
+ * The {@code Socket} and {@code SSLEngine} overloads are what let the
delegate perform endpoint
+ * identification, so they must be passed through rather than being
redirected to the two-argument
+ * method.
+ */
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType,
Socket socket)
+ throws CertificateException {
+ requireChain(chain, "Server");
+ delegate.checkServerTrusted(chain, authType, socket);
+ validateHybridChain(chain, "Server");
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType,
SSLEngine engine)
+ throws CertificateException {
+ requireChain(chain, "Server");
+ delegate.checkServerTrusted(chain, authType, engine);
+ validateHybridChain(chain, "Server");
}
@Override
public X509Certificate[] getAcceptedIssuers() {
- // Return empty array for self-signed certificates in this demo.
- //
- // In production with a CA hierarchy, this would return the
- // list of trusted CA certificates that can issue client certificates.
- return new X509Certificate[0];
+ // Delegate, so that the certificate authorities advertised during the
handshake are the ones
+ // in the configured truststore. Returning an empty array here tells
peers nothing about which
+ // issuers are acceptable.
+ return delegate.getAcceptedIssuers();
+ }
+
+ private static void requireChain(X509Certificate[] chain, String peer)
throws CertificateException {
+ if (chain == null || chain.length == 0) {
+ throw new CertificateException(peer + " certificate chain is
empty");
+ }
+ }
+
+ /**
+ * Verifies the ML-DSA-65 alternative signature on every certificate in
the chain, each against the
+ * ML-DSA-65 public key published by its issuer.
+ */
+ private void validateHybridChain(X509Certificate[] chain, String peer)
throws CertificateException {
+ for (X509Certificate cert : chain) {
+ LOG.debug("Validating {} certificate hybrid PQC extensions: {}",
peer, cert.getSubjectX500Principal());
+
+ X509Certificate issuer = findIssuer(cert, chain);
+ if (issuer == null) {
+ throw new CertificateException("Could not find the issuer of "
+ cert.getSubjectX500Principal()
+ + ", so its ML-DSA-65 signature cannot be verified");
+ }
+
+ try {
+ CertificatesUtil.validateHybridCertificate(cert, issuer);
+ } catch (CertificateValidationException e) {
+ LOG.error("Hybrid PQC certificate validation failed: {}",
e.getMessage());
+ throw new CertificateException("Validation failed: " +
e.getMessage(), e);
+ }
+ }
+
+ LOG.debug("{} certificate chain validated successfully (RSA chain +
ML-DSA-65)", peer);
+ }
+
+ /**
+ * Finds the certificate that issued {@code cert}, looking in the chain
the peer presented and then
+ * among the configured trust anchors. Peers may send only their own
certificate and leave the anchor
+ * to the relying party, or send the anchor along with it, so both need
checking.
+ *
+ * <p>
+ * Candidates matching on key identifier are preferred to candidates
matching on the issuer name
+ * alone, because the name is the weaker claim and anything the peer sends
can carry one. The chain
+ * has already been validated by the delegate at this point, so a
certificate found in it is one the
+ * platform trust manager accepted as part of a path to an anchor — but
not necessarily one it built
+ * the path <em>through</em>, since it ignores any certificate that does
not belong there.
+ */
+ private X509Certificate findIssuer(X509Certificate cert, X509Certificate[]
chain) {
+ X509Certificate[] anchors = delegate.getAcceptedIssuers();
+
+ // Key identifiers first, so that a candidate which merely shares the
issuer name cannot
+ // displace the one the platform actually built the path through
+ X509Certificate issuer = findIssuerAmong(cert, chain, anchors, true);
+ if (issuer != null) {
+ return issuer;
+ }
+
+ // A self-issued certificate is its own issuer. Trust anchors carry no
authority key
+ // identifier to match on, so without this the name fallback below
would settle for any
+ // same-DN sibling, and a rolled-over CA has one of those in the
truststore by definition
+ if (isSelfIssued(cert)) {
+ return cert;
+ }
+
+ return findIssuerAmong(cert, chain, anchors, false);
+ }
+
+ private static X509Certificate findIssuerAmong(X509Certificate cert,
X509Certificate[] chain,
+ X509Certificate[] anchors, boolean requireKeyIdentifier) {
+ for (X509Certificate candidate : chain) {
+ if (candidate != cert && isIssuerOf(candidate, cert,
requireKeyIdentifier)) {
+ return candidate;
+ }
+ }
+
+ for (X509Certificate anchor : anchors) {
+ if (isIssuerOf(anchor, cert, requireKeyIdentifier)) {
+ return anchor;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Decides whether {@code candidate} is the issuer of {@code cert}. Both
the authority key
+ * identifier and the issuer name must match when {@code
requireKeyIdentifier} is set; otherwise the
+ * name alone is enough.
+ *
+ * <p>
+ * Matching on the name alone is not enough on its own. Distinguished
names are not unique over time
+ * or across a federation: a CA that has rolled its key is present 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 makes this class reject a certificate the platform trust
manager just accepted. Matching
+ * the authority key identifier against the candidate's subject key
identifier, as the platform does
+ * when it builds the path, keeps the two in step. The name-only pass
exists for certificates that
+ * predate the extensions, RFC 5280 requiring neither of them on a
self-signed anchor.
+ */
+ private static boolean isIssuerOf(X509Certificate candidate,
X509Certificate cert,
+ boolean requireKeyIdentifier) {
+ X500Principal issuerName = cert.getIssuerX500Principal();
+ if (!candidate.getSubjectX500Principal().equals(issuerName)) {
+ return false;
+ }
+
+ if (!requireKeyIdentifier) {
+ return true;
+ }
+
+ try {
+ byte[] authorityKeyId = authorityKeyIdentifier(cert);
+ byte[] subjectKeyId = subjectKeyIdentifier(candidate);
+
+ return authorityKeyId != null && subjectKeyId != null &&
Arrays.equals(authorityKeyId, subjectKeyId);
+ } catch (IllegalArgumentException e) {
+ // Anything a peer sends can be malformed. A key identifier that
cannot be read is treated
+ // as one that is not there, leaving the issuer name as the only
thing left to match on
+ LOG.debug("Ignoring unparseable key identifier extension: {}",
e.getMessage());
+ return false;
+ }
+ }
+
+ private static boolean isSelfIssued(X509Certificate cert) {
+ return
cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal());
+ }
+
+ private static byte[] authorityKeyIdentifier(X509Certificate cert) {
+ byte[] encoded =
cert.getExtensionValue(Extension.authorityKeyIdentifier.getId());
+ return encoded == null
+ ? null
+ :
AuthorityKeyIdentifier.getInstance(unwrap(encoded)).getKeyIdentifier();
+ }
+
+ private static byte[] subjectKeyIdentifier(X509Certificate cert) {
+ byte[] encoded =
cert.getExtensionValue(Extension.subjectKeyIdentifier.getId());
+ return encoded == null
+ ? null
+ :
SubjectKeyIdentifier.getInstance(unwrap(encoded)).getKeyIdentifier();
+ }
+
+ /**
+ * {@link X509Certificate#getExtensionValue(String)} returns the extension
wrapped in a DER octet
+ * string, which has to be unwrapped before the value inside can be parsed.
+ */
+ private static byte[] unwrap(byte[] encoded) {
+ return ASN1OctetString.getInstance(encoded).getOctets();
}
}
diff --git a/http-pqc-j17/src/main/resources/application.properties
b/http-pqc-j17/src/main/resources/application.properties
index 90b2e0cd..cf7f5efd 100644
--- a/http-pqc-j17/src/main/resources/application.properties
+++ b/http-pqc-j17/src/main/resources/application.properties
@@ -33,7 +33,9 @@ quarkus.http.ssl.certificate.key-store-file-type = PKCS12
# Certificates must be valid hybrid PQC certificates (RSA + ML-DSA-65)
quarkus.http.ssl.client-auth = required
-# Truststore for client certificate validation (standard TLS layer)
+# Trust anchors for client certificate validation.
HybridPqcTrustManagerCustomizer passes the trust
+# manager built from this truststore to the custom TrustManager as its
delegate, so these anchors are
+# what client certificate chains are validated against.
quarkus.http.ssl.certificate.trust-store-file =
target/certs/server-hybrid-truststore.p12
quarkus.http.ssl.certificate.trust-store-password = changeit
quarkus.http.ssl.certificate.trust-store-file-type = PKCS12
diff --git
a/http-pqc-j17/src/test/java/org/acme/http/pqc/CertificatesUtilTest.java
b/http-pqc-j17/src/test/java/org/acme/http/pqc/CertificatesUtilTest.java
new file mode 100644
index 00000000..c617dbe9
--- /dev/null
+++ b/http-pqc-j17/src/test/java/org/acme/http/pqc/CertificatesUtilTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.acme.http.pqc;
+
+import java.security.Security;
+import java.security.cert.X509Certificate;
+
+import org.acme.http.pqc.certificates.HybridCertificateGenerator;
+import
org.acme.http.pqc.certificates.HybridCertificateGenerator.CertificateData;
+import org.acme.http.pqc.certificates.util.CertificateValidationException;
+import org.acme.http.pqc.certificates.util.CertificatesUtil;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests the ML-DSA-65 half of the validation in isolation.
+ *
+ * <p>
+ * Some of these cases cannot be reached through the TLS handshake: the
standard chain validation that
+ * {@code HybridPqcX509TrustManager} runs first rejects an untrusted
certificate before the PQC check
+ * is consulted, so an end-to-end test would pass even if this check were
broken.
+ */
+public class CertificatesUtilTest {
+
+ private static CertificateData ca;
+ private static X509Certificate leaf;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception {
+ if (Security.getProvider("BC") == null) {
+ Security.insertProviderAt(new BouncyCastleProvider(), 1);
+ }
+
+ ca = HybridCertificateGenerator.generateCertificateAuthority();
+ leaf = HybridCertificateGenerator.issueCertificate(ca,
"unit-test-leaf", true, null).certificate;
+ }
+
+ @Test
+ public void certificateIssuedByTheCaIsAccepted() {
+ assertDoesNotThrow(() ->
CertificatesUtil.validateHybridCertificate(leaf, ca.certificate));
+ }
+
+ @Test
+ public void selfSignedCaIsAcceptedAgainstItself() {
+ assertDoesNotThrow(() ->
CertificatesUtil.validateHybridCertificate(ca.certificate, ca.certificate));
+ }
+
+ @Test
+ public void certificateWithoutAltSignatureIsRejected() throws Exception {
+ X509Certificate rsaOnly = HybridCertificateGenerator
+ .issueCertificate(ca, "unit-test-rsa-only", false,
null).certificate;
+
+ CertificateValidationException e =
assertThrows(CertificateValidationException.class,
+ () -> CertificatesUtil.validateHybridCertificate(rsaOnly,
ca.certificate));
+ assertTrue(e.getMessage().contains("2.5.29.73"), "Unexpected message:
" + e.getMessage());
+ }
+
+ /**
+ * The signature must be verified with the issuer's key, so a certificate
issued by one CA must not
+ * validate against another.
+ */
+ @Test
+ public void certificateFromAnotherCaIsRejected() throws Exception {
+ CertificateData otherCa =
HybridCertificateGenerator.generateCertificateAuthority();
+
+ CertificateValidationException e =
assertThrows(CertificateValidationException.class,
+ () -> CertificatesUtil.validateHybridCertificate(leaf,
otherCa.certificate));
+ assertEquals("ML-DSA-65 signature validation failed", e.getMessage());
+ }
+
+ /**
+ * The alternative signature must cover the certificate body, so copying
the PQC extensions onto a
+ * different certificate must not produce one that validates. A signature
computed over only the
+ * subject DN would pass this, because the DN is copied along with the
extensions.
+ */
+ @Test
+ public void certificateWithLiftedExtensionsIsRejected() throws Exception {
+ X509Certificate forged =
ForgedCertificates.withLiftedExtensions(leaf).certificate;
+
+ assertEquals(leaf.getSubjectX500Principal(),
forged.getSubjectX500Principal(),
+ "The forgery should reuse the victim's subject DN");
+
+ CertificateValidationException e =
assertThrows(CertificateValidationException.class,
+ () -> CertificatesUtil.validateHybridCertificate(forged,
ca.certificate));
+ assertEquals("ML-DSA-65 signature validation failed", e.getMessage());
+ }
+
+ /**
+ * Without the issuer's published ML-DSA-65 key there is nothing to verify
against, so validation
+ * must fail rather than skip the check.
+ */
+ @Test
+ public void issuerWithoutPublishedAltKeyIsRejected() throws Exception {
+ X509Certificate issuerWithoutAltKey = HybridCertificateGenerator
+ .issueCertificate(ca, "unit-test-no-alt-key", true,
null).certificate;
+
+ CertificateValidationException e =
assertThrows(CertificateValidationException.class,
+ () -> CertificatesUtil.validateHybridCertificate(leaf,
issuerWithoutAltKey));
+ assertTrue(e.getMessage().contains("2.5.29.72"), "Unexpected message:
" + e.getMessage());
+ }
+}
diff --git
a/http-pqc-j17/src/test/java/org/acme/http/pqc/ForgedCertificates.java
b/http-pqc-j17/src/test/java/org/acme/http/pqc/ForgedCertificates.java
new file mode 100644
index 00000000..a74feb43
--- /dev/null
+++ b/http-pqc-j17/src/test/java/org/acme/http/pqc/ForgedCertificates.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.acme.http.pqc;
+
+import java.io.FileOutputStream;
+import java.math.BigInteger;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+
+import org.acme.http.pqc.crypto.ChimeraOids;
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.ASN1OctetString;
+import org.bouncycastle.asn1.ASN1Primitive;
+import org.bouncycastle.asn1.DEROctetString;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+/**
+ * Builds certificates that a reader might expect to be accepted but that must
be rejected.
+ * Used by {@link HttpPqcTest} and {@link CertificatesUtilTest}.
+ */
+public final class ForgedCertificates {
+
+ private static final String PASSWORD = "changeit";
+ private static final String CERT_DIR = "target/certs";
+
+ private ForgedCertificates() {
+ }
+
+ /**
+ * A certificate and the private key needed to present it during a TLS
handshake.
+ */
+ public static class Forged {
+ public final KeyPair rsaKeyPair;
+ public final X509Certificate certificate;
+
+ Forged(KeyPair rsaKeyPair, X509Certificate certificate) {
+ this.rsaKeyPair = rsaKeyPair;
+ this.certificate = certificate;
+ }
+ }
+
+ /**
+ * A self-signed hybrid certificate with a correct, freshly generated
ML-DSA-65 alternative
+ * signature. Everything about it is well formed; it simply is not in the
server truststore.
+ */
+ public static Forged rogueHybrid() throws Exception {
+ return forge(new X500Name("CN=attacker,O=Evil Corp,C=XX"), 0, null);
+ }
+
+ /**
+ * A hybrid certificate whose validity period ended a year ago.
+ */
+ public static Forged expiredHybrid() throws Exception {
+ return forge(new X500Name("CN=expired,O=Evil Corp,C=XX"), -730, null);
+ }
+
+ /**
+ * A certificate built on a freshly generated RSA key, carrying the PQC
extensions copied verbatim
+ * off {@code victim} along with its subject DN. This is the forgery that
succeeds when the
+ * alternative signature covers only the subject DN instead of the
certificate body: the forger
+ * never holds the ML-DSA-65 private key that produced the copied
signature.
+ *
+ * <p>
+ * The result is self-signed, so at TLS level it is rejected for lacking a
trust anchor. Use it
+ * against {@code CertificatesUtil} directly to exercise the signature
check itself.
+ */
+ public static Forged withLiftedExtensions(X509Certificate victim) throws
Exception {
+ // Rebuild the DN from its DER encoding so the bytes are identical to
the victim's
+ X500Name victimDn =
X500Name.getInstance(victim.getSubjectX500Principal().getEncoded());
+ return forge(victimDn, 0, victim);
+ }
+
+ /**
+ * Writes a certificate and its key to a PKCS12 keystore that a client can
present.
+ */
+ public static Path keystore(String alias, Forged forged) throws Exception {
+ Path path = Paths.get(CERT_DIR, alias + "-keystore.p12");
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, null);
+ keyStore.setKeyEntry(alias, forged.rsaKeyPair.getPrivate(),
PASSWORD.toCharArray(),
+ new X509Certificate[] { forged.certificate });
+ try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
+ keyStore.store(fos, PASSWORD.toCharArray());
+ }
+ return path;
+ }
+
+ private static Forged forge(X500Name name, int dayOffset, X509Certificate
liftFrom) throws Exception {
+ KeyPairGenerator rsaKpg = KeyPairGenerator.getInstance("RSA");
+ rsaKpg.initialize(2048, new SecureRandom());
+ KeyPair rsa = rsaKpg.generateKeyPair();
+
+ long day = 24L * 60 * 60 * 1000;
+ long start = System.currentTimeMillis() + dayOffset * day;
+
+ JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
+ name,
+ new BigInteger(64, new SecureRandom()),
+ new Date(start),
+ new Date(start + 365 * day),
+ name,
+ rsa.getPublic());
+ builder.addExtension(Extension.basicConstraints, true, new
BasicConstraints(false));
+
+ ContentSigner rsaSigner = new
JcaContentSignerBuilder("SHA256withRSA").build(rsa.getPrivate());
+ X509CertificateHolder holder;
+
+ if (liftFrom != null) {
+ // A certificate issued by a CA carries no subjectAltPublicKeyInfo
of its own, so only copy
+ // the extensions that are actually present
+ copyExtensionIfPresent(builder, liftFrom,
ChimeraOids.SUBJECT_ALT_PUBLIC_KEY_INFO);
+ copyExtensionIfPresent(builder, liftFrom,
ChimeraOids.ALT_SIGNATURE_ALGORITHM);
+ copyExtensionIfPresent(builder, liftFrom,
ChimeraOids.ALT_SIGNATURE_VALUE);
+ holder = builder.build(rsaSigner);
+ } else {
+ KeyPair mlDsa = KeyPairGenerator.getInstance("ML-DSA-65",
"BC").generateKeyPair();
+ builder.addExtension(ChimeraOids.SUBJECT_ALT_PUBLIC_KEY_INFO,
false,
+
SubjectPublicKeyInfo.getInstance(mlDsa.getPublic().getEncoded()));
+ ContentSigner mlDsaSigner = new
JcaContentSignerBuilder("ML-DSA-65")
+ .setProvider("BC")
+ .build(mlDsa.getPrivate());
+ holder = builder.build(rsaSigner, false, mlDsaSigner);
+ }
+
+ X509Certificate certificate = new JcaX509CertificateConverter()
+ .setProvider("BC")
+ .getCertificate(holder);
+
+ return new Forged(rsa, certificate);
+ }
+
+ private static void copyExtensionIfPresent(JcaX509v3CertificateBuilder
builder, X509Certificate from,
+ ASN1ObjectIdentifier oid) throws Exception {
+ byte[] wrapped = from.getExtensionValue(oid.getId());
+ if (wrapped == null) {
+ return;
+ }
+ byte[] inner = ((ASN1OctetString)
ASN1Primitive.fromByteArray(wrapped)).getOctets();
+ builder.addExtension(new Extension(oid, false, new
DEROctetString(inner)));
+ }
+}
diff --git a/http-pqc-j17/src/test/java/org/acme/http/pqc/HttpPqcTest.java
b/http-pqc-j17/src/test/java/org/acme/http/pqc/HttpPqcTest.java
index 39387ef6..35c60127 100644
--- a/http-pqc-j17/src/test/java/org/acme/http/pqc/HttpPqcTest.java
+++ b/http-pqc-j17/src/test/java/org/acme/http/pqc/HttpPqcTest.java
@@ -16,10 +16,16 @@
*/
package org.acme.http.pqc;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.Security;
+
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.config.RestAssuredConfig;
import io.restassured.config.SSLConfig;
+import io.restassured.specification.RequestSpecification;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.eclipse.microprofile.config.ConfigProvider;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -28,17 +34,22 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.hamcrest.Matchers.containsString;
-import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
@QuarkusTest
public class HttpPqcTest {
private static final Logger log =
LoggerFactory.getLogger(HttpPqcTest.class);
+ private static final String PASSWORD = "changeit";
+ private static final String CERT_DIR = "target/certs";
@BeforeAll
public static void setUpClass() {
- // Use relaxed HTTPS validation for self-signed certificates in tests
- RestAssured.useRelaxedHTTPSValidation();
+ // Needed to build the deliberately invalid certificates used by the
rejection tests
+ if (Security.getProvider("BC") == null) {
+ Security.insertProviderAt(new BouncyCastleProvider(), 1);
+ }
}
@BeforeEach
@@ -54,62 +65,87 @@ public class HttpPqcTest {
@Test
public void testPqcSecureEndpointWithoutClientCert() {
// With client-auth=required, TLS handshake fails before reaching the
route
- try {
- RestAssured.given()
- .when()
- .get("/pqc/secure")
- .then()
- .statusCode(200);
-
- fail("Expected SSL exception but connection succeeded");
- } catch (Exception e) {
- // Expected: SSL handshake failure (certificate_required)
- log.info("✓ Expected SSL exception without client cert: {}",
e.getMessage());
- }
+ assertRejected(withTrustStore(SSLConfig.sslConfig()), "no client
certificate");
}
@Test
public void testPqcSecureEndpointWithValidClientCert() {
- // Test /pqc/secure WITH valid hybrid client certificate
- // TLS handshake should succeed, and custom TrustManager validates
both RSA + ML-DSA-65
- // Configure RestAssured with client certificate keystore and server
truststore
-
- RestAssured.given()
- .config(RestAssuredConfig.config().sslConfig(
- SSLConfig.sslConfig()
-
.keyStore("target/certs/client-hybrid-keystore.p12", "changeit")
-
.trustStore("target/certs/server-hybrid-truststore.p12", "changeit")
- .allowAllHostnames() // Accept localhost with
self-signed cert
- ))
+ // Test /pqc/secure WITH valid hybrid client certificate. It chains to
the CA in the server
+ // truststore and carries the CA's ML-DSA-65 alternative signature, so
the handshake succeeds.
+ client(CERT_DIR + "/client-hybrid-keystore.p12")
.when()
.get("/pqc/secure")
.then()
.statusCode(200)
.body(containsString("Hybrid PQC certificate validated"))
- .body(containsString("quantum-safe"))
- .body(containsString("TLS layer"));
+ .body(containsString("trust anchor"))
+ .body(containsString("ML-DSA-65"));
}
@Test
public void testPqcSecureEndpointWithRsaOnlyCertificate() {
- // Test /pqc/secure WITH RSA-only client certificate (no PQC
extensions)
- // TLS handshake should FAIL because custom TrustManager requires
hybrid cert
- try {
- RestAssured.given()
- .config(RestAssuredConfig.config().sslConfig(
- SSLConfig.sslConfig()
-
.keyStore("target/certs/client-rsa-only-keystore.p12", "changeit")
-
.trustStore("target/certs/server-hybrid-truststore.p12", "changeit")
- .allowAllHostnames()))
- .when()
- .get("/pqc/secure")
- .then()
- .statusCode(200); // Should NOT reach here
-
- fail("Expected SSL exception for RSA-only certificate, but
connection succeeded");
- } catch (Exception e) {
- // Expected: TLS handshake failure due to missing PQC extensions
- log.info("✓ Expected SSL exception for RSA-only cert: {}",
e.getMessage());
+ // Test /pqc/secure WITH RSA-only client certificate (no PQC
extensions). It is issued by the
+ // same CA, so it passes chain validation and the ML-DSA-65 check is
the only thing rejecting it.
+ assertRejected(CERT_DIR + "/client-rsa-only-keystore.p12", "RSA-only
cert issued by the CA");
+ }
+
+ @Test
+ public void testPqcSecureEndpointWithUntrustedHybridCertificate() throws
Exception {
+ // A self-signed hybrid certificate carrying a well-formed ML-DSA-65
alternative signature over
+ // its own body, but issued by nobody the server trusts. Rejected
because the PQC check adds to
+ // the standard chain validation rather than replacing it. This is the
case that a custom
+ // TrustManager doing its own validation instead of delegating will
wrongly accept.
+ Path keystore = ForgedCertificates.keystore("rogue",
ForgedCertificates.rogueHybrid());
+ assertRejected(keystore.toString(), "untrusted self-signed hybrid
cert");
+ }
+
+ @Test
+ public void testPqcSecureEndpointWithExpiredHybridCertificate() throws
Exception {
+ // A hybrid certificate whose validity period ended a year ago, which
the validity check picks
+ // up. It is self-signed too, so it fails for want of a trust anchor
as well; both checks only
+ // run because validation is delegated to the platform trust manager.
+ Path keystore = ForgedCertificates.keystore("expired",
ForgedCertificates.expiredHybrid());
+ assertRejected(keystore.toString(), "expired hybrid cert");
+ }
+
+ private void assertRejected(String keyStorePath, String description) {
+
assertRejected(withTrustStore(SSLConfig.sslConfig()).keyStore(keyStorePath,
PASSWORD), description);
+ }
+
+ private void assertRejected(SSLConfig sslConfig, String description) {
+ Exception e = assertThrows(Exception.class,
+ () ->
given(sslConfig).when().get("/pqc/secure").then().statusCode(200),
+ "Expected the TLS handshake to be rejected for " +
description);
+
+ // TLS-level rejections surface as an IOException subclass, most often
SSLHandshakeException.
+ // Asserting that keeps the test from passing because of an unrelated
failure.
+ assertInstanceOf(IOException.class, rootCause(e),
+ "Expected a TLS failure for " + description + " but got: " +
e);
+ log.info("✓ Rejected as expected ({}): {}", description,
e.getMessage());
+ }
+
+ /**
+ * Trusts the server via the generated client truststore, and verifies its
identity against the
+ * subject alternative names in the server certificate. Hostname
verification is deliberately left
+ * enabled so that the tests fail if those names regress.
+ */
+ private static SSLConfig withTrustStore(SSLConfig sslConfig) {
+ return sslConfig.trustStore(CERT_DIR +
"/client-hybrid-truststore.p12", PASSWORD);
+ }
+
+ private static RequestSpecification client(String keyStorePath) {
+ return
given(withTrustStore(SSLConfig.sslConfig()).keyStore(keyStorePath, PASSWORD));
+ }
+
+ private static RequestSpecification given(SSLConfig sslConfig) {
+ return
RestAssured.given().config(RestAssuredConfig.config().sslConfig(sslConfig));
+ }
+
+ private static Throwable rootCause(Throwable t) {
+ Throwable cause = t;
+ while (cause.getCause() != null && cause.getCause() != cause) {
+ cause = cause.getCause();
}
+ return cause;
}
}
diff --git
a/http-pqc-j17/src/test/java/org/acme/http/pqc/HybridPqcX509TrustManagerTest.java
b/http-pqc-j17/src/test/java/org/acme/http/pqc/HybridPqcX509TrustManagerTest.java
new file mode 100644
index 00000000..251f71f7
--- /dev/null
+++
b/http-pqc-j17/src/test/java/org/acme/http/pqc/HybridPqcX509TrustManagerTest.java
@@ -0,0 +1,269 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.acme.http.pqc;
+
+import java.net.Socket;
+import java.security.KeyStore;
+import java.security.Security;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509ExtendedTrustManager;
+
+import org.acme.http.pqc.certificates.HybridCertificateGenerator;
+import
org.acme.http.pqc.certificates.HybridCertificateGenerator.CertificateData;
+import org.acme.http.pqc.trustmanager.HybridPqcX509TrustManager;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Verifies that {@link HybridPqcX509TrustManager} implements the whole
+ * {@link X509ExtendedTrustManager} contract rather than leaving parts of it
permissive. An empty
+ * {@code checkServerTrusted} or an empty {@code getAcceptedIssuers} compiles
and passes any
+ * happy-path test, so each is asserted here explicitly.
+ */
+public class HybridPqcX509TrustManagerTest {
+
+ private static CertificateData ca;
+ private static X509Certificate leaf;
+ private static HybridPqcX509TrustManager trustManager;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception {
+ if (Security.getProvider("BC") == null) {
+ Security.insertProviderAt(new BouncyCastleProvider(), 1);
+ }
+
+ ca = HybridCertificateGenerator.generateCertificateAuthority();
+ leaf = HybridCertificateGenerator.issueCertificate(ca, "trusted-leaf",
true, null).certificate;
+ trustManager = trustManagerAnchoredOn(ca.certificate);
+ }
+
+ /**
+ * Builds the trust manager under test on top of a platform one seeded
with the given trust anchors,
+ * which is the arrangement {@code HybridPqcTrustManagerCustomizer} sets
up at runtime.
+ */
+ private static HybridPqcX509TrustManager
trustManagerAnchoredOn(X509Certificate... anchors) throws Exception {
+ KeyStore trustStore = KeyStore.getInstance("PKCS12");
+ trustStore.load(null, null);
+ for (int i = 0; i < anchors.length; i++) {
+ trustStore.setCertificateEntry("anchor-" + i, anchors[i]);
+ }
+
+ TrustManagerFactory tmf =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(trustStore);
+
+ for (TrustManager candidate : tmf.getTrustManagers()) {
+ if (candidate instanceof X509ExtendedTrustManager) {
+ return new
HybridPqcX509TrustManager((X509ExtendedTrustManager) candidate);
+ }
+ }
+ throw new IllegalStateException("No X509ExtendedTrustManager
available");
+ }
+
+ /**
+ * A peer that sends only its own certificate, leaving the relying party
to supply the anchor. The
+ * issuer's ML-DSA-65 key has to be found in the truststore for this to
pass.
+ */
+ @Test
+ public void leafAloneIsAccepted() {
+ assertDoesNotThrow(() -> trustManager.checkClientTrusted(new
X509Certificate[] { leaf }, "RSA"));
+ }
+
+ @Test
+ public void leafWithCaInChainIsAccepted() {
+ assertDoesNotThrow(
+ () -> trustManager.checkClientTrusted(new X509Certificate[] {
leaf, ca.certificate }, "RSA"));
+ }
+
+ @Test
+ public void untrustedHybridClientIsRejected() throws Exception {
+ X509Certificate rogue = ForgedCertificates.rogueHybrid().certificate;
+ assertThrows(CertificateException.class,
+ () -> trustManager.checkClientTrusted(new X509Certificate[] {
rogue }, "RSA"));
+ }
+
+ /**
+ * Issued by the trusted CA, so it passes chain validation, but carries no
ML-DSA-65 signature. This
+ * isolates the post-quantum check as the sole reason for rejection.
+ */
+ @Test
+ public void certificateFromTheCaWithoutAltSignatureIsRejected() throws
Exception {
+ X509Certificate rsaOnly = HybridCertificateGenerator
+ .issueCertificate(ca, "rsa-only-leaf", false,
null).certificate;
+
+ CertificateException e = assertThrows(CertificateException.class,
+ () -> trustManager.checkClientTrusted(new X509Certificate[] {
rsaOnly }, "RSA"));
+ assertEquals(true, e.getMessage().contains("2.5.29.73"), "Unexpected
message: " + e.getMessage());
+ }
+
+ @Test
+ public void untrustedHybridServerIsRejected() throws Exception {
+ X509Certificate rogue = ForgedCertificates.rogueHybrid().certificate;
+ assertThrows(CertificateException.class,
+ () -> trustManager.checkServerTrusted(new X509Certificate[] {
rogue }, "RSA"),
+ "checkServerTrusted must validate the chain, not accept
anything");
+ }
+
+ @Test
+ public void emptyChainIsRejected() {
+ assertThrows(CertificateException.class, () ->
trustManager.checkClientTrusted(null, "RSA"));
+ assertThrows(CertificateException.class,
+ () -> trustManager.checkClientTrusted(new X509Certificate[0],
"RSA"));
+ }
+
+ /**
+ * The {@code Socket} and {@code SSLEngine} overloads are the ones a TLS
stack actually calls, so
+ * they have to run the same two checks as the two-argument methods.
+ */
+ @Test
+ public void extendedOverloadsValidateToo() throws Exception {
+ X509Certificate rogue = ForgedCertificates.rogueHybrid().certificate;
+
+ assertDoesNotThrow(() -> trustManager.checkClientTrusted(new
X509Certificate[] { leaf }, "RSA", (Socket) null));
+ assertDoesNotThrow(
+ () -> trustManager.checkClientTrusted(new X509Certificate[] {
leaf }, "RSA", (SSLEngine) null));
+
+ assertThrows(CertificateException.class,
+ () -> trustManager.checkClientTrusted(new X509Certificate[] {
rogue }, "RSA", (Socket) null));
+ assertThrows(CertificateException.class,
+ () -> trustManager.checkServerTrusted(new X509Certificate[] {
rogue }, "RSA", (SSLEngine) null));
+ }
+
+ /**
+ * The connection context has to reach the delegate, because that is what
the platform uses for
+ * endpoint identification. Forwarding the three-argument overloads to the
two-argument method
+ * validates the certificate just as well and would satisfy the test
above, while dropping the
+ * hostname check on the way through.
+ */
+ @Test
+ public void connectionContextIsHandedToTheDelegate() throws Exception {
+ RecordingTrustManager recorder = new
RecordingTrustManager(ca.certificate);
+ HybridPqcX509TrustManager wrapper = new
HybridPqcX509TrustManager(recorder);
+ X509Certificate[] chain = new X509Certificate[] { leaf };
+
+ wrapper.checkServerTrusted(chain, "RSA", (SSLEngine) null);
+ assertEquals("checkServerTrusted/SSLEngine", recorder.lastCall);
+
+ wrapper.checkServerTrusted(chain, "RSA", (Socket) null);
+ assertEquals("checkServerTrusted/Socket", recorder.lastCall);
+
+ wrapper.checkClientTrusted(chain, "RSA", (SSLEngine) null);
+ assertEquals("checkClientTrusted/SSLEngine", recorder.lastCall);
+
+ wrapper.checkClientTrusted(chain, "RSA", (Socket) null);
+ assertEquals("checkClientTrusted/Socket", recorder.lastCall);
+ }
+
+ /**
+ * Two CAs sharing a distinguished name, which is what a key rollover
looks like to a relying party
+ * holding both. Resolving the issuer by DN alone picks whichever anchor
happens to come first, so
+ * one of these two certificates would fail its ML-DSA-65 check despite
the platform trust manager
+ * having just accepted its chain.
+ *
+ * <p>
+ * Both chain shapes are exercised. The CA is the interesting one: it
carries no authority key
+ * identifier of its own, being self-signed, so matching it needs more
than the key identifiers the
+ * leaf supplies — and the keystores this example generates do put the CA
in the chain.
+ */
+ @Test
+ public void certificatesFromTwoCasSharingADnAreBothAccepted() throws
Exception {
+ CertificateData firstCa =
HybridCertificateGenerator.generateCertificateAuthority();
+ CertificateData secondCa =
HybridCertificateGenerator.generateCertificateAuthority();
+ assertEquals(firstCa.certificate.getSubjectX500Principal(),
secondCa.certificate.getSubjectX500Principal(),
+ "The two CAs are only interesting to this test if they share a
DN");
+
+ X509Certificate firstLeaf = HybridCertificateGenerator
+ .issueCertificate(firstCa, "rollover-leaf-1", true,
null).certificate;
+ X509Certificate secondLeaf = HybridCertificateGenerator
+ .issueCertificate(secondCa, "rollover-leaf-2", true,
null).certificate;
+
+ HybridPqcX509TrustManager bothAnchors =
trustManagerAnchoredOn(firstCa.certificate, secondCa.certificate);
+
+ assertDoesNotThrow(() -> bothAnchors.checkClientTrusted(new
X509Certificate[] { firstLeaf }, "RSA"));
+ assertDoesNotThrow(() -> bothAnchors.checkClientTrusted(new
X509Certificate[] { secondLeaf }, "RSA"));
+
+ assertDoesNotThrow(() -> bothAnchors
+ .checkClientTrusted(new X509Certificate[] { firstLeaf,
firstCa.certificate }, "RSA"));
+ assertDoesNotThrow(() -> bothAnchors
+ .checkClientTrusted(new X509Certificate[] { secondLeaf,
secondCa.certificate }, "RSA"));
+ }
+
+ @Test
+ public void acceptedIssuersComeFromTheTruststore() {
+ X509Certificate[] issuers = trustManager.getAcceptedIssuers();
+ assertEquals(1, issuers.length, "Expected the configured trust anchor
to be advertised");
+ assertEquals(ca.certificate, issuers[0]);
+ }
+
+ /**
+ * A delegate that accepts everything and records which of the six methods
it was asked, so that the
+ * overload the wrapper chose can be asserted.
+ */
+ private static final class RecordingTrustManager extends
X509ExtendedTrustManager {
+
+ private final X509Certificate anchor;
+ private String lastCall;
+
+ private RecordingTrustManager(X509Certificate anchor) {
+ this.anchor = anchor;
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String
authType) {
+ lastCall = "checkClientTrusted/2";
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String
authType, Socket socket) {
+ lastCall = "checkClientTrusted/Socket";
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String
authType, SSLEngine engine) {
+ lastCall = "checkClientTrusted/SSLEngine";
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String
authType) {
+ lastCall = "checkServerTrusted/2";
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String
authType, Socket socket) {
+ lastCall = "checkServerTrusted/Socket";
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String
authType, SSLEngine engine) {
+ lastCall = "checkServerTrusted/SSLEngine";
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[] { anchor };
+ }
+ }
+}