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..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 @@ -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 b96d97c613c43..198f29c7e57de 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 a3a696f8f9652..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 @@ -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; @@ -580,7 +584,7 @@ private void signature(Exchange exchange) } exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature); - persistStatefulKeyStateAfterSign(exchange); + recordKeyUsageAfterSign(exchange); } private void verification(Exchange exchange) @@ -831,9 +835,9 @@ 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); + throw new RuntimeCamelException("Failed to record key usage after hybrid signing", e); } } @@ -1201,21 +1205,25 @@ 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 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. */ - 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 @@ private void persistStatefulKeyStateAfterSign(Exchange exchange) throws Exceptio 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 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/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..affabe24d4f1c --- /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 d403cecd05274..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 @@ -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: