This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 e6885df7894d CAMEL-25796: camel-pqc - persist the stateful key index
before the signature is published (#28099)
e6885df7894d is described below
commit e6885df7894da4f98d7e21f7fb7ad79f1994c302
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 22 07:24:50 2026 +0200
CAMEL-25796: camel-pqc - persist the stateful key index before the
signature is published (#28099)
sign() consumes the one-time index of an LMS, HSS, XMSS or XMSSMT private
key.
The signature was placed on the message before the advanced key was
persisted,
so a failure in between left a published signature while the stored key kept
the old index, and a restart reused that index.
signature() and hybridSignature() now persist the key state first and set
the
signature headers only after, so a persist failure fails the exchange. The
paths that skip persistence for a stateful key log a warning once per
producer. The upgrade guide notes the change.
Closes #28099
Co-Authored-By: Claude Opus 5 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../apache/camel/component/pqc/PQCProducer.java | 69 ++++++++---
.../pqc/PQCStatefulKeyPersistOrderTest.java | 138 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 19 +++
3 files changed, 210 insertions(+), 16 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 2a06175555de..cf34f9333751 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
@@ -123,8 +123,9 @@ public class PQCProducer extends DefaultProducer {
private KeyGenerator keyGenerator;
private KeyPair keyPair;
- // Keeps the "usage could not be recorded" warning to one line per
producer instead of one per exchange
+ // Each keeps its warning to one line per producer instead of one per
exchange
private final AtomicBoolean usageRecordFailureWarned = new AtomicBoolean();
+ private final AtomicBoolean keyUsageNotRecordedWarned = new
AtomicBoolean();
// 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.
@@ -582,9 +583,13 @@ public class PQCProducer extends DefaultProducer {
updateSignatureFromBody(signerForExchange, exchange.getMessage());
signature = signerForExchange.sign();
}
- exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);
+ // sign() has already advanced the one-time index inside a stateful
private key. Record that before the
+ // signature leaves this processor, so a persistence failure fails the
exchange instead of emitting a
+ // signature whose index advance was never stored - on restart that
index would be handed out a second time.
recordKeyUsageAfterSign(exchange);
+
+ exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);
}
private void verification(Exchange exchange)
@@ -830,15 +835,16 @@ public class PQCProducer extends DefaultProducer {
// Parse to get individual signatures for headers
HybridSignature.HybridSignatureComponents components =
HybridSignature.parse(hybridSig);
- exchange.getMessage().setHeader(PQCConstants.HYBRID_SIGNATURE,
hybridSig);
- exchange.getMessage().setHeader(PQCConstants.CLASSICAL_SIGNATURE,
components.classicalSignature());
- exchange.getMessage().setHeader(PQCConstants.PQC_SIGNATURE,
components.pqcSignature());
-
+ // Recorded before the signatures are published, for the reason given
in signature()
try {
recordKeyUsageAfterSign(exchange);
} catch (Exception e) {
throw new RuntimeCamelException("Failed to record key usage after
hybrid signing", e);
}
+
+ exchange.getMessage().setHeader(PQCConstants.HYBRID_SIGNATURE,
hybridSig);
+ exchange.getMessage().setHeader(PQCConstants.CLASSICAL_SIGNATURE,
components.classicalSignature());
+ exchange.getMessage().setHeader(PQCConstants.PQC_SIGNATURE,
components.pqcSignature());
}
private void hybridVerification(Exchange exchange)
@@ -1224,19 +1230,26 @@ public class PQCProducer extends DefaultProducer {
return;
}
+ PrivateKey privateKey = keyPair.getPrivate();
+ boolean stateful = getStatefulKeyRemaining(privateKey) >= 0;
+
KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager();
if (klm == null) {
+ // Only the stateful risk is worth reporting here. A plain signing
endpoint with no lifecycle management
+ // is an ordinary setup, and with no manager there is no
maxKeyUsage threshold to miss.
+ if (stateful) {
+ warnKeyUsageNotRecorded(true, "no keyLifecycleManager is
configured on the endpoint");
+ }
return;
}
String keyId = exchange.getMessage().getHeader(PQCConstants.KEY_ID,
String.class);
if (ObjectHelper.isEmpty(keyId)) {
+ warnKeyUsageNotRecorded(stateful,
+ "the exchange carries no " + PQCConstants.KEY_ID + "
header identifying the key");
return;
}
- PrivateKey privateKey = keyPair.getPrivate();
- boolean stateful = getStatefulKeyRemaining(privateKey) >= 0;
-
KeyMetadata metadata;
try {
metadata = klm.getKeyMetadata(keyId);
@@ -1249,13 +1262,7 @@ public class PQCProducer extends DefaultProducer {
return;
}
if (metadata == null) {
- if (stateful) {
- LOG.warn(
- "No metadata found for stateful key '{}'. The key
index has been advanced by the signing operation "
- + "but cannot be persisted — on restart this index
advance will be lost, which may lead to key reuse. "
- + "Ensure a KeyLifecycleManager is properly
configured and the key is stored with metadata.",
- keyId);
- }
+ warnKeyUsageNotRecorded(stateful, "the keyLifecycleManager holds
no metadata for key '" + keyId + "'");
return;
}
@@ -1290,6 +1297,36 @@ public class PQCProducer extends DefaultProducer {
}
}
+ /**
+ * Warns, once per producer, that a signing operation could not have its
key usage recorded.
+ * <p/>
+ * For a stateful key this is the serious case: the operation has already
consumed the one-time index inside the
+ * private key, so after a restart the key resumes at the old index and
signs again with an index already in use,
+ * which is what the one-time property of LMS, HSS, XMSS and XMSSMT
forbids.
+ * <p/>
+ * For any other key the consequence is milder: {@code usageCount} does
not advance, so a configured
+ * {@code maxKeyUsage} rotation threshold is never reached. That is only
worth saying when a
+ * {@code keyLifecycleManager} is configured at all - the caller
suppresses it otherwise.
+ */
+ private void warnKeyUsageNotRecorded(boolean stateful, String reason) {
+ if (!keyUsageNotRecordedWarned.compareAndSet(false, true)) {
+ return;
+ }
+ if (stateful) {
+ LOG.warn("Signed with the stateful key of signature algorithm {}
but its advanced index was not persisted,"
+ + " because {}. On restart the key resumes at the
previous index and may sign twice with the same"
+ + " one-time index. Configure a keyLifecycleManager,
store the key with metadata, and send the"
+ + " {} header on every exchange. This is reported once
per producer.",
+ getConfiguration().getSignatureAlgorithm(), reason,
PQCConstants.KEY_ID);
+ } else {
+ LOG.warn("Signed with the key of signature algorithm {} but its
usage was not recorded, because {}."
+ + " The usageCount of the key does not advance, so a
configured maxKeyUsage rotation threshold"
+ + " will never be reached. Send the {} header on every
exchange and store the key with metadata."
+ + " This is reported once per producer.",
+ getConfiguration().getSignatureAlgorithm(), reason,
PQCConstants.KEY_ID);
+ }
+ }
+
/**
* Returns the remaining signatures for a stateful private key, or -1 if
the key is not stateful.
*/
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyPersistOrderTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyPersistOrderTest.java
new file mode 100644
index 000000000000..f36e0610d951
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyPersistOrderTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.KeyPair;
+import java.security.Security;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.Exchange;
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.pqc.lifecycle.InMemoryKeyLifecycleManager;
+import org.apache.camel.component.pqc.lifecycle.KeyMetadata;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Signing consumes the one-time index of a stateful key, so the advanced key
has to be persisted before the signature
+ * is published. If persisting fails, the exchange must fail rather than hand
out a signature whose index advance was
+ * never recorded.
+ */
+public class PQCStatefulKeyPersistOrderTest extends CamelTestSupport {
+
+ private static final String KEY_ID = "lms-key";
+
+ private final FailingOnStoreKeyManager keyManager = new
FailingOnStoreKeyManager();
+
+ @Produce("direct:sign")
+ protected ProducerTemplate templateSign;
+
+ @BeforeEach
+ void addProviders() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) ==
null) {
+ Security.addProvider(new BouncyCastlePQCProvider());
+ }
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ context.getRegistry().bind("keyManager", keyManager);
+ from("direct:sign")
+
.to("pqc:sign?operation=sign&signatureAlgorithm=LMS&keyLifecycleManager=#keyManager");
+ }
+ };
+ }
+
+ @Test
+ void aFailureToPersistTheIndexFailsTheExchange() throws Exception {
+ keyManager.generateKeyPair("LMS", KEY_ID);
+ keyManager.failOnStore = true;
+
+ CamelExecutionException e = assertThrows(CamelExecutionException.class,
+ () -> templateSign.sendBodyAndHeader("direct:sign", "Hello",
PQCConstants.KEY_ID, KEY_ID));
+
+ assertTrue(e.getCause().getMessage().contains("storeKey failed"),
+ "the persistence failure must surface: " + e.getCause());
+ }
+
+ @Test
+ void theSignatureIsNotPublishedWhenPersistenceFails() throws Exception {
+ keyManager.generateKeyPair("LMS", KEY_ID);
+ keyManager.failOnStore = true;
+
+ Exchange out = templateSign.send("direct:sign", e -> {
+ e.getMessage().setBody("Hello");
+ e.getMessage().setHeader(PQCConstants.KEY_ID, KEY_ID);
+ });
+
+ assertTrue(out.isFailed(), "the exchange must be marked failed");
+ assertNull(out.getMessage().getHeader(PQCConstants.SIGNATURE),
+ "no signature may be published when the advanced index could
not be persisted");
+ }
+
+ @Test
+ void aSuccessfulSignStillPersistsAndPublishes() throws Exception {
+ keyManager.generateKeyPair("LMS", KEY_ID);
+ // generateKeyPair stores the key itself; count only what the signing
operation does
+ keyManager.storeCount = 0;
+ keyManager.failOnStore = false;
+
+ Exchange out = templateSign.send("direct:sign", e -> {
+ e.getMessage().setBody("Hello");
+ e.getMessage().setHeader(PQCConstants.KEY_ID, KEY_ID);
+ });
+
+ assertNull(out.getException(), "signing must succeed");
+ assertTrue(out.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class).length > 0);
+ assertEquals(1, keyManager.storeCount, "the advanced stateful key must
have been stored");
+ assertEquals(1, keyManager.getKeyMetadata(KEY_ID).getUsageCount());
+ }
+
+ /**
+ * An InMemoryKeyLifecycleManager whose storeKey can be made to fail,
standing in for an unreachable vault.
+ */
+ private static final class FailingOnStoreKeyManager extends
InMemoryKeyLifecycleManager {
+
+ private boolean failOnStore;
+ private int storeCount;
+
+ @Override
+ public void storeKey(String keyId, KeyPair keyPair, KeyMetadata
metadata) {
+ if (failOnStore) {
+ throw new IllegalStateException("storeKey failed");
+ }
+ storeCount++;
+ super.storeKey(keyId, keyPair, metadata);
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index cb4dc32a61e2..46379cf15944 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -394,6 +394,25 @@ the signing exchanges, or use `maxKeyAge` or
`nextRotationAt`, if the write is n
The key encapsulation operations do not record usage, so `maxKeyUsage` still
never triggers for a KEM key.
+=== camel-pqc - a failed stateful signature no longer carries the signature
headers
+
+Signing with one of the stateful hash-based algorithms (LMS, HSS, XMSS,
XMSSMT) consumes a one-time index inside the
+private key. That advanced key is now persisted through the
`keyLifecycleManager` *before* the signature is placed on
+the message, instead of after.
+
+Such an exchange already failed when the key state could not be stored, so
what changes is the content of the failed
+message: it no longer carries `CamelPQCSignature`, nor the hybrid
`CamelPQCHybridSignature`,
+`CamelPQCClassicalSignature` and `CamelPQCPqcSignature` headers. A route that
caught the failure with
+`onException(...).handled(true)`, or routed it to a dead letter channel, could
previously read the signature off the
+failed message and pass it on; it can no longer do so. That is the point of
the change - a signature whose index
+advance was never recorded must not escape, because after a restart the key
resumes at the previous index and can
+sign a second time with an index already used.
+
+Signing a stateful key without a `keyLifecycleManager`, and signing any key
without a `CamelPQCKeyId` header or
+with no stored metadata for the key, now logs a warning once per producer
rather than being silently ignored. A
+non-stateful key without a `keyLifecycleManager` stays silent, as there is no
`maxKeyUsage` threshold to miss. For a
+stateful key the warning names the index-reuse risk; for any other key it
names the `usageCount` that will not advance.
+
=== camel-jbang - camel get endpoint shows the scope of an endpoint
`camel get endpoint` replaces the `REMOTE` column with `SCOPE`, which tells
three kinds of endpoint apart: