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 12c6e613f746 CAMEL-26588: camel-pqc - the stateful key claim follows
the key, not the CamelPQCKeyId header (#29555)
12c6e613f746 is described below
commit 12c6e613f746ac157aacba78e0ebed9b49c05eab
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Nov 11 10:29:12 2026 +0100
CAMEL-26588: camel-pqc - the stateful key claim follows the key, not the
CamelPQCKeyId header (#29555)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../camel/catalog/docs/pqc-key-lifecycle.adoc | 23 +-
.../camel-pqc/src/main/docs/pqc-key-lifecycle.adoc | 23 +-
.../apache/camel/component/pqc/PQCProducer.java | 115 ++++++----
.../lifecycle/FileBasedKeyLifecycleManager.java | 79 +++++--
.../pqc/lifecycle/InMemoryKeyLifecycleManager.java | 2 +-
.../pqc/lifecycle/KeyLifecycleManager.java | 49 +++-
.../pqc/PQCStatefulKeyClaimIdentityTest.java | 170 ++++++++++++++
.../component/pqc/PQCStatefulKeyClaimTest.java | 18 +-
...eBasedKeyLifecycleManagerStatefulClaimTest.java | 248 ++++++++++++++++-----
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 14 +-
10 files changed, 594 insertions(+), 147 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 156bf5be4573..6896ea5ad703 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
@@ -1299,11 +1299,13 @@ holds the claim until the route stops. What the claim
is worth depends on the st
| Manager | Claim
| `FileBasedKeyLifecycleManager`
-| An exclusive `FileLock` on a `<keyId>.lock` file in the key directory. A
second process reading the same directory
- is refused, and so is a second manager in the same JVM, even through another
path to the directory; the operating
- system releases the lock if the holder dies. Producers that share one
manager share its claim, which is given back
- when the last of them stops. Once the claim is granted the key and its
metadata are read from the store again, so
- a producer that waited for the claim resumes from the index the previous
owner reached.
+| An exclusive `FileLock` on a lock file in the key directory, named from the
SHA-256 fingerprint of the public key,
+ so the claim follows the key material: the same key signed under two
`CamelPQCKeyId` values, or without the
+ header, is one claim. A second process reading the same directory is
refused, and so is a second manager in the
+ same JVM, even through another path to the directory; the operating system
releases the lock if the holder dies.
+ Producers that share one manager share its claim, which is given back when
the last of them stops. Once the claim
+ is granted the key and its metadata are read from the store again, so a
producer that waited for the claim resumes
+ from the index the previous owner reached.
| `InMemoryKeyLifecycleManager`
| Granted: the keys never leave the process.
@@ -1317,13 +1319,18 @@ A refused claim fails the exchange with a message
naming the key. If a single si
outside Camel - one replica, an external lock, a leader election - set
`allowSharedStatefulKey=true` on the endpoint
to sign anyway. There is no such thing as a safe default here: the option
exists so the decision is explicit.
-The claim needs the `CamelPQCKeyId` header to name the key, which is the same
header the usage recording uses. An
+The claim does not depend on the `CamelPQCKeyId` header: the key is claimed by
its public key, under whatever name
+the exchange gives it, or none. The header still names the stored copy, for
the usage recording and for the re-read
+after the claim. Resuming from the stored index therefore needs the header: an
unnamed claim serialises the signers,
+but nothing names the copy to read back, so the producer keeps the key it
loaded until an exchange names it. An
endpoint signing with a stateful key and no `KeyLifecycleManager` persists no
index at all, and already warns about
it; nothing can be claimed in that case either.
A custom `KeyLifecycleManager` that can guarantee exclusivity - a database row
lock, a distributed lease - should
-override `claimStatefulKey` to take it and return `true`, and
`releaseStatefulKey` to give it back. Count the claims
-when several producers can share the manager, so that the key is given back by
the last of them, not the first.
+override `claimStatefulKey(String, PublicKey)` to take it and return `true`,
and `releaseStatefulKey(String, PublicKey)`
+to give it back. Key the lease on `KeyLifecycleManager.fingerprint(publicKey)`
rather than on the name, so that it
+follows the key, and count the claims when several producers can share the
manager, so that the key is given back by
+the last of them, not the first.
**5. Secure Key Export**
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 156bf5be4573..6896ea5ad703 100644
--- a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
+++ b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
@@ -1299,11 +1299,13 @@ holds the claim until the route stops. What the claim
is worth depends on the st
| Manager | Claim
| `FileBasedKeyLifecycleManager`
-| An exclusive `FileLock` on a `<keyId>.lock` file in the key directory. A
second process reading the same directory
- is refused, and so is a second manager in the same JVM, even through another
path to the directory; the operating
- system releases the lock if the holder dies. Producers that share one
manager share its claim, which is given back
- when the last of them stops. Once the claim is granted the key and its
metadata are read from the store again, so
- a producer that waited for the claim resumes from the index the previous
owner reached.
+| An exclusive `FileLock` on a lock file in the key directory, named from the
SHA-256 fingerprint of the public key,
+ so the claim follows the key material: the same key signed under two
`CamelPQCKeyId` values, or without the
+ header, is one claim. A second process reading the same directory is
refused, and so is a second manager in the
+ same JVM, even through another path to the directory; the operating system
releases the lock if the holder dies.
+ Producers that share one manager share its claim, which is given back when
the last of them stops. Once the claim
+ is granted the key and its metadata are read from the store again, so a
producer that waited for the claim resumes
+ from the index the previous owner reached.
| `InMemoryKeyLifecycleManager`
| Granted: the keys never leave the process.
@@ -1317,13 +1319,18 @@ A refused claim fails the exchange with a message
naming the key. If a single si
outside Camel - one replica, an external lock, a leader election - set
`allowSharedStatefulKey=true` on the endpoint
to sign anyway. There is no such thing as a safe default here: the option
exists so the decision is explicit.
-The claim needs the `CamelPQCKeyId` header to name the key, which is the same
header the usage recording uses. An
+The claim does not depend on the `CamelPQCKeyId` header: the key is claimed by
its public key, under whatever name
+the exchange gives it, or none. The header still names the stored copy, for
the usage recording and for the re-read
+after the claim. Resuming from the stored index therefore needs the header: an
unnamed claim serialises the signers,
+but nothing names the copy to read back, so the producer keeps the key it
loaded until an exchange names it. An
endpoint signing with a stateful key and no `KeyLifecycleManager` persists no
index at all, and already warns about
it; nothing can be claimed in that case either.
A custom `KeyLifecycleManager` that can guarantee exclusivity - a database row
lock, a distributed lease - should
-override `claimStatefulKey` to take it and return `true`, and
`releaseStatefulKey` to give it back. Count the claims
-when several producers can share the manager, so that the key is given back by
the last of them, not the first.
+override `claimStatefulKey(String, PublicKey)` to take it and return `true`,
and `releaseStatefulKey(String, PublicKey)`
+to give it back. Key the lease on `KeyLifecycleManager.fingerprint(publicKey)`
rather than on the name, so that it
+follows the key, and count the claims when several producers can share the
manager, so that the key is given back by
+the last of them, not the first.
**5. Secure Key Export**
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 9bab2bbd683b..72588287e718 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
@@ -134,12 +134,16 @@ public class PQCProducer extends DefaultProducer {
private final AtomicBoolean usageRecordFailureWarned = new AtomicBoolean();
private final AtomicBoolean keyUsageNotRecordedWarned = new
AtomicBoolean();
- // The stateful keys this producer has claimed, so the claim is taken once
and released when it stops
- private final Set<String> claimedStatefulKeys =
ConcurrentHashMap.newKeySet();
- // Serialises the claim of a key: the mapping function runs once, so
concurrent first-time signers
- // of the same key cannot both reach claimStatefulKey
- private final ConcurrentMap<String, Boolean> statefulKeyClaims = new
ConcurrentHashMap<>();
- private final Set<String> sharedStatefulKeyWarned =
ConcurrentHashMap.newKeySet();
+ // The claim is on the key material of this producer, so it is taken once,
under the name the first exchange
+ // gives the key, and released when the producer stops
+ private final Object statefulClaimLock = new Object();
+ private volatile boolean statefulKeyClaimed;
+ private String claimedStatefulKeyId;
+ private PublicKey claimedStatefulPublicKey;
+ // The stored key is re-read once per name it is signed under: the mapping
function runs once, so concurrent
+ // first-time signers under the same name wait for it
+ private final ConcurrentMap<String, Boolean> statefulKeyAdoptions = new
ConcurrentHashMap<>();
+ private final AtomicBoolean sharedStatefulKeyWarned = 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.
@@ -594,20 +598,20 @@ public class PQCProducer extends DefaultProducer {
*/
private void releaseClaimedStatefulKeys() {
KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager();
- if (klm == null) {
- claimedStatefulKeys.clear();
- statefulKeyClaims.clear();
- return;
- }
- for (String keyId : claimedStatefulKeys) {
- try {
- klm.releaseStatefulKey(keyId);
- } catch (Exception e) {
- LOG.warn("Could not release the claim on the stateful key
'{}': {}", keyId, e.getMessage(), e);
+ synchronized (statefulClaimLock) {
+ if (statefulKeyClaimed && klm != null) {
+ try {
+ klm.releaseStatefulKey(claimedStatefulKeyId,
claimedStatefulPublicKey);
+ } catch (Exception e) {
+ LOG.warn("Could not release the claim on the stateful key
{}: {}",
+ statefulKeyLabel(claimedStatefulKeyId),
e.getMessage(), e);
+ }
}
+ statefulKeyClaimed = false;
+ claimedStatefulKeyId = null;
+ claimedStatefulPublicKey = null;
}
- claimedStatefulKeys.clear();
- statefulKeyClaims.clear();
+ statefulKeyAdoptions.clear();
}
private void signature(Exchange exchange)
@@ -1288,6 +1292,10 @@ public class PQCProducer extends DefaultProducer {
* directory, a vault - hands each of them the same key at the same index.
The in-process ordering of
* {@link #recordKeyUsageAfterSign(Exchange)} does nothing about that, so
the key is claimed from the
* {@link KeyLifecycleManager} the first time it is signed with, and held
until this producer stops.
+ * <p/>
+ * The claim is on the key material, identified by its public key: it is
taken once per producer, whatever name the
+ * exchanges give the key through the {@code CamelPQCKeyId} header, and
also when they give none. The name is what
+ * the store knows the key by, so the stored copy is re-read once per
name, after the claim.
*/
private void claimStatefulKeyBeforeSign(Exchange exchange) {
if (keyPair == null || keyPair.getPrivate() == null
@@ -1296,47 +1304,70 @@ public class PQCProducer extends DefaultProducer {
}
KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager();
- String keyId = exchange.getMessage().getHeader(PQCConstants.KEY_ID,
String.class);
- if (klm == null || ObjectHelper.isEmpty(keyId)) {
- // There is no store to claim from, and no key to name.
recordKeyUsageAfterSign already warns that the
- // index of this signature is not being persisted at all, which is
the larger problem of the two.
+ if (klm == null) {
+ // There is no store to claim from. recordKeyUsageAfterSign
already warns that the index of this
+ // signature is not being persisted at all, which is the larger
problem of the two.
return;
}
- if (claimedStatefulKeys.contains(keyId)) {
+ String keyId = exchange.getMessage().getHeader(PQCConstants.KEY_ID,
String.class);
+ if (ObjectHelper.isEmpty(keyId)) {
+ keyId = null;
+ }
+ PublicKey publicKey = keyPair.getPublic();
+ if (keyId == null && KeyLifecycleManager.fingerprint(publicKey) ==
null) {
+ // Nothing identifies the key: the exchange names none and the
public key gives no encoding to
+ // fingerprint. recordKeyUsageAfterSign warns that the index is
not persisted in this case.
return;
}
- // The claim is taken once per key. computeIfAbsent rather than a
contains/claim/add sequence, so two
- // concurrent first-time signers cannot both reach claimStatefulKey
for the same key.
- statefulKeyClaims.computeIfAbsent(keyId, id -> {
- doClaimStatefulKey(klm, id);
- adoptStoredStatefulKeyIfAhead(klm, id);
- claimedStatefulKeys.add(id);
- return Boolean.TRUE;
- });
+ if (!statefulKeyClaimed) {
+ synchronized (statefulClaimLock) {
+ if (!statefulKeyClaimed) {
+ doClaimStatefulKey(klm, keyId, publicKey);
+ // The first re-read happens before the claim is
published, so no concurrent exchange, under
+ // another name or none, signs from the key loaded at
startup while the stored one is read
+ if (keyId != null) {
+ adoptStoredStatefulKeyIfAhead(klm, keyId);
+ statefulKeyAdoptions.put(keyId, Boolean.TRUE);
+ }
+ claimedStatefulKeyId = keyId;
+ claimedStatefulPublicKey = publicKey;
+ statefulKeyClaimed = true;
+ }
+ }
+ }
+
+ if (keyId != null) {
+ // computeIfAbsent rather than a contains/read/add sequence, so
two concurrent first-time signers under
+ // the same name cannot both re-read the stored key
+ statefulKeyAdoptions.computeIfAbsent(keyId, id -> {
+ adoptStoredStatefulKeyIfAhead(klm, id);
+ return Boolean.TRUE;
+ });
+ }
}
- private void doClaimStatefulKey(KeyLifecycleManager klm, String keyId) {
+ private void doClaimStatefulKey(KeyLifecycleManager klm, String keyId,
PublicKey publicKey) {
boolean claimed;
try {
- claimed = klm.claimStatefulKey(keyId);
+ claimed = klm.claimStatefulKey(keyId, publicKey);
} catch (Exception e) {
throw new IllegalStateException(
- "Cannot claim the stateful key '" + keyId + "': " +
e.getMessage(), e);
+ "Cannot claim the stateful key " + statefulKeyLabel(keyId)
+ ": " + e.getMessage(), e);
}
if (!claimed) {
if (getConfiguration().isAllowSharedStatefulKey()) {
- if (sharedStatefulKeyWarned.add(keyId)) {
- LOG.warn("Signing with the stateful key '{}', which {}
cannot claim for this process alone,"
+ if (sharedStatefulKeyWarned.compareAndSet(false, true)) {
+ LOG.warn("Signing with the stateful key {}, which {}
cannot claim for this process alone,"
+ " because allowSharedStatefulKey is enabled. If
a second process signs with the same"
+ " key, both hand out the same one-time index
and the key is broken.",
- keyId, klm.getClass().getSimpleName());
+ statefulKeyLabel(keyId),
klm.getClass().getSimpleName());
}
} else {
throw new IllegalStateException(
- "The stateful key '" + keyId + "' cannot be claimed
for this process: "
+ "The stateful key " + statefulKeyLabel(keyId) + "
cannot be claimed for this process: "
+
klm.getClass().getSimpleName()
+ " cannot guarantee that no
other process holds it. "
+
keyPair.getPrivate().getAlgorithm()
@@ -1349,6 +1380,14 @@ public class PQCProducer extends DefaultProducer {
}
}
+ /**
+ * How the stateful key of this producer is referred to in messages: by
the name the exchange gave it, or as the key
+ * of this endpoint when the exchange gave none.
+ */
+ private static String statefulKeyLabel(String keyId) {
+ return keyId != null ? "'" + keyId + "'" : "of this endpoint (no
CamelPQCKeyId header)";
+ }
+
/**
* Re-reads the stored key once the claim is granted, and adopts it when
its index has moved past the one this
* producer loaded.
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
index 4f8d6b65b5a5..d8f8b1940060 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManager.java
@@ -509,25 +509,31 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
}
/**
- * Takes an exclusive {@link FileLock} on a lock file beside the key, so
that a second process reading the same key
- * directory - two containers on one volume, say - cannot sign from the
same one-time index.
+ * Takes an exclusive {@link FileLock} on a lock file in the key
directory, so that a second process reading the
+ * same key directory - two containers on one volume, say - cannot sign
from the same one-time index.
+ * <p/>
+ * The lock file is named from the fingerprint of the public key, so the
claim follows the key material: the same
+ * key signed under two names, or under no name at all, contends for one
file. A key whose public key gives no
+ * encoding is claimed under its name instead.
* <p/>
* The lock lives as long as the channel, which this manager keeps open,
so the operating system releases it if the
* process dies without releasing the claim. Every claim counts as a
holder of the key, so producers that share this
* manager do not release each other's claim: the key is given back when
the last holder releases it.
*/
@Override
- public boolean claimStatefulKey(String keyId) throws IOException {
+ public boolean claimStatefulKey(String keyId, PublicKey publicKey) throws
IOException {
+ String lockName = lockName(keyId, publicKey);
+ String label = keyLabel(keyId, lockName);
// compute so the whole open-lock-store sequence happens once per key:
a plain containsKey check followed by
// a separate put lets two concurrent first-time signers both open a
channel, and the loser then finds the
// file locked for no reason. A claim on a key this manager already
holds adds a holder instead.
try {
- statefulClaims.compute(keyId, (id, claim) -> {
+ statefulClaims.compute(lockName, (name, claim) -> {
if (claim != null) {
claim.holders.incrementAndGet();
return claim;
}
- return openAndLock(id);
+ return openAndLock(name, keyId, label);
});
} catch (UncheckedIOException e) {
throw e.getCause();
@@ -544,35 +550,37 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
* {@link #JVM_CLAIMS} first, under its real path, and a second manager of
the same directory is refused there,
* before any channel is opened. A channel is only ever closed here when
this process holds no lock on the file.
* <p/>
- * Taking a new lock also drops the cached key and metadata: the next read
comes from the store, which is how a
+ * Taking a new lock also drops the cached keys and metadata: the next
read comes from the store, which is how a
* producer that waited for the claim resumes from the index the previous
owner reached.
*/
- private StatefulClaim openAndLock(String keyId) {
- Path lockFile = resolveKeyFile(keyId, LOCK_SUFFIX);
+ private StatefulClaim openAndLock(String lockName, String keyId, String
label) {
+ Path lockFile = resolveKeyFile(lockName, LOCK_SUFFIX);
Path lockPath = realPath(lockFile);
FileChannel[] opened = new FileChannel[1];
JVM_CLAIMS.compute(lockPath, (path, holder) -> {
if (holder != null) {
throw new IllegalStateException(
- "The stateful key '" + keyId + "' is already claimed
in this JVM through " + lockPath
+ "The stateful key " + label + " is already claimed in
this JVM through " + lockPath
+ ". A stateful signature key
must have a single owner: share the"
+ " KeyLifecycleManager
instance rather than the key directory.");
}
- opened[0] = openAndLockChannel(keyId, lockFile);
+ opened[0] = openAndLockChannel(label, lockFile);
return this;
});
- // The store may have moved on while the previous owner held the key:
read it again rather than from cache
- keyCache.remove(keyId);
- metadataCache.remove(keyId);
- LOG.info("Claimed the stateful key '{}' through {}", keyId, lockFile);
+ // The store may have moved on while the previous owner held the key:
read it again rather than from cache.
+ // Both caches go, not only the entry of the name the claim was made
under: the key may be read back under
+ // another name, or the claim may have no name at all, and claims are
rare.
+ keyCache.clear();
+ metadataCache.clear();
+ LOG.info("Claimed the stateful key {} through {}", label, lockFile);
return new StatefulClaim(lockPath, opened[0]);
}
/**
* Opens the lock file and takes the exclusive lock on it, returning the
channel that holds the lock.
*/
- private static FileChannel openAndLockChannel(String keyId, Path lockFile)
{
+ private static FileChannel openAndLockChannel(String label, Path lockFile)
{
FileChannel channel;
try {
channel = FileChannel.open(lockFile, StandardOpenOption.CREATE,
StandardOpenOption.WRITE);
@@ -589,7 +597,7 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
// closed: closing it, now or when the garbage collector gets to
it, would drop that lock.
FOREIGN_LOCK_CHANNELS.add(channel);
throw new IllegalStateException(
- "The stateful key '" + keyId + "' is locked in this JVM
through " + lockFile
+ "The stateful key " + label + " is locked in this JVM
through " + lockFile
+ " outside of any
FileBasedKeyLifecycleManager. A stateful signature key"
+ " must have a single owner.",
e);
@@ -603,7 +611,7 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
// Held by another process, so this one holds no lock on the file
and may close its channel
closeQuietly(channel);
throw new IllegalStateException(
- "The stateful key '" + keyId + "' is held by another
process through " + lockFile
+ "The stateful key " + label + " is held by another process
through " + lockFile
+ ". Signing from it here would
hand out a one-time index twice, which"
+ " breaks the key. Give each
process its own stateful key, or stop the"
+ " other one.");
@@ -617,15 +625,17 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
* <p/>
* The lock file itself is left behind on purpose. Deleting it would be
unsafe: another process can already have the
* same file open and locked, and unlinking it lets a third process create
a fresh file at that path and lock that
- * instead - two owners, each holding a valid lock on a different inode.
An empty {@code .lock} file beside each
- * stateful key is the cost of that safety.
+ * instead - two owners, each holding a valid lock on a different inode.
An empty {@code .lock} file per stateful
+ * key is the cost of that safety.
*/
@Override
- public void releaseStatefulKey(String keyId) {
+ public void releaseStatefulKey(String keyId, PublicKey publicKey) {
+ String lockName = lockName(keyId, publicKey);
StatefulClaim[] released = new StatefulClaim[1];
- statefulClaims.computeIfPresent(keyId, (id, claim) -> {
+ statefulClaims.computeIfPresent(lockName, (name, claim) -> {
if (claim.holders.decrementAndGet() > 0) {
- LOG.debug("The stateful key '{}' stays claimed, {} holders
remain", id, claim.holders.get());
+ LOG.debug("The stateful key {} stays claimed, {} holders
remain", keyLabel(keyId, name),
+ claim.holders.get());
return claim;
}
released[0] = claim;
@@ -635,10 +645,33 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
// closing the channel releases the lock it holds, and only then
is the file free for the next manager
closeQuietly(released[0].channel);
JVM_CLAIMS.remove(released[0].lockPath, this);
- LOG.info("Released the claim on the stateful key '{}'", keyId);
+ LOG.info("Released the claim on the stateful key {}",
keyLabel(keyId, lockName));
}
}
+ /**
+ * The name of the lock file of a key: the fingerprint of its public key,
so that the claim follows the key material
+ * whatever name the key is stored under, or the name when the key gives
no encoding to fingerprint.
+ */
+ private static String lockName(String keyId, PublicKey publicKey) {
+ String fingerprint = KeyLifecycleManager.fingerprint(publicKey);
+ if (fingerprint != null) {
+ return fingerprint;
+ }
+ if (keyId != null && !keyId.isBlank()) {
+ return keyId;
+ }
+ throw new IllegalArgumentException(
+ "A stateful key is claimed by its public key, or by its name
when the key gives no encoding; neither was given");
+ }
+
+ /**
+ * How a key is referred to in messages: its name when it has one,
otherwise the fingerprint it is claimed under.
+ */
+ private static String keyLabel(String keyId, String lockName) {
+ return keyId != null ? "'" + keyId + "'" : "with fingerprint " +
lockName;
+ }
+
/**
* The real path of a lock file, so that two managers that reach one
directory through different paths, a symbolic
* link say, are known to hold the same file.
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/InMemoryKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/InMemoryKeyLifecycleManager.java
index b7c8cb619139..1ce8c7eb7a3a 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/InMemoryKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/InMemoryKeyLifecycleManager.java
@@ -186,7 +186,7 @@ public class InMemoryKeyLifecycleManager implements
KeyLifecycleManager {
* always granted.
*/
@Override
- public boolean claimStatefulKey(String keyId) {
+ public boolean claimStatefulKey(String keyId, PublicKey publicKey) {
return true;
}
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyLifecycleManager.java
index f5e806b07893..00eac949c484 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyLifecycleManager.java
@@ -17,7 +17,11 @@
package org.apache.camel.component.pqc.lifecycle;
import java.security.KeyPair;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
import java.time.Duration;
+import java.util.HexFormat;
import java.util.List;
/**
@@ -107,32 +111,59 @@ public interface KeyLifecycleManager {
* processes read from - a shared directory, a vault - hands the same key,
at the same index, to each of them, and
* each advances its own copy. The claim is what keeps a key to a single
owner.
* <p/>
- * The claim is held until {@link #releaseStatefulKey(String)}, or until
the process ends. Claiming a key this
- * manager has already claimed succeeds again and adds a holder: the key
is given back when the last holder releases
- * it, so producers that share a manager do not release each other's claim.
+ * The claim is on the key material, identified by its public key (see
{@link #fingerprint(PublicKey)}), not on the
+ * name the key is stored under: the same key signed under two names, or
under none, is one claim. The name, when
+ * there is one, is what the store knows the key by, for messages and for
reading the key back.
+ * <p/>
+ * The claim is held until {@link #releaseStatefulKey(String, PublicKey)},
or until the process ends. Claiming a key
+ * this manager has already claimed succeeds again and adds a holder: the
key is given back when the last holder
+ * releases it, so producers that share a manager do not release each
other's claim.
*
- * @param keyId the key to claim
+ * @param keyId the name the key is stored under, or {@code null} for
a key that has none
+ * @param publicKey the public key of the key pair, which identifies the
claim
* @return {@code true} when this manager guarantees no other
process holds the same key, {@code false}
* when the store cannot offer that guarantee - the
default, since most stores cannot
* @throws Exception when the key is held by someone else, or the claim
cannot be attempted
* @since 4.24
*/
- default boolean claimStatefulKey(String keyId) throws Exception {
+ default boolean claimStatefulKey(String keyId, PublicKey publicKey) throws
Exception {
return false;
}
/**
- * Releases a claim taken by {@link #claimStatefulKey(String)}. The key is
given back once every claim on it is
- * released. Releasing a key that was never claimed does nothing.
+ * Releases a claim taken by {@link #claimStatefulKey(String, PublicKey)}.
The key is given back once every claim on
+ * it is released. Releasing a key that was never claimed does nothing.
*
- * @param keyId the key to release
+ * @param keyId the name the key is stored under, or {@code null} for
a key that has none
+ * @param publicKey the public key of the key pair, which identifies the
claim
* @throws Exception when the claim cannot be released
* @since 4.24
*/
- default void releaseStatefulKey(String keyId) throws Exception {
+ default void releaseStatefulKey(String keyId, PublicKey publicKey) throws
Exception {
// nothing is claimed by default
}
+ /**
+ * The fingerprint of a public key: the SHA-256 digest of its encoding, in
lower-case hex. It identifies the key
+ * material independently of the name the key is stored under, which is
what a claim on a stateful key has to be
+ * tied to.
+ *
+ * @param publicKey the public key, may be {@code null}
+ * @return the fingerprint, or {@code null} when there is no key
or the key gives no encoding
+ * @since 4.24
+ */
+ static String fingerprint(PublicKey publicKey) {
+ byte[] encoded = publicKey == null ? null : publicKey.getEncoded();
+ if (encoded == null) {
+ return null;
+ }
+ try {
+ return
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(encoded));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is not available", e);
+ }
+ }
+
enum KeyFormat {
PEM,
DER,
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimIdentityTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimIdentityTest.java
new file mode 100644
index 000000000000..54b16d55f1f7
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimIdentityTest.java
@@ -0,0 +1,170 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.Security;
+import java.security.Signature;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.pqc.lifecycle.FileBasedKeyLifecycleManager;
+import org.apache.camel.component.pqc.lifecycle.KeyLifecycleManager;
+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 org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The claim on a stateful key is on the key material, not on the name an
exchange gives the key: whatever CamelPQCKeyId
+ * values the exchanges carry, and also when they carry none, one producer
signing with one key holds one lock, named
+ * from the fingerprint of the public key.
+ */
+public class PQCStatefulKeyClaimIdentityTest extends CamelTestSupport {
+
+ @TempDir
+ Path tempDir;
+
+ private static final String KEY_ID = "my-key";
+ private static final int BURNED_ELSEWHERE = 2;
+
+ private Path keyDir;
+ private KeyPair keyPair;
+ private FileBasedKeyLifecycleManager keyManager;
+ private long remainingAtStartup;
+ private long remainingInStore;
+
+ @Produce("direct:sign")
+ protected ProducerTemplate templateSign;
+
+ static {
+ 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() throws Exception {
+ keyDir = tempDir.resolve("keys");
+
+ // the previous owner of the key: it generated the key, signed
with it and stored it back ahead
+ FileBasedKeyLifecycleManager leaving = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ KeyPair original = leaving.generateKeyPair("LMS", KEY_ID);
+
+ // this producer loaded the key at startup, as documented,
through its own manager, which caches it
+ keyManager = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ keyPair = keyManager.getKey(KEY_ID);
+ remainingAtStartup =
PQCProducer.getStatefulKeyRemaining(keyPair.getPrivate());
+
+ leaving.claimStatefulKey(KEY_ID, original.getPublic());
+ burn(original, BURNED_ELSEWHERE);
+ leaving.storeKey(KEY_ID, original,
leaving.getKeyMetadata(KEY_ID));
+ leaving.releaseStatefulKey(KEY_ID, original.getPublic());
+ remainingInStore =
PQCProducer.getStatefulKeyRemaining(original.getPrivate());
+
+ context.getRegistry().bind("keyManager", keyManager);
+ context.getRegistry().bind("startupKeyPair", keyPair);
+ from("direct:sign")
+
.to("pqc:sign?operation=sign&signatureAlgorithm=LMS&keyLifecycleManager=#keyManager"
+ + "&keyPair=#startupKeyPair");
+ }
+ };
+ }
+
+ @Test
+ void theClaimFollowsTheKeyWhateverNameTheExchangesGiveIt() throws
Exception {
+ sign("name-a");
+ sign("name-b");
+
+ // one key, one lock: a name per exchange would have taken one lock
per name, and none of them on the key
+
assertEquals(List.of(KeyLifecycleManager.fingerprint(keyPair.getPublic()) +
".lock"), lockFiles());
+ }
+
+ @Test
+ void anExchangeWithoutTheHeaderIsClaimedToo() throws Exception {
+ sign(null);
+
+
assertEquals(List.of(KeyLifecycleManager.fingerprint(keyPair.getPublic()) +
".lock"), lockFiles(),
+ "a key the exchange gives no name to is still claimed, by its
fingerprint");
+ }
+
+ @Test
+ void anUnnamedFirstExchangeDoesNotHideTheStoredIndexFromALaterNamedOne()
throws Exception {
+ assertEquals(remainingAtStartup - BURNED_ELSEWHERE, remainingInStore,
+ "the stored key must be ahead of the one loaded at startup for
this test to mean anything");
+
+ // The first exchange after the handover names no key: it takes the
claim, which must drop the cached copy.
+ // Nothing names the stored key to read back, so this signature still
comes from the key loaded at startup.
+ sign(null);
+ // The next one names the key, re-reads it, and must find the stored
index, not the one loaded at startup.
+ // Without the eviction the re-read would serve the cached startup
copy, adopt nothing, and the store would
+ // end up holding that copy at remainingInStore, as if the previous
owner had never advanced the key.
+ sign(KEY_ID);
+
+ long remainingAfter =
PQCProducer.getStatefulKeyRemaining(keyManager.getKey(KEY_ID).getPrivate());
+ assertEquals(remainingInStore - 1, remainingAfter,
+ "the named signature must come from the stored index, one past
where the previous owner left it");
+ }
+
+ private void sign(String keyId) {
+ Exchange out = templateSign.send("direct:sign", e -> {
+ e.getMessage().setBody("Hello");
+ if (keyId != null) {
+ e.getMessage().setHeader(PQCConstants.KEY_ID, keyId);
+ }
+ });
+ assertNull(out.getException(), "signing must succeed");
+ assertTrue(out.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class).length > 0);
+ }
+
+ /**
+ * Consumes signatures from a stateful key, standing in for another
instance having used it.
+ */
+ private static void burn(KeyPair keyPair, int count) throws Exception {
+ Signature signer = Signature.getInstance("LMS", "BC");
+ for (int i = 0; i < count; i++) {
+ signer.initSign(keyPair.getPrivate());
+ signer.update("burn".getBytes(StandardCharsets.UTF_8));
+ signer.sign();
+ }
+ }
+
+ private List<String> lockFiles() throws IOException {
+ try (Stream<Path> files = Files.list(keyDir)) {
+ return files.map(f -> f.getFileName().toString()).filter(n ->
n.endsWith(".lock")).sorted().toList();
+ }
+ }
+}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimTest.java
index d289fceb97e0..cb61df3bfc9a 100644
---
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimTest.java
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.component.pqc;
+import java.security.PublicKey;
import java.security.Security;
import org.apache.camel.CamelExecutionException;
@@ -121,6 +122,21 @@ public class PQCStatefulKeyClaimTest extends
CamelTestSupport {
assertTrue(out.getMessage().getHeader(PQCConstants.SIGNATURE,
byte[].class).length > 0);
}
+ @Test
+ void anUnnamedStatefulKeyIsClaimedToo() throws Exception {
+ keyManager.generateKeyPair("LMS", KEY_ID);
+
+ // the claim is on the key material, so an exchange that names no key
through CamelPQCKeyId is claimed as well
+ CamelExecutionException e = assertThrows(CamelExecutionException.class,
+ () -> templateSign.sendBody("direct:sign", "Hello"));
+
+ String message = e.getCause().getMessage();
+ assertTrue(message.contains("allowSharedStatefulKey"),
+ "the message must say how to proceed deliberately: " +
message);
+ assertTrue(message.contains(PQCConstants.KEY_ID),
+ "the message must say that the exchange named no key: " +
message);
+ }
+
@Test
void aStatelessKeyIsNotClaimedAtAll() throws Exception {
keyManager.generateKeyPair("MLDSA", "mldsa-key");
@@ -141,7 +157,7 @@ public class PQCStatefulKeyClaimTest extends
CamelTestSupport {
private static final class UnclaimableKeyManager extends
InMemoryKeyLifecycleManager {
@Override
- public boolean claimStatefulKey(String keyId) {
+ public boolean claimStatefulKey(String keyId, PublicKey publicKey) {
return false;
}
}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManagerStatefulClaimTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManagerStatefulClaimTest.java
index 797bdf2048b1..30e5a5505764 100644
---
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManagerStatefulClaimTest.java
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManagerStatefulClaimTest.java
@@ -20,6 +20,8 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
import java.security.Security;
import java.util.ArrayList;
import java.util.List;
@@ -32,23 +34,29 @@ import java.util.concurrent.TimeUnit;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* XMSS, XMSSMT, LMS and HSS are one-time-signature schemes, so a key
directory that two processes read from must not
- * hand the same key to both of them.
+ * hand the same key to both of them. The claim is on the key material: the
lock file is named from the fingerprint of
+ * the public key, not from the name the key is stored under.
*/
public class FileBasedKeyLifecycleManagerStatefulClaimTest {
private static final String KEY_ID = "lms-key";
+ private static PublicKey key;
+ private static PublicKey otherKey;
+
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
@@ -58,25 +66,38 @@ public class FileBasedKeyLifecycleManagerStatefulClaimTest {
}
}
+ @BeforeAll
+ static void generateKeys() throws Exception {
+ // the claim only fingerprints the public key, so any key with an
encoding will do, and these are quick
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519");
+ key = generator.generateKeyPair().getPublic();
+ otherKey = generator.generateKeyPair().getPublic();
+ }
+
+ private static Path lockFile(Path keyDir, PublicKey publicKey) {
+ return keyDir.resolve(KeyLifecycleManager.fingerprint(publicKey) +
".lock");
+ }
+
@Test
void aClaimIsGrantedAndTakesALockFile(@TempDir Path tempDir) throws
Exception {
Path keyDir = tempDir.resolve("keys");
FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(keyDir.toString());
- assertTrue(manager.claimStatefulKey(KEY_ID), "the claim must be
granted");
- assertTrue(Files.exists(keyDir.resolve(KEY_ID + ".lock")), "the claim
must be visible to other processes");
+ assertTrue(manager.claimStatefulKey(KEY_ID, key), "the claim must be
granted");
+ assertTrue(Files.exists(lockFile(keyDir, key)), "the claim must be
visible to other processes");
+ assertFalse(Files.exists(keyDir.resolve(KEY_ID + ".lock")), "the lock
is on the key, not on its name");
// claiming what this manager already holds is not contention
- assertTrue(manager.claimStatefulKey(KEY_ID), "re-claiming the same key
must succeed");
+ assertTrue(manager.claimStatefulKey(KEY_ID, key), "re-claiming the
same key must succeed");
// each claim is a holder: one release keeps the key, the second gives
it back
- manager.releaseStatefulKey(KEY_ID);
+ manager.releaseStatefulKey(KEY_ID, key);
FileBasedKeyLifecycleManager other = new
FileBasedKeyLifecycleManager(keyDir.toString());
- assertThrows(IllegalStateException.class, () ->
other.claimStatefulKey(KEY_ID),
+ assertThrows(IllegalStateException.class, () ->
other.claimStatefulKey(KEY_ID, key),
"the key must stay claimed while a holder remains");
- manager.releaseStatefulKey(KEY_ID);
- assertDoesNotThrow(() -> other.claimStatefulKey(KEY_ID), "the last
release must give the key back");
- other.releaseStatefulKey(KEY_ID);
+ manager.releaseStatefulKey(KEY_ID, key);
+ assertDoesNotThrow(() -> other.claimStatefulKey(KEY_ID, key), "the
last release must give the key back");
+ other.releaseStatefulKey(KEY_ID, key);
}
@Test
@@ -85,19 +106,19 @@ public class FileBasedKeyLifecycleManagerStatefulClaimTest
{
FileBasedKeyLifecycleManager first = new
FileBasedKeyLifecycleManager(keyDir.toString());
FileBasedKeyLifecycleManager second = new
FileBasedKeyLifecycleManager(keyDir.toString());
- assertTrue(first.claimStatefulKey(KEY_ID));
+ assertTrue(first.claimStatefulKey(KEY_ID, key));
- IllegalStateException e = assertThrows(IllegalStateException.class, ()
-> second.claimStatefulKey(KEY_ID));
+ IllegalStateException e = assertThrows(IllegalStateException.class, ()
-> second.claimStatefulKey(KEY_ID, key));
assertTrue(e.getMessage().contains(KEY_ID), "the message must name the
key: " + e.getMessage());
// The refusal comes from the registry of lock files this JVM holds,
before a second channel is opened on
// the file, so there is no channel left behind for the garbage
collector to close - which would drop the
// first holder's lock. The claim is still registered after the
refusal.
FileBasedKeyLifecycleManager third = new
FileBasedKeyLifecycleManager(keyDir.toString());
- assertThrows(IllegalStateException.class, () ->
third.claimStatefulKey(KEY_ID),
+ assertThrows(IllegalStateException.class, () ->
third.claimStatefulKey(KEY_ID, key),
"the claim must still be registered after a second holder was
refused");
- first.releaseStatefulKey(KEY_ID);
+ first.releaseStatefulKey(KEY_ID, key);
}
@Test
@@ -112,42 +133,78 @@ public class
FileBasedKeyLifecycleManagerStatefulClaimTest {
}
FileBasedKeyLifecycleManager second = new
FileBasedKeyLifecycleManager(link.toString());
- assertTrue(first.claimStatefulKey(KEY_ID));
+ assertTrue(first.claimStatefulKey(KEY_ID, key));
// the registry holds the real path of the lock file, so the link does
not pass for another file
- assertThrows(IllegalStateException.class, () ->
second.claimStatefulKey(KEY_ID),
+ assertThrows(IllegalStateException.class, () ->
second.claimStatefulKey(KEY_ID, key),
"the same directory under another path must be refused");
- first.releaseStatefulKey(KEY_ID);
+ first.releaseStatefulKey(KEY_ID, key);
}
@Test
- void aNewClaimReadsTheKeyAndItsMetadataFromTheStore(@TempDir Path tempDir)
throws Exception {
+ void theClaimFollowsTheKeyNotItsName(@TempDir Path tempDir) throws
Exception {
Path keyDir = tempDir.resolve("keys");
- FileBasedKeyLifecycleManager leaving = new
FileBasedKeyLifecycleManager(keyDir.toString());
- FileBasedKeyLifecycleManager arriving = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ FileBasedKeyLifecycleManager first = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ FileBasedKeyLifecycleManager second = new
FileBasedKeyLifecycleManager(keyDir.toString());
- KeyPair atStartup = leaving.generateKeyPair("LMS", KEY_ID);
- assertTrue(leaving.claimStatefulKey(KEY_ID));
- // the arriving instance loads the key and its metadata, and caches
both, while it waits for the claim
- assertArrayEquals(atStartup.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded());
- assertEquals(0, arriving.getKeyMetadata(KEY_ID).getUsageCount());
+ assertTrue(first.claimStatefulKey("one-name", key));
- // the leaving instance moves the key on while it holds the claim
- KeyPair advanced = leaving.generateKeyPair("LMS", KEY_ID);
- KeyMetadata used = leaving.getKeyMetadata(KEY_ID);
- used.setUsageCount(42);
- leaving.updateKeyMetadata(KEY_ID, used);
- assertArrayEquals(atStartup.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded(),
- "before the claim the arriving instance still serves what it
loaded");
- leaving.releaseStatefulKey(KEY_ID);
+ // the same key under another name is the same key: one of them would
hand out the other's indices
+ assertThrows(IllegalStateException.class, () ->
second.claimStatefulKey("another-name", key),
+ "the same key material under another name must be refused");
+ assertTrue(Files.exists(lockFile(keyDir, key)));
+ assertFalse(Files.exists(keyDir.resolve("one-name.lock")));
+ assertFalse(Files.exists(keyDir.resolve("another-name.lock")));
- // the claim is what makes the arriving instance read the store again
- assertTrue(arriving.claimStatefulKey(KEY_ID));
- assertArrayEquals(advanced.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded(),
- "a new claim must read the key from the store, not from the
cache");
- assertEquals(42, arriving.getKeyMetadata(KEY_ID).getUsageCount(),
- "a new claim must read the metadata from the store, not from
the cache");
- arriving.releaseStatefulKey(KEY_ID);
+ first.releaseStatefulKey("one-name", key);
+ }
+
+ @Test
+ void differentKeysUnderOneNameDoNotContend(@TempDir Path tempDir) throws
Exception {
+ Path keyDir = tempDir.resolve("keys");
+ FileBasedKeyLifecycleManager first = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ FileBasedKeyLifecycleManager second = new
FileBasedKeyLifecycleManager(keyDir.toString());
+
+ assertTrue(first.claimStatefulKey(KEY_ID, key));
+ // another key that happens to carry the same name is another key,
with its own one-time indices
+ assertTrue(second.claimStatefulKey(KEY_ID, otherKey), "a different key
under the same name is not contention");
+ assertTrue(Files.exists(lockFile(keyDir, key)));
+ assertTrue(Files.exists(lockFile(keyDir, otherKey)));
+
+ first.releaseStatefulKey(KEY_ID, key);
+ second.releaseStatefulKey(KEY_ID, otherKey);
+ }
+
+ @Test
+ void anUnnamedKeyIsClaimed(@TempDir Path tempDir) throws Exception {
+ Path keyDir = tempDir.resolve("keys");
+ FileBasedKeyLifecycleManager first = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ FileBasedKeyLifecycleManager second = new
FileBasedKeyLifecycleManager(keyDir.toString());
+
+ // a key the exchange gives no name to is still one key material, so
it has something to lock
+ assertTrue(first.claimStatefulKey(null, key), "a key without a name
must be claimed by its fingerprint");
+ assertTrue(Files.exists(lockFile(keyDir, key)));
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, ()
-> second.claimStatefulKey(null, key));
+ assertTrue(e.getMessage().contains("fingerprint"), "the message must
identify the key: " + e.getMessage());
+
+ first.releaseStatefulKey(null, key);
+ assertDoesNotThrow(() -> second.claimStatefulKey(null, key));
+ second.releaseStatefulKey(null, key);
+ }
+
+ @Test
+ void aKeyWithoutAnEncodingIsClaimedByItsName(@TempDir Path tempDir) throws
Exception {
+ Path keyDir = tempDir.resolve("keys");
+ FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ PublicKey opaque = new OpaquePublicKey();
+
+ assertTrue(manager.claimStatefulKey("named", opaque), "a key with no
encoding falls back to its name");
+ assertTrue(Files.exists(keyDir.resolve("named.lock")));
+ manager.releaseStatefulKey("named", opaque);
+
+ // with neither an encoding nor a name there is nothing to claim
+ assertThrows(IllegalArgumentException.class, () ->
manager.claimStatefulKey(null, opaque));
}
@Test
@@ -163,7 +220,7 @@ public class FileBasedKeyLifecycleManagerStatefulClaimTest {
for (int i = 0; i < threads; i++) {
results.add(pool.submit(() -> {
start.await();
- return manager.claimStatefulKey(KEY_ID);
+ return manager.claimStatefulKey(KEY_ID, key);
}));
}
start.countDown();
@@ -181,15 +238,15 @@ public class
FileBasedKeyLifecycleManagerStatefulClaimTest {
// each thread claimed once, so the key is held eight times, and
another manager is still refused
FileBasedKeyLifecycleManager other = new
FileBasedKeyLifecycleManager(keyDir.toString());
- assertThrows(IllegalStateException.class, () ->
other.claimStatefulKey(KEY_ID));
+ assertThrows(IllegalStateException.class, () ->
other.claimStatefulKey(KEY_ID, key));
// every holder releases the key, and only then is it given back
for (int i = 0; i < threads; i++) {
- manager.releaseStatefulKey(KEY_ID);
+ manager.releaseStatefulKey(KEY_ID, key);
}
- assertDoesNotThrow(() -> other.claimStatefulKey(KEY_ID),
+ assertDoesNotThrow(() -> other.claimStatefulKey(KEY_ID, key),
"once every holder has released the key, another manager must
be able to claim it");
- other.releaseStatefulKey(KEY_ID);
+ other.releaseStatefulKey(KEY_ID, key);
}
@Test
@@ -198,26 +255,111 @@ public class
FileBasedKeyLifecycleManagerStatefulClaimTest {
FileBasedKeyLifecycleManager first = new
FileBasedKeyLifecycleManager(keyDir.toString());
FileBasedKeyLifecycleManager second = new
FileBasedKeyLifecycleManager(keyDir.toString());
- assertTrue(first.claimStatefulKey(KEY_ID));
- first.releaseStatefulKey(KEY_ID);
+ assertTrue(first.claimStatefulKey(KEY_ID, key));
+ first.releaseStatefulKey(KEY_ID, key);
- assertDoesNotThrow(() -> second.claimStatefulKey(KEY_ID),
+ assertDoesNotThrow(() -> second.claimStatefulKey(KEY_ID, key),
"a key given back must be available to the next owner");
- second.releaseStatefulKey(KEY_ID);
+ second.releaseStatefulKey(KEY_ID, key);
}
@Test
void releasingAKeyThatWasNeverClaimedDoesNothing(@TempDir Path tempDir)
throws Exception {
FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(tempDir.resolve("keys").toString());
- assertDoesNotThrow(() -> manager.releaseStatefulKey(KEY_ID));
+ assertDoesNotThrow(() -> manager.releaseStatefulKey(KEY_ID, key));
}
@Test
- void aKeyIdThatEscapesTheDirectoryIsRejected(@TempDir Path tempDir) throws
Exception {
- FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(tempDir.resolve("keys").toString());
+ void aNameThatEscapesTheDirectoryIsRejectedWhenItNamesTheLockFile(@TempDir
Path tempDir) throws Exception {
+ Path keyDir = tempDir.resolve("keys");
+ FileBasedKeyLifecycleManager manager = new
FileBasedKeyLifecycleManager(keyDir.toString());
- // the claim resolves a file name from a header-supplied keyId, as
every other file this manager writes does
- assertThrows(IllegalArgumentException.class, () ->
manager.claimStatefulKey("../escaped"));
+ // the name names the lock file only for a key without an encoding,
and is then confined like every other
+ // file this manager writes from a header-supplied keyId
+ assertThrows(IllegalArgumentException.class, () ->
manager.claimStatefulKey("../escaped", new OpaquePublicKey()));
+
+ // for a key with an encoding the lock file is named from the
fingerprint, so the name cannot escape
+ assertTrue(manager.claimStatefulKey("../escaped", key));
+ assertTrue(Files.exists(lockFile(keyDir, key)));
+ assertFalse(Files.exists(tempDir.resolve("escaped.lock")));
+ manager.releaseStatefulKey("../escaped", key);
+ }
+
+ @Test
+ void aNewClaimReadsTheKeyAndItsMetadataFromTheStore(@TempDir Path tempDir)
throws Exception {
+ Path keyDir = tempDir.resolve("keys");
+ FileBasedKeyLifecycleManager leaving = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ FileBasedKeyLifecycleManager arriving = new
FileBasedKeyLifecycleManager(keyDir.toString());
+
+ KeyPair atStartup = leaving.generateKeyPair("LMS", KEY_ID);
+ assertTrue(leaving.claimStatefulKey(KEY_ID, atStartup.getPublic()));
+ // the arriving instance loads the key and its metadata, and caches
both, while it waits for the claim
+ assertArrayEquals(atStartup.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded());
+ assertEquals(0, arriving.getKeyMetadata(KEY_ID).getUsageCount());
+
+ // the leaving instance moves the key on while it holds the claim
+ KeyPair advanced = leaving.generateKeyPair("LMS", KEY_ID);
+ KeyMetadata used = leaving.getKeyMetadata(KEY_ID);
+ used.setUsageCount(42);
+ leaving.updateKeyMetadata(KEY_ID, used);
+ assertArrayEquals(atStartup.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded(),
+ "before the claim the arriving instance still serves what it
loaded");
+ leaving.releaseStatefulKey(KEY_ID, atStartup.getPublic());
+
+ // the claim is what makes the arriving instance read the store again
+ assertTrue(arriving.claimStatefulKey(KEY_ID, atStartup.getPublic()));
+ assertArrayEquals(advanced.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded(),
+ "a new claim must read the key from the store, not from the
cache");
+ assertEquals(42, arriving.getKeyMetadata(KEY_ID).getUsageCount(),
+ "a new claim must read the metadata from the store, not from
the cache");
+ arriving.releaseStatefulKey(KEY_ID, atStartup.getPublic());
+ }
+
+ @Test
+ void anUnnamedClaimReadsTheStoreAgainToo(@TempDir Path tempDir) throws
Exception {
+ Path keyDir = tempDir.resolve("keys");
+ FileBasedKeyLifecycleManager leaving = new
FileBasedKeyLifecycleManager(keyDir.toString());
+ FileBasedKeyLifecycleManager arriving = new
FileBasedKeyLifecycleManager(keyDir.toString());
+
+ KeyPair atStartup = leaving.generateKeyPair("LMS", KEY_ID);
+ assertTrue(leaving.claimStatefulKey(KEY_ID, atStartup.getPublic()));
+ assertArrayEquals(atStartup.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded());
+ assertEquals(0, arriving.getKeyMetadata(KEY_ID).getUsageCount());
+
+ KeyPair advanced = leaving.generateKeyPair("LMS", KEY_ID);
+ KeyMetadata used = leaving.getKeyMetadata(KEY_ID);
+ used.setUsageCount(42);
+ leaving.updateKeyMetadata(KEY_ID, used);
+ leaving.releaseStatefulKey(KEY_ID, atStartup.getPublic());
+
+ // the claim names no key, but a later read under the name must still
come from the store, not the cache
+ assertTrue(arriving.claimStatefulKey(null, atStartup.getPublic()));
+ assertArrayEquals(advanced.getPublic().getEncoded(),
arriving.getKey(KEY_ID).getPublic().getEncoded(),
+ "a claim without a name must drop the cached key as well");
+ assertEquals(42, arriving.getKeyMetadata(KEY_ID).getUsageCount(),
+ "a claim without a name must drop the cached metadata as
well");
+ arriving.releaseStatefulKey(null, atStartup.getPublic());
+ }
+
+ /**
+ * A public key that gives no encoding, as a hardware-backed or otherwise
opaque key may.
+ */
+ private static final class OpaquePublicKey implements PublicKey {
+
+ @Override
+ public String getAlgorithm() {
+ return "opaque";
+ }
+
+ @Override
+ public String getFormat() {
+ return null;
+ }
+
+ @Override
+ public byte[] getEncoded() {
+ return null;
+ }
}
}
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 d05f26c176a5..58292cacc40c 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
@@ -2055,12 +2055,14 @@ lives inside the private key, so two processes that
load the same key from a sha
two replicas reading one vault secret - each advance their own copy and hand
out the same index.
Before it first signs with a stateful key, the `pqc` producer now claims it
from the configured
-`KeyLifecycleManager`, through the new
`KeyLifecycleManager.claimStatefulKey(String)` and
-`releaseStatefulKey(String)` methods. Both have a default implementation, so a
custom manager still compiles; the
-default grants no claim.
-
-* `FileBasedKeyLifecycleManager` takes an exclusive `FileLock` on a
`<keyId>.lock` file in the key directory, so a
- second process reading that directory is refused.
+`KeyLifecycleManager`, through the new
`KeyLifecycleManager.claimStatefulKey(String, PublicKey)` and
+`releaseStatefulKey(String, PublicKey)` methods. Both have a default
implementation, so a custom manager still
+compiles; the default grants no claim. The claim is on the key material,
identified by the public key (see the new
+`KeyLifecycleManager.fingerprint(PublicKey)`), not on the name the
`CamelPQCKeyId` header gives the key: the same key
+under two names, or under none, is one claim, so an exchange that carries no
header is claimed too.
+
+* `FileBasedKeyLifecycleManager` takes an exclusive `FileLock` on a lock file
in the key directory named from the
+ SHA-256 fingerprint of the public key, so a second process reading that
directory is refused.
* `InMemoryKeyLifecycleManager` grants the claim - its keys never leave the
process.
* The vault-backed managers (AWS Secrets Manager, Azure Key Vault, Hashicorp
Vault) cannot offer a lease Camel can
take, so they do not grant it. **An endpoint signing with a stateful key
through one of them now fails**, with a