Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
25 changes: 21 additions & 4 deletions components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -580,7 +584,7 @@ private void signature(Exchange exchange)
}
exchange.getMessage().setHeader(PQCConstants.SIGNATURE, signature);

persistStatefulKeyStateAfterSign(exchange);
recordKeyUsageAfterSign(exchange);
}

private void verification(Exchange exchange)
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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.
* <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;
Expand All @@ -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);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ public void setLastUsedAt(Instant lastUsedAt) {
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++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@
* }</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);
Expand All @@ -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;

Expand Down Expand Up @@ -269,8 +273,8 @@ public Predicate<KeyMetadata> 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<KeyMetadata> keyFilter) {
this.keyFilter = ObjectHelper.notNull(keyFilter, "keyFilter");
Expand Down
Loading
Loading