From 38d77a7fd9bd4919f6522f4bdf25ca43dba53ae3 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Mon, 19 Oct 2026 11:39:51 +0200 Subject: [PATCH 1/4] CAMEL-25797: camel-pqc - the usage-count and PENDING_ROTATION key rotation signals now fire KeyRotationScheduler documents three rotation signals. Two of them could never trigger. usageCount is advanced only by KeyMetadata.updateLastUsed(), whose single production caller sat inside persistStatefulKeyStateAfterSign() behind an early return for any key that is not XMSS/XMSSMT/LMS/HSS. For ML-DSA, SLH-DSA, Falcon and every KEM key the counter therefore stayed at zero forever, while all five lifecycle managers gate rotation on "maxUsage > 0 && metadata.getUsageCount() >= maxUsage". setMaxKeyUsage and the documented camel.component.pqc.key-rotation-max-usage property were inert. That method is now recordKeyUsageAfterSign(): it advances and persists the usage count for every key, and only re-stores the key pair itself for the stateful algorithms, where the advanced one-time index has to survive a restart. This also removes a redundant remote write per signature for the non-stateful keys, which previously did neither. The scheduler's default key filter accepted only ACTIVE keys, so a key an operator had marked PENDING_ROTATION was skipped at the filter before needsRotation() - which does test for PENDING_ROTATION - was ever consulted. The default now accepts ACTIVE and PENDING_ROTATION; a filter supplied through setKeyFilter is unaffected. Co-Authored-By: Claude Opus 5 Signed-off-by: Andrea Cosentino --- .../camel/component/pqc/PQCProducer.java | 47 ++++---- .../pqc/lifecycle/KeyRotationScheduler.java | 18 +-- .../pqc/lifecycle/KeyRotationSignalsTest.java | 111 ++++++++++++++++++ .../pages/camel-4x-upgrade-guide-4_24.adoc | 16 +++ 4 files changed, 165 insertions(+), 27 deletions(-) create mode 100644 components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.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 a3a696f8f9652..d4d9e4e9026de 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 @@ -580,7 +580,7 @@ private void signature(Exchange exchange) } exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature); - persistStatefulKeyStateAfterSign(exchange); + recordKeyUsageAfterSign(exchange); } private void verification(Exchange exchange) @@ -831,7 +831,7 @@ private void hybridSignature(Exchange exchange) exchange.getMessage().setHeader(PQCConstants.PQC_SIGNATURE, components.pqcSignature()); try { - persistStatefulKeyStateAfterSign(exchange); + recordKeyUsageAfterSign(exchange); } catch (Exception e) { throw new RuntimeCamelException("Failed to persist stateful key state after hybrid signing", e); } @@ -1201,21 +1201,22 @@ private void checkStatefulKeyBeforeSign() { } /** - * Persists stateful key state after a signing operation through the KeyLifecycleManager, if configured. This - * ensures the key index is tracked across restarts. + * Records the use of the signing key through the KeyLifecycleManager, if one is configured and the exchange names a + * key. + *

+ * This advances {@code usageCount}, which is what the {@code maxKeyUsage} rotation signal of + * {@link org.apache.camel.component.pqc.lifecycle.KeyRotationScheduler} reads. It must therefore run for every key, + * not only the stateful ones: ML-DSA, SLH-DSA, Falcon and the KEM keys would otherwise sit at a usage count of zero + * forever and never be rotated. + *

+ * For a stateful signature key the advanced private key is stored as well, so the one-time index survives a + * restart. */ - private void persistStatefulKeyStateAfterSign(Exchange exchange) throws Exception { + private void recordKeyUsageAfterSign(Exchange exchange) throws Exception { if (keyPair == null || keyPair.getPrivate() == null) { return; } - PrivateKey privateKey = keyPair.getPrivate(); - long remaining = getStatefulKeyRemaining(privateKey); - if (remaining < 0) { - // Not a stateful key - return; - } - KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager(); if (klm == null) { return; @@ -1226,22 +1227,28 @@ private void persistStatefulKeyStateAfterSign(Exchange exchange) throws Exceptio return; } - // Update metadata with current usage + PrivateKey privateKey = keyPair.getPrivate(); + boolean stateful = getStatefulKeyRemaining(privateKey) >= 0; + KeyMetadata metadata = klm.getKeyMetadata(keyId); if (metadata == null) { - 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); + 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); + } return; } metadata.updateLastUsed(); klm.updateKeyMetadata(keyId, metadata); - // Persist the updated key (with new index) so state survives restarts - klm.storeKey(keyId, keyPair, metadata); + if (stateful) { + // Persist the updated key (with new index) so state survives restarts + klm.storeKey(keyId, keyPair, metadata); + } } /** diff --git a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java index 93a319f863472..f873c408c17d1 100644 --- a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java +++ b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyRotationScheduler.java @@ -54,10 +54,11 @@ * } * * On every tick the scheduler lists the managed keys, keeps only those matching the configured {@link #setKeyFilter key - * filter} (by default only {@link KeyMetadata.KeyStatus#ACTIVE ACTIVE} keys) and, for each of them, delegates the - * decision to {@link KeyLifecycleManager#needsRotation(String, Duration, long)}. When a key needs rotation, the id of - * its replacement is produced by the configured {@link #setKeyIdStrategy key id strategy} (by default the previous key - * id with a rotation marker and timestamp appended). + * filter} (by default {@link KeyMetadata.KeyStatus#ACTIVE ACTIVE} and {@link KeyMetadata.KeyStatus#PENDING_ROTATION + * PENDING_ROTATION} keys) and, for each of them, delegates the decision to + * {@link KeyLifecycleManager#needsRotation(String, Duration, long)}. When a key needs rotation, the id of its + * replacement is produced by the configured {@link #setKeyIdStrategy key id strategy} (by default the previous key id + * with a rotation marker and timestamp appended). *

* At least one rotation signal should be configured (a {@link #setMaxKeyAge maximum age}, a {@link #setMaxKeyUsage * maximum usage}, or a per-key {@code nextRotationAt}/{@code PENDING_ROTATION} status recorded in the key metadata); @@ -76,7 +77,10 @@ public class KeyRotationScheduler extends ServiceSupport implements CamelContext private Duration checkInterval = Duration.ofHours(1); private Duration maxKeyAge; private long maxKeyUsage; - private Predicate keyFilter = metadata -> metadata.getStatus() == KeyMetadata.KeyStatus.ACTIVE; + // PENDING_ROTATION has to pass the filter too: it is one of the documented rotation signals, and a key an + // operator marked that way is by definition no longer ACTIVE + private Predicate keyFilter = metadata -> metadata.getStatus() == KeyMetadata.KeyStatus.ACTIVE + || metadata.getStatus() == KeyMetadata.KeyStatus.PENDING_ROTATION; private Function keyIdStrategy = KeyRotationScheduler::defaultRotatedKeyId; private KeyRotationListener listener; @@ -269,8 +273,8 @@ public Predicate getKeyFilter() { } /** - * Selects which keys are considered for rotation. Defaults to only {@link KeyMetadata.KeyStatus#ACTIVE ACTIVE} - * keys. + * Selects which keys are considered for rotation. Defaults to {@link KeyMetadata.KeyStatus#ACTIVE ACTIVE} and + * {@link KeyMetadata.KeyStatus#PENDING_ROTATION PENDING_ROTATION} keys. */ public KeyRotationScheduler setKeyFilter(Predicate keyFilter) { this.keyFilter = ObjectHelper.notNull(keyFilter, "keyFilter"); diff --git a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java new file mode 100644 index 0000000000000..66d4948abe937 --- /dev/null +++ b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java @@ -0,0 +1,111 @@ +/* + * 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.lifecycle; + +import java.security.Security; + +import org.apache.camel.EndpointInject; +import org.apache.camel.Produce; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.component.pqc.PQCConstants; +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.assertTrue; + +/** + * The two rotation signals that never fired: usageCount was only advanced for stateful keys, and PENDING_ROTATION was + * filtered out before needsRotation() was ever consulted. + */ +public class KeyRotationSignalsTest extends CamelTestSupport { + + private static final String KEY_ID = "signing-key"; + + private final InMemoryKeyLifecycleManager keyManager = new InMemoryKeyLifecycleManager(); + + @EndpointInject("mock:sign") + protected MockEndpoint resultSign; + + @Produce("direct:sign") + protected ProducerTemplate templateSign; + + @BeforeEach + void addProvider() { + 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=MLDSA&keyLifecycleManager=#keyManager") + .to("mock:sign"); + } + }; + } + + @Test + void signingAStatelessKeyAdvancesTheUsageCount() throws Exception { + keyManager.generateKeyPair("MLDSA", KEY_ID); + assertEquals(0, keyManager.getKeyMetadata(KEY_ID).getUsageCount()); + + resultSign.expectedMessageCount(3); + for (int i = 0; i < 3; i++) { + templateSign.sendBodyAndHeader("Hello", PQCConstants.KEY_ID, KEY_ID); + } + resultSign.assertIsSatisfied(); + + // ML-DSA is not a stateful key; before the fix the counter stayed at zero and maxKeyUsage never fired + assertEquals(3, keyManager.getKeyMetadata(KEY_ID).getUsageCount(), + "every signing operation must advance the usage count"); + + assertTrue(keyManager.needsRotation(KEY_ID, null, 3), + "a key at its maxKeyUsage must be reported as needing rotation"); + } + + @Test + void aPendingRotationKeyIsSelectedByTheDefaultFilter() throws Exception { + keyManager.generateKeyPair("MLDSA", KEY_ID); + KeyMetadata metadata = keyManager.getKeyMetadata(KEY_ID); + metadata.setStatus(KeyMetadata.KeyStatus.PENDING_ROTATION); + keyManager.updateKeyMetadata(KEY_ID, metadata); + + KeyRotationScheduler scheduler = new KeyRotationScheduler(keyManager); + scheduler.setCamelContext(context); + + assertTrue(scheduler.getKeyFilter().test(keyManager.getKeyMetadata(KEY_ID)), + "the default key filter must not drop a PENDING_ROTATION key before needsRotation() is consulted"); + + int rotated = scheduler.checkAndRotate(); + + assertEquals(1, rotated, "a PENDING_ROTATION key must be rotated by the default configuration"); + } +} 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 d403cecd05274..a692fece79025 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 @@ -339,6 +339,22 @@ local model gets a core set of tools (`--tools=auto`, the default), a hosted mod of `--max-iterations` is now 25 instead of 10, and `--model llama3.2` now selects that model also when a model better at tool calling, such as `qwen3.6`, is installed. +=== camel-pqc - the usage-count and PENDING_ROTATION key rotation signals now fire + +Two of the three documented rotation signals of `KeyRotationScheduler` previously never triggered. Both now work, so a +deployment that already configured them will start rotating keys where before nothing happened. + +* `maxKeyUsage` (and `camel.component.pqc.key-rotation-max-usage`) read `usageCount`, which was only advanced for the + stateful signature algorithms (XMSS, XMSSMT, LMS, HSS). For ML-DSA, SLH-DSA, Falcon and the KEM keys the counter + stayed at zero forever. A signing operation now advances it for every key, provided a `keyLifecycleManager` is + configured and the exchange carries a `CamelPQCKeyId` header. +* The scheduler's default key filter accepted only `ACTIVE` keys, so a key an operator had marked + `PENDING_ROTATION` was skipped before `needsRotation()` was consulted. The default filter now accepts `ACTIVE` and + `PENDING_ROTATION`. A custom filter set with `setKeyFilter` is unaffected. + +Review any configured `maxKeyUsage` threshold before upgrading: a long-running deployment may rotate on the first +check once the counter starts advancing. + === 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 4ee49cdc89422722a4777e8853600989db12ddca Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Mon, 19 Oct 2026 13:35:39 +0200 Subject: [PATCH 2/4] CAMEL-25797: Address review feedback - correct the usage-count scope and a stale message The javadoc of recordKeyUsageAfterSign claimed the KEM keys were covered. They are not: no key encapsulation path calls the method, so a KEM key still does not advance its usage count and maxKeyUsage does not apply to it. Stating otherwise would mislead an operator configuring rotation on a KEM key. The javadoc now says which keys are covered and which are not. The hybrid signing catch block still reported "Failed to persist stateful key state" after the method was generalised from persisting stateful state to recording usage. Co-Authored-By: Claude Opus 5 Signed-off-by: Andrea Cosentino --- .../java/org/apache/camel/component/pqc/PQCProducer.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 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 d4d9e4e9026de..f56b76451b7b0 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 @@ -833,7 +833,7 @@ private void hybridSignature(Exchange exchange) try { recordKeyUsageAfterSign(exchange); } catch (Exception e) { - throw new RuntimeCamelException("Failed to persist stateful key state after hybrid signing", e); + throw new RuntimeCamelException("Failed to record key usage after hybrid signing", e); } } @@ -1205,10 +1205,13 @@ private void checkStatefulKeyBeforeSign() { * key. *

* This advances {@code usageCount}, which is what the {@code maxKeyUsage} rotation signal of - * {@link org.apache.camel.component.pqc.lifecycle.KeyRotationScheduler} reads. It must therefore run for every key, - * not only the stateful ones: ML-DSA, SLH-DSA, Falcon and the KEM keys would otherwise sit at a usage count of zero + * {@link org.apache.camel.component.pqc.lifecycle.KeyRotationScheduler} reads. It therefore runs for every signing + * key, not only the stateful ones: ML-DSA, SLH-DSA and Falcon keys would otherwise sit at a usage count of zero * forever and never be rotated. *

+ * The key encapsulation operations do not call this method, so a KEM key still does not advance its usage count and + * {@code maxKeyUsage} does not apply to it. + *

* For a stateful signature key the advanced private key is stored as well, so the one-time index survives a * restart. */ From 44fac579e1a0a2dd8df0615f6b43598d241b409a Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Mon, 19 Oct 2026 15:23:35 +0200 Subject: [PATCH 3/4] CAMEL-25797: Address review feedback - the cost of recording usage, and the rotation docs Recording the usage of a non-stateful key writes to the backing store on every signature, where such a key previously wrote nothing. The PR description had this backwards. Three changes follow from it. For a stateful key, storeKey already writes the metadata entry in every manager, so the updateKeyMetadata call before it was a second round trip for the same data: six secret writes per signature where three suffice. Only storeKey is called now. For a non-stateful key, a failure to record the usage no longer fails the exchange. It is logged once per producer and the signature is returned. A lost usage count only delays a rotation, which is not worth discarding a good signature for; a lost stateful index is a different matter and still fails. The upgrade guide now states the per-signature write, that a failure of it does not fail the exchange for a non-stateful key, and that the KEM operations never advance the count. pqc-key-lifecycle.adoc still described the default key filter as ACTIVE only, and said nothing about what maxKeyUsage actually counts. It now covers the widened filter, that the count needs a keyLifecycleManager and a CamelPQCKeyId header, that it is recorded against the key named by that header, that the KEM operations do not advance it, and that it costs a remote write per signature. Catalog copy updated. KeyRotationSignalsTest signed with the autowired default material while counting against a different key id, so it did not show the intended setup. It now signs with the very key pair it counts. updateLastUsed carries a note that the increment is not atomic. Co-Authored-By: Claude Opus 5 Signed-off-by: Andrea Cosentino --- .../camel/catalog/docs/pqc-key-lifecycle.adoc | 24 ++++++++++--- .../src/main/docs/pqc-key-lifecycle.adoc | 24 ++++++++++--- .../camel/component/pqc/PQCProducer.java | 22 ++++++++++-- .../component/pqc/lifecycle/KeyMetadata.java | 7 ++++ .../pqc/lifecycle/KeyRotationSignalsTest.java | 36 +++++++++++-------- .../pages/camel-4x-upgrade-guide-4_24.adoc | 9 +++++ 6 files changed, 97 insertions(+), 25 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc index b96d97c613c43..320bd65c10985 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc @@ -925,10 +925,26 @@ periodically in the background and rotate the ones that breach a configurable po the scheduler is started. On every tick the scheduler lists the keys of its `KeyLifecycleManager`, keeps the ones matching the key filter (by -default only `ACTIVE` keys) and, for each, delegates the decision to `needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A -key that needs rotation is replaced by a freshly generated key while the previous key is deprecated. At least one -rotation signal must be configured - a maximum age, a maximum usage, or a per-key `nextRotationAt`/`PENDING_ROTATION` -status recorded in the key metadata - otherwise no key will ever be selected. +default `ACTIVE` and `PENDING_ROTATION` keys) and, for each, delegates the decision to +`needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A key that needs rotation is replaced by a freshly generated key while +the previous key is deprecated. At least one rotation signal must be configured - a maximum age, a maximum usage, or a +per-key `nextRotationAt`/`PENDING_ROTATION` status recorded in the key metadata - otherwise no key will ever be +selected. + +[IMPORTANT] +==== +`maxKeyUsage` counts *signing operations only*, and only those made through an endpoint that has a +`keyLifecycleManager` configured and whose exchange carries the `CamelPQCKeyId` header naming the key. The count is +recorded against the key named by that header. + +The key encapsulation operations do not advance the count at all, so `maxKeyUsage` never triggers for a KEM key - use +`maxKeyAge` or `nextRotationAt` for those. The count is also not exact under concurrency; it is a rotation threshold, +not an audit figure. + +Recording the usage of a non-stateful key writes to the backing store on every signature. On a high volume route +against a remote vault, prefer `maxKeyAge` or `nextRotationAt`, or leave the `CamelPQCKeyId` header off the signing +exchanges. +==== ._Java-only: scheduling automated key rotation_ [source,java] diff --git a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc index b96d97c613c43..320bd65c10985 100644 --- a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc +++ b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc @@ -925,10 +925,26 @@ periodically in the background and rotate the ones that breach a configurable po the scheduler is started. On every tick the scheduler lists the keys of its `KeyLifecycleManager`, keeps the ones matching the key filter (by -default only `ACTIVE` keys) and, for each, delegates the decision to `needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A -key that needs rotation is replaced by a freshly generated key while the previous key is deprecated. At least one -rotation signal must be configured - a maximum age, a maximum usage, or a per-key `nextRotationAt`/`PENDING_ROTATION` -status recorded in the key metadata - otherwise no key will ever be selected. +default `ACTIVE` and `PENDING_ROTATION` keys) and, for each, delegates the decision to +`needsRotation(keyId, maxKeyAge, maxKeyUsage)`. A key that needs rotation is replaced by a freshly generated key while +the previous key is deprecated. At least one rotation signal must be configured - a maximum age, a maximum usage, or a +per-key `nextRotationAt`/`PENDING_ROTATION` status recorded in the key metadata - otherwise no key will ever be +selected. + +[IMPORTANT] +==== +`maxKeyUsage` counts *signing operations only*, and only those made through an endpoint that has a +`keyLifecycleManager` configured and whose exchange carries the `CamelPQCKeyId` header naming the key. The count is +recorded against the key named by that header. + +The key encapsulation operations do not advance the count at all, so `maxKeyUsage` never triggers for a KEM key - use +`maxKeyAge` or `nextRotationAt` for those. The count is also not exact under concurrency; it is a rotation threshold, +not an audit figure. + +Recording the usage of a non-stateful key writes to the backing store on every signature. On a high volume route +against a remote vault, prefer `maxKeyAge` or `nextRotationAt`, or leave the `CamelPQCKeyId` header off the signing +exchanges. +==== ._Java-only: scheduling automated key rotation_ [source,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 f56b76451b7b0..44a471b04ab6e 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 @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import javax.crypto.KeyAgreement; import javax.crypto.KeyGenerator; @@ -122,6 +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 + private final AtomicBoolean usageRecordFailureWarned = 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. private String signerAlgorithm; @@ -1246,11 +1250,25 @@ private void recordKeyUsageAfterSign(Exchange exchange) throws Exception { } metadata.updateLastUsed(); - klm.updateKeyMetadata(keyId, metadata); if (stateful) { - // Persist the updated key (with new index) so state survives restarts + // storeKey writes the metadata entry as well in every manager, so calling updateKeyMetadata first would + // simply double the number of remote writes per signature klm.storeKey(keyId, keyPair, metadata); + } else { + // Only the usage count is at stake here. Failing the exchange over it would throw away a perfectly good + // signature to protect a counter whose loss merely delays a rotation - unlike a stateful index, which + // cannot be recovered once it has been handed out twice. + try { + klm.updateKeyMetadata(keyId, metadata); + } catch (Exception e) { + if (usageRecordFailureWarned.compareAndSet(false, true)) { + LOG.warn("Could not record the usage of key '{}' through the keyLifecycleManager, so its" + + " usageCount does not advance and a configured maxKeyUsage threshold may not be" + + " reached. The signature itself is unaffected. Reported once per producer.", + keyId, e); + } + } } } diff --git a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java index 6bac87c69419e..27e97a41ad594 100644 --- a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java +++ b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadata.java @@ -78,6 +78,13 @@ public void setLastUsedAt(Instant lastUsedAt) { this.lastUsedAt = lastUsedAt; } + /** + * Records one use of the key. + *

+ * The increment is not atomic and the managers hand out the cached instance, so concurrent exchanges can lose + * counts. That is accepted: the count only feeds a rotation threshold, where undercounting delays a rotation rather + * than breaking anything. Do not rely on it as an exact number of operations. + */ public void updateLastUsed() { this.lastUsedAt = Instant.now(); this.usageCount++; diff --git a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java index 66d4948abe937..f93f830a86420 100644 --- a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java +++ b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java @@ -16,6 +16,7 @@ */ package org.apache.camel.component.pqc.lifecycle; +import java.security.KeyPair; import java.security.Security; import org.apache.camel.EndpointInject; @@ -27,7 +28,6 @@ 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; @@ -41,16 +41,7 @@ public class KeyRotationSignalsTest extends CamelTestSupport { private static final String KEY_ID = "signing-key"; - private final InMemoryKeyLifecycleManager keyManager = new InMemoryKeyLifecycleManager(); - - @EndpointInject("mock:sign") - protected MockEndpoint resultSign; - - @Produce("direct:sign") - protected ProducerTemplate templateSign; - - @BeforeEach - void addProvider() { + private static void registerProviders() { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } @@ -59,14 +50,31 @@ void addProvider() { } } + private final InMemoryKeyLifecycleManager keyManager = new InMemoryKeyLifecycleManager(); + private KeyPair signingKeyPair; + + @EndpointInject("mock:sign") + protected MockEndpoint resultSign; + + @Produce("direct:sign") + protected ProducerTemplate templateSign; + @Override protected RouteBuilder createRouteBuilder() { return new RouteBuilder() { @Override - public void configure() { + public void configure() throws Exception { + // re-registered per route build: until CAMEL-25795 lands, stopping the previous test's context + // de-registers the providers for the whole JVM + registerProviders(); context.getRegistry().bind("keyManager", keyManager); + // the key that signs is the one the usage is recorded against: recordKeyUsageAfterSign counts + // against the key named by CamelPQCKeyId, so the endpoint signs with that very key pair + signingKeyPair = keyManager.generateKeyPair("MLDSA", KEY_ID); + context.getRegistry().bind("signingKeyPair", signingKeyPair); from("direct:sign") - .to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager") + .to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager" + + "&keyPair=#signingKeyPair") .to("mock:sign"); } }; @@ -74,7 +82,6 @@ public void configure() { @Test void signingAStatelessKeyAdvancesTheUsageCount() throws Exception { - keyManager.generateKeyPair("MLDSA", KEY_ID); assertEquals(0, keyManager.getKeyMetadata(KEY_ID).getUsageCount()); resultSign.expectedMessageCount(3); @@ -93,7 +100,6 @@ void signingAStatelessKeyAdvancesTheUsageCount() throws Exception { @Test void aPendingRotationKeyIsSelectedByTheDefaultFilter() throws Exception { - keyManager.generateKeyPair("MLDSA", KEY_ID); KeyMetadata metadata = keyManager.getKeyMetadata(KEY_ID); metadata.setStatus(KeyMetadata.KeyStatus.PENDING_ROTATION); keyManager.updateKeyMetadata(KEY_ID, 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 a692fece79025..f32b9f22993c8 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 @@ -355,6 +355,15 @@ deployment that already configured them will start rotating keys where before no Review any configured `maxKeyUsage` threshold before upgrading: a long-running deployment may rotate on the first check once the counter starts advancing. +Recording the usage also costs a write. A signing endpoint with a `keyLifecycleManager` and a `CamelPQCKeyId` header +now updates the key metadata in the backing store on every signature, where a non-stateful key previously wrote +nothing. Against AWS Secrets Manager, Azure Key Vault or HashiCorp Vault that is a remote call per message. A failure +of that write does not fail the exchange for a non-stateful key - it is logged once per producer and the signature is +returned - because losing a usage count only delays a rotation. Leave the `CamelPQCKeyId` header off the signing +exchanges, or use `maxKeyAge` or `nextRotationAt`, if the write is not wanted. + +The key encapsulation operations do not record usage, so `maxKeyUsage` still never triggers for a KEM key. + === 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 0fd54fe93571cb18cf7d873e51dfccdbca01aa4c Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Tue, 20 Oct 2026 12:59:51 +0200 Subject: [PATCH 4/4] CAMEL-25797: Address review feedback - a failed metadata read for a non-stateful key keeps the signature The metadata read before the usage write went to the keyLifecycleManager unguarded, so a vault that could not be read failed the exchange of a non-stateful key although only its usage count was at stake. The read is now treated like the write: logged once per producer, the signature is returned. A stateful key still fails, as its index must be persisted. KeyRotationSignalsTest covers the read failure with a manager whose reads can be made to fail. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Andrea Cosentino --- .../camel/catalog/docs/pqc-key-lifecycle.adoc | 3 +- .../src/main/docs/pqc-key-lifecycle.adoc | 3 +- .../camel/component/pqc/PQCProducer.java | 32 +++++++++++--- .../pqc/lifecycle/KeyRotationSignalsTest.java | 44 ++++++++++++++++++- .../pages/camel-4x-upgrade-guide-4_24.adoc | 6 +-- 5 files changed, 75 insertions(+), 13 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc index 320bd65c10985..198f29c7e57de 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pqc-key-lifecycle.adoc @@ -943,7 +943,8 @@ not an audit figure. Recording the usage of a non-stateful key writes to the backing store on every signature. On a high volume route against a remote vault, prefer `maxKeyAge` or `nextRotationAt`, or leave the `CamelPQCKeyId` header off the signing -exchanges. +exchanges. A store that cannot be read or written does not fail the exchange of a non-stateful key: the usage is not +recorded, a warning is logged once per producer, and the signature is returned. ==== ._Java-only: scheduling automated key rotation_ diff --git a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc index 320bd65c10985..198f29c7e57de 100644 --- a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc +++ b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc @@ -943,7 +943,8 @@ not an audit figure. Recording the usage of a non-stateful key writes to the backing store on every signature. On a high volume route against a remote vault, prefer `maxKeyAge` or `nextRotationAt`, or leave the `CamelPQCKeyId` header off the signing -exchanges. +exchanges. A store that cannot be read or written does not fail the exchange of a non-stateful key: the usage is not +recorded, a warning is logged once per producer, and the signature is returned. ==== ._Java-only: scheduling automated key rotation_ 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 44a471b04ab6e..2a06175555de4 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 @@ -1237,7 +1237,17 @@ private void recordKeyUsageAfterSign(Exchange exchange) throws Exception { PrivateKey privateKey = keyPair.getPrivate(); boolean stateful = getStatefulKeyRemaining(privateKey) >= 0; - KeyMetadata metadata = klm.getKeyMetadata(keyId); + KeyMetadata metadata; + try { + metadata = klm.getKeyMetadata(keyId); + } catch (Exception e) { + if (stateful) { + throw e; + } + // the read fails for the same reasons as the write below, and costs the same: only the usage count + warnUsageRecordFailure(keyId, e); + return; + } if (metadata == null) { if (stateful) { LOG.warn( @@ -1262,16 +1272,24 @@ private void recordKeyUsageAfterSign(Exchange exchange) throws Exception { try { klm.updateKeyMetadata(keyId, metadata); } catch (Exception e) { - if (usageRecordFailureWarned.compareAndSet(false, true)) { - LOG.warn("Could not record the usage of key '{}' through the keyLifecycleManager, so its" - + " usageCount does not advance and a configured maxKeyUsage threshold may not be" - + " reached. The signature itself is unaffected. Reported once per producer.", - keyId, e); - } + warnUsageRecordFailure(keyId, e); } } } + /** + * Warns, once per producer, that the usage of a non-stateful key was not recorded because its metadata could not be + * read from or written to the keyLifecycleManager. The signature is kept: only the usage count is lost. + */ + private void warnUsageRecordFailure(String keyId, Exception e) { + if (usageRecordFailureWarned.compareAndSet(false, true)) { + LOG.warn("Could not record the usage of key '{}' through the keyLifecycleManager, so its" + + " usageCount does not advance and a configured maxKeyUsage threshold may not be" + + " reached. The signature itself is unaffected. Reported once per producer.", + keyId, e); + } + } + /** * 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/lifecycle/KeyRotationSignalsTest.java b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java index f93f830a86420..affabe24d4f1c 100644 --- a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java +++ b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -50,7 +51,23 @@ private static void registerProviders() { } } - private final InMemoryKeyLifecycleManager keyManager = new InMemoryKeyLifecycleManager(); + private final FailingReadKeyLifecycleManager keyManager = new FailingReadKeyLifecycleManager(); + + /** + * An in-memory manager whose metadata reads can be made to fail, like a vault that is unreachable. + */ + private static final class FailingReadKeyLifecycleManager extends InMemoryKeyLifecycleManager { + private volatile boolean failReads; + + @Override + public KeyMetadata getKeyMetadata(String keyId) { + if (failReads) { + throw new IllegalStateException("simulated vault outage"); + } + return super.getKeyMetadata(keyId); + } + } + private KeyPair signingKeyPair; @EndpointInject("mock:sign") @@ -76,6 +93,12 @@ public void configure() throws Exception { .to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager" + "&keyPair=#signingKeyPair") .to("mock:sign"); + // the key status check before signing is a deliberate fail-closed read of the same metadata; this + // route turns it off, so the only vault read of an exchange is the one recording the usage + from("direct:signNoStatusCheck") + .to("pqc:sign?operation=sign&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager" + + "&keyPair=#signingKeyPair&strictKeyLifecycle=false") + .to("mock:sign"); } }; } @@ -98,6 +121,25 @@ void signingAStatelessKeyAdvancesTheUsageCount() throws Exception { "a key at its maxKeyUsage must be reported as needing rotation"); } + @Test + void aFailedMetadataReadKeepsTheSignatureOfAStatelessKey() throws Exception { + resultSign.expectedMessageCount(1); + + keyManager.failReads = true; + try { + template.sendBodyAndHeader("direct:signNoStatusCheck", "Hello", PQCConstants.KEY_ID, KEY_ID); + } finally { + keyManager.failReads = false; + } + + resultSign.assertIsSatisfied(); + // ML-DSA is not stateful, so no one-time index was consumed: only the usage count is lost + assertNotNull(resultSign.getExchanges().get(0).getMessage().getHeader(PQCConstants.SIGNATURE, byte[].class), + "a vault that cannot be read must not cost the signature of a stateless key"); + assertEquals(0, keyManager.getKeyMetadata(KEY_ID).getUsageCount(), + "the usage that could not be recorded must not be counted afterwards"); + } + @Test void aPendingRotationKeyIsSelectedByTheDefaultFilter() throws Exception { KeyMetadata metadata = keyManager.getKeyMetadata(KEY_ID); 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 f32b9f22993c8..ea77862d2be7f 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 @@ -358,9 +358,9 @@ check once the counter starts advancing. Recording the usage also costs a write. A signing endpoint with a `keyLifecycleManager` and a `CamelPQCKeyId` header now updates the key metadata in the backing store on every signature, where a non-stateful key previously wrote nothing. Against AWS Secrets Manager, Azure Key Vault or HashiCorp Vault that is a remote call per message. A failure -of that write does not fail the exchange for a non-stateful key - it is logged once per producer and the signature is -returned - because losing a usage count only delays a rotation. Leave the `CamelPQCKeyId` header off the signing -exchanges, or use `maxKeyAge` or `nextRotationAt`, if the write is not wanted. +to read or write that metadata does not fail the exchange for a non-stateful key - it is logged once per producer and +the signature is returned - because losing a usage count only delays a rotation. Leave the `CamelPQCKeyId` header off +the signing exchanges, or use `maxKeyAge` or `nextRotationAt`, if the write is not wanted. The key encapsulation operations do not record usage, so `maxKeyUsage` still never triggers for a KEM key.