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 536d2e64fea9 CAMEL-25797: camel-pqc - the usage-count and
PENDING_ROTATION key rotation signals now fire (#28094)
536d2e64fea9 is described below
commit 536d2e64fea9b615f167fdc564976d01b04d200c
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 21 14:44:52 2026 +0200
CAMEL-25797: camel-pqc - the usage-count and PENDING_ROTATION key rotation
signals now fire (#28094)
KeyRotationScheduler documents three rotation signals, but two never fired.
The usage count was only advanced for stateful keys, so for ML-DSA, SLH-DSA,
Falcon and every KEM key it stayed at 0 and setMaxKeyUsage and
camel.component.pqc.key-rotation-max-usage had no effect. A key marked
PENDING_ROTATION was skipped by the default key filter, which only accepted
ACTIVE keys, before needsRotation() was consulted.
The producer now records and persists the usage count for every key and
re-stores the key pair only for stateful algorithms, whose one-time index
must
survive a restart. A failed metadata read for a non-stateful key is logged
and
does not fail the exchange. The default key filter of the scheduler accepts
ACTIVE and PENDING_ROTATION keys.
The 4.24 upgrade guide describes the change.
Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
---
.../camel/catalog/docs/pqc-key-lifecycle.adoc | 25 +++-
.../camel-pqc/src/main/docs/pqc-key-lifecycle.adoc | 25 +++-
.../apache/camel/component/pqc/PQCProducer.java | 92 +++++++++---
.../camel/component/pqc/lifecycle/KeyMetadata.java | 7 +
.../pqc/lifecycle/KeyRotationScheduler.java | 18 ++-
.../pqc/lifecycle/KeyRotationSignalsTest.java | 159 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 25 ++++
7 files changed, 313 insertions(+), 38 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 b96d97c613c4..198f29c7e57d 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,27 @@ 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. 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_
[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 b96d97c613c4..198f29c7e57d 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,27 @@ 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. 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_
[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 a3a696f8f965..2a06175555de 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.Arrays;
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;
@@ -580,7 +584,7 @@ public class PQCProducer extends DefaultProducer {
}
exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);
- persistStatefulKeyStateAfterSign(exchange);
+ recordKeyUsageAfterSign(exchange);
}
private void verification(Exchange exchange)
@@ -831,9 +835,9 @@ public class PQCProducer extends DefaultProducer {
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);
+ throw new RuntimeCamelException("Failed to record key usage after
hybrid signing", e);
}
}
@@ -1201,21 +1205,25 @@ public class PQCProducer extends DefaultProducer {
}
/**
- * 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.
+ * <p/>
+ * This advances {@code usageCount}, which is what the {@code maxKeyUsage}
rotation signal of
+ * {@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.
+ * <p/>
+ * 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.
+ * <p/>
+ * 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 +1234,60 @@ public class PQCProducer extends DefaultProducer {
return;
}
- // Update metadata with current usage
- KeyMetadata metadata = klm.getKeyMetadata(keyId);
+ PrivateKey privateKey = keyPair.getPrivate();
+ boolean stateful = getStatefulKeyRemaining(privateKey) >= 0;
+
+ 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) {
- 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) {
+ // 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) {
+ 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);
+ }
}
/**
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 6bac87c69419..27e97a41ad59 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 class KeyMetadata implements Serializable {
this.lastUsedAt = lastUsedAt;
}
+ /**
+ * Records one use of the key.
+ * <p/>
+ * 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/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 93a319f86347..f873c408c17d 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 @@ import org.slf4j.LoggerFactory;
* }</pre>
*
* 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).
* <p/>
* 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<KeyMetadata> 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<KeyMetadata> keyFilter = metadata ->
metadata.getStatus() == KeyMetadata.KeyStatus.ACTIVE
+ || metadata.getStatus() == KeyMetadata.KeyStatus.PENDING_ROTATION;
private Function<KeyMetadata, String> keyIdStrategy =
KeyRotationScheduler::defaultRotatedKeyId;
private KeyRotationListener listener;
@@ -269,8 +273,8 @@ public class KeyRotationScheduler extends ServiceSupport
implements CamelContext
}
/**
- * 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<KeyMetadata> 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 000000000000..affabe24d4f1
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyRotationSignalsTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.KeyPair;
+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.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;
+
+/**
+ * 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 static void registerProviders() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) ==
null) {
+ Security.addProvider(new BouncyCastlePQCProvider());
+ }
+ }
+
+ 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")
+ protected MockEndpoint resultSign;
+
+ @Produce("direct:sign")
+ protected ProducerTemplate templateSign;
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ 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"
+ + "&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");
+ }
+ };
+ }
+
+ @Test
+ void signingAStatelessKeyAdvancesTheUsageCount() throws Exception {
+ 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 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);
+ 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 58cacfbf022b..5587787d422e 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,31 @@ 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.
+
+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
+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.
+
=== 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: