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