This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 91d5bdac5118 CAMEL-24445: camel-pqc - use a per-exchange Signature
instance
91d5bdac5118 is described below
commit 91d5bdac51182fda8515e5b200e8aa842ccd1bc6
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Aug 27 17:26:30 2026 +0200
CAMEL-24445: camel-pqc - use a per-exchange Signature instance
PQCProducer held a single Signature and called initSign/update/sign on it
from every exchange. java.security.Signature is stateful and not thread
safe, so concurrent exchanges on the same producer interleaved on one
instance - a verification could observe another exchange's state, and a
valid signature replayed while other verifications were in flight could
be accepted.
The producer now remembers the algorithm and provider whenever it created
the Signature itself, and builds a fresh instance per exchange. When the
user configured the instance it cannot be recreated, so that case falls
back to sharing it under synchronization. Both the signing and the
verification paths are covered.
Closes #25825
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../apache/camel/component/pqc/PQCProducer.java | 129 ++++++++++++++++-----
.../pqc/PQCConcurrentSignatureIsolationTest.java | 96 +++++++++++++++
2 files changed, 194 insertions(+), 31 deletions(-)
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
index be03b318ed00..4e818da8d059 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCProducer.java
@@ -121,6 +121,12 @@ public class PQCProducer extends DefaultProducer {
private KeyGenerator keyGenerator;
private KeyPair keyPair;
+ // Set only when this producer created the Signature itself, so it knows
how to create another one.
+ // Left null when the user configured an instance, which then has to be
shared and locked instead.
+ private String signerAlgorithm;
+ private String signerProvider;
+ private String classicalSignerAlgorithm;
+
// Hybrid cryptography fields
private Signature classicalSigner;
private KeyAgreement classicalKeyAgreement;
@@ -379,7 +385,9 @@ public class PQCProducer extends DefaultProducer {
if (ObjectHelper.isEmpty(signer)) {
PQCSignatureAlgorithms sigAlg =
PQCSignatureAlgorithms.valueOf(getConfiguration().getSignatureAlgorithm());
- signer = Signature.getInstance(sigAlg.getAlgorithm(),
sigAlg.getBcProvider());
+ signerAlgorithm = sigAlg.getAlgorithm();
+ signerProvider = sigAlg.getBcProvider();
+ signer = Signature.getInstance(signerAlgorithm,
signerProvider);
}
}
@@ -423,7 +431,9 @@ public class PQCProducer extends DefaultProducer {
signer = getEndpoint().getConfiguration().getSigner();
if (ObjectHelper.isEmpty(signer) &&
ObjectHelper.isNotEmpty(getConfiguration().getSignatureAlgorithm())) {
PQCSignatureAlgorithms sigAlg =
PQCSignatureAlgorithms.valueOf(getConfiguration().getSignatureAlgorithm());
- signer = Signature.getInstance(sigAlg.getAlgorithm(),
sigAlg.getBcProvider());
+ signerAlgorithm = sigAlg.getAlgorithm();
+ signerProvider = sigAlg.getBcProvider();
+ signer = Signature.getInstance(signerAlgorithm,
signerProvider);
}
// Initialize classical signer
@@ -432,7 +442,8 @@ public class PQCProducer extends DefaultProducer {
&&
ObjectHelper.isNotEmpty(getConfiguration().getClassicalSignatureAlgorithm())) {
PQCClassicalSignatureAlgorithms classAlg
=
PQCClassicalSignatureAlgorithms.valueOf(getConfiguration().getClassicalSignatureAlgorithm());
- classicalSigner =
Signature.getInstance(classAlg.getAlgorithm());
+ classicalSignerAlgorithm = classAlg.getAlgorithm();
+ classicalSigner =
Signature.getInstance(classicalSignerAlgorithm);
}
// Initialize classical key pair
@@ -504,24 +515,61 @@ public class PQCProducer extends DefaultProducer {
throws Exception {
checkStatefulKeyBeforeSign();
- signer.initSign(keyPair.getPrivate());
- updateSignatureFromBody(signer, exchange.getMessage());
-
- byte[] signature = signer.sign();
+ Signature signerForExchange = signerForExchange();
+ byte[] signature;
+ synchronized (signerForExchange) {
+ signerForExchange.initSign(keyPair.getPrivate());
+ updateSignatureFromBody(signerForExchange, exchange.getMessage());
+ signature = signerForExchange.sign();
+ }
exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);
persistStatefulKeyStateAfterSign(exchange);
}
private void verification(Exchange exchange)
- throws InvalidPayloadException, InvalidKeyException,
SignatureException, IOException {
- signer.initVerify(keyPair.getPublic());
- updateSignatureFromBody(signer, exchange.getMessage());
- if
(signer.verify(exchange.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class))) {
- exchange.getMessage().setHeader(PQCConstants.VERIFY, true);
- } else {
- exchange.getMessage().setHeader(PQCConstants.VERIFY, false);
+ throws InvalidPayloadException, InvalidKeyException,
SignatureException, IOException,
+ NoSuchAlgorithmException, NoSuchProviderException {
+ Signature signerForExchange = signerForExchange();
+ boolean verified;
+ synchronized (signerForExchange) {
+ signerForExchange.initVerify(keyPair.getPublic());
+ updateSignatureFromBody(signerForExchange, exchange.getMessage());
+ verified =
signerForExchange.verify(exchange.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class));
+ }
+ exchange.getMessage().setHeader(PQCConstants.VERIFY, verified);
+ }
+
+ /**
+ * Returns the {@link Signature} to use for a single exchange.
+ * <p>
+ * {@code java.security.Signature} carries per-operation state across its
init - update - sign/verify sequence and
+ * is not thread-safe, while a Camel producer is a singleton invoked
concurrently. Interleaving two sequences on one
+ * instance does not merely corrupt output: a concurrent {@code
initVerify} can reset the object between another
+ * exchange's updates and its {@code verify()}, so the result no longer
corresponds to the message it was called
+ * for.
+ * <p>
+ * When this producer created the instance it can simply create another
the same way, which removes the sharing
+ * altogether. When the user configured one - {@code
PQCDefault*Material.signer} are {@code public static final}, so
+ * a configured instance can be shared JVM-wide - that instance has to be
handed back as-is, and the caller
+ * serializes on whatever it gets.
+ */
+ private Signature signerForExchange() throws NoSuchAlgorithmException,
NoSuchProviderException {
+ if (signerAlgorithm == null) {
+ return signer;
+ }
+ return signerProvider != null
+ ? Signature.getInstance(signerAlgorithm, signerProvider) :
Signature.getInstance(signerAlgorithm);
+ }
+
+ /**
+ * The classical counterpart of {@link #signerForExchange()}, used by the
hybrid operations.
+ */
+ private Signature classicalSignerForExchange() throws
NoSuchAlgorithmException {
+ if (classicalSignerAlgorithm == null) {
+ return classicalSigner;
}
+ return Signature.getInstance(classicalSignerAlgorithm);
}
/**
@@ -643,7 +691,8 @@ public class PQCProducer extends DefaultProducer {
// ========== Hybrid Signature Operations ==========
private void hybridSignature(Exchange exchange)
- throws InvalidPayloadException, InvalidKeyException,
SignatureException, IOException {
+ throws InvalidPayloadException, InvalidKeyException,
SignatureException, IOException,
+ NoSuchAlgorithmException, NoSuchProviderException {
checkStatefulKeyBeforeSign();
byte[] data = bodyToByteArray(exchange.getMessage());
@@ -656,13 +705,22 @@ public class PQCProducer extends DefaultProducer {
throw new IllegalStateException("PQC signer and key pair must be
configured for hybrid signature operations");
}
- // Create hybrid signature
- byte[] hybridSig = HybridSignature.sign(
- data,
- classicalKeyPair.getPrivate(),
- classicalSigner,
- keyPair.getPrivate(),
- signer);
+ // Create hybrid signature. Both Signature instances need the same
per-exchange isolation as the
+ // single-algorithm paths; see signerForExchange(). The two locks are
always taken PQC-first so the
+ // nesting order is the same here and in hybridVerification().
+ Signature pqcSigner = signerForExchange();
+ Signature classical = classicalSignerForExchange();
+ byte[] hybridSig;
+ synchronized (pqcSigner) {
+ synchronized (classical) {
+ hybridSig = HybridSignature.sign(
+ data,
+ classicalKeyPair.getPrivate(),
+ classical,
+ keyPair.getPrivate(),
+ pqcSigner);
+ }
+ }
// Parse to get individual signatures for headers
HybridSignature.HybridSignatureComponents components =
HybridSignature.parse(hybridSig);
@@ -679,7 +737,8 @@ public class PQCProducer extends DefaultProducer {
}
private void hybridVerification(Exchange exchange)
- throws InvalidPayloadException, InvalidKeyException,
SignatureException, IOException {
+ throws InvalidPayloadException, InvalidKeyException,
SignatureException, IOException,
+ NoSuchAlgorithmException, NoSuchProviderException {
byte[] data = bodyToByteArray(exchange.getMessage());
byte[] hybridSig =
exchange.getMessage().getHeader(PQCConstants.HYBRID_SIGNATURE, byte[].class);
@@ -695,14 +754,22 @@ public class PQCProducer extends DefaultProducer {
throw new IllegalStateException("PQC signer and key pair must be
configured for hybrid verification operations");
}
- // Verify hybrid signature (both must pass)
- boolean valid = HybridSignature.verify(
- data,
- hybridSig,
- classicalKeyPair.getPublic(),
- classicalSigner,
- keyPair.getPublic(),
- signer);
+ // Verify hybrid signature (both must pass). Same per-exchange
isolation and same PQC-first lock order
+ // as hybridSignature().
+ Signature pqcSigner = signerForExchange();
+ Signature classical = classicalSignerForExchange();
+ boolean valid;
+ synchronized (pqcSigner) {
+ synchronized (classical) {
+ valid = HybridSignature.verify(
+ data,
+ hybridSig,
+ classicalKeyPair.getPublic(),
+ classical,
+ keyPair.getPublic(),
+ pqcSigner);
+ }
+ }
exchange.getMessage().setHeader(PQCConstants.HYBRID_VERIFY, valid);
exchange.getMessage().setHeader(PQCConstants.VERIFY, valid);
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCConcurrentSignatureIsolationTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCConcurrentSignatureIsolationTest.java
new file mode 100644
index 000000000000..f3dd6ddfc51c
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCConcurrentSignatureIsolationTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.apache.camel.component.pqc;
+
+import java.security.Security;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A Camel producer is a singleton invoked concurrently, while {@code
java.security.Signature} carries state across its
+ * init - update - sign/verify sequence. Every message signed and then
verified through the same pair of producers must
+ * verify, no matter how many exchanges are in flight at once.
+ */
+class PQCConcurrentSignatureIsolationTest extends CamelTestSupport {
+
+ private static final int THREADS = 8;
+ private static final int MESSAGES_PER_THREAD = 40;
+
+ @Produce("direct:sign")
+ protected ProducerTemplate templateSign;
+
+ @BeforeAll
+ static void startup() {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+
+ @Test
+ void everyConcurrentlySignedMessageVerifies() throws Exception {
+ ExecutorService pool = Executors.newFixedThreadPool(THREADS);
+ try {
+ List<Callable<List<Object>>> work = new ArrayList<>();
+ for (int t = 0; t < THREADS; t++) {
+ final int thread = t;
+ work.add(() -> {
+ List<Object> verdicts = new ArrayList<>();
+ for (int i = 0; i < MESSAGES_PER_THREAD; i++) {
+ verdicts.add(templateSign.requestBody("direct:sign",
"message-" + thread + "-" + i, Object.class));
+ }
+ return verdicts;
+ });
+ }
+
+ List<Object> allVerdicts = new ArrayList<>();
+ for (Future<List<Object>> f : pool.invokeAll(work, 5,
TimeUnit.MINUTES)) {
+ allVerdicts.addAll(f.get());
+ }
+
+ assertThat(allVerdicts).hasSize(THREADS * MESSAGES_PER_THREAD);
+ assertThat(allVerdicts).allSatisfy(v ->
assertThat(v).isEqualTo(Boolean.TRUE));
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:sign")
+ .to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA")
+
.to("pqc:verify?operation=verify&signatureAlgorithm=MLDSA")
+ .setBody(header(PQCConstants.VERIFY));
+ }
+ };
+ }
+}