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

Reply via email to