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:

Reply via email to