From e4392f2edca074bc728caa0aa56721488bbe59b2 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Mon, 19 Oct 2026 11:57:04 +0200 Subject: [PATCH 1/2] CAMEL-25796: camel-pqc - persist the stateful key index before the signature is published sign() consumes the one-time index inside an LMS, HSS, XMSS or XMSSMT private key. The signature was placed on the message first and the advanced key persisted afterwards, so a crash, a kill -9, or any failure inside the persist step left an emitted signature in the world while the stored key still sat at the old index. On restart the key resumes there and signs a second time with an index that is already in use, which is precisely what the one-time property of these schemes forbids. The order is now reversed in both signature() and hybridSignature(): the key state is recorded first, and only then are the signature headers set. The window cannot be closed completely - BouncyCastle advances the index inside sign() and exposes no reserve-then-sign API - but a failure now fails the exchange instead of releasing a signature whose index advance was never stored. Losing a signature is recoverable; reusing an index is not. The three paths that quietly skipped persistence for a stateful key - no keyLifecycleManager, no CamelPQCKeyId header, no stored metadata - now log a warning naming the reason. It is emitted once per producer so a high volume route does not flood the log. Co-Authored-By: Claude Opus 5 Signed-off-by: Andrea Cosentino --- .../camel/component/pqc/PQCProducer.java | 69 +++++++-- .../pqc/PQCStatefulKeyPersistOrderTest.java | 138 ++++++++++++++++++ .../pages/camel-4x-upgrade-guide-4_24.adoc | 18 +++ 3 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyPersistOrderTest.java 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 2a06175555de4..cf34f93337517 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 @@ private void signature(Exchange exchange) 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 @@ private void hybridSignature(Exchange exchange) // 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 @@ private void recordKeyUsageAfterSign(Exchange exchange) throws Exception { 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 @@ private void recordKeyUsageAfterSign(Exchange exchange) throws Exception { 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 @@ private void warnUsageRecordFailure(String keyId, Exception e) { } } + /** + * Warns, once per producer, that a signing operation could not have its key usage recorded. + *

+ * 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. + *

+ * 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 0000000000000..f36e0610d951c --- /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 5587787d422e7..0cb009678641d 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 @@ -364,6 +364,24 @@ 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 without a `keyLifecycleManager`, without a `CamelPQCKeyId` header, or with no stored metadata for the key, +now also logs a warning once per producer rather than being silently ignored. 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: From 1389b72cf7dc3124833d5686b166a976a3c5def0 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 21 Oct 2026 11:00:42 +0200 Subject: [PATCH 2/2] CAMEL-25796: Address review feedback - the upgrade-guide note says which keys warn without a keyLifecycleManager Only a stateful key warns when no keyLifecycleManager is configured; the note read as if every key did. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Andrea Cosentino --- .../modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 0cb009678641d..99c6222be74c1 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 @@ -378,9 +378,10 @@ failed message and pass it on; it can no longer do so. That is the point of the 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 without a `keyLifecycleManager`, without a `CamelPQCKeyId` header, or with no stored metadata for the key, -now also logs a warning once per producer rather than being silently ignored. For a stateful key the warning names -the index-reuse risk; for any other key it names the `usageCount` that will not advance. +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