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..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 @@ -364,6 +364,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: