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 f321752362b7 CAMEL-26547: camel-pqc - a stateful signature key is 
claimed for one process (#29098)
f321752362b7 is described below

commit f321752362b75f9dca935588e41a0bc46971031c
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Nov 5 13:00:34 2026 +0100

    CAMEL-26547: camel-pqc - a stateful signature key is claimed for one 
process (#29098)
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Co-Authored-By: Claude Opus 5 <[email protected]>
---
 .../org/apache/camel/catalog/components/pqc.json   |   6 +-
 .../camel/catalog/docs/pqc-key-lifecycle.adoc      |  45 +++++
 .../component/pqc/PQCComponentConfigurer.java      |   6 +
 .../camel/component/pqc/PQCEndpointConfigurer.java |   6 +
 .../camel/component/pqc/PQCEndpointUriFactory.java |   3 +-
 .../org/apache/camel/component/pqc/pqc.json        |   6 +-
 .../camel-pqc/src/main/docs/pqc-key-lifecycle.adoc |  45 +++++
 .../camel/component/pqc/PQCConfiguration.java      |  23 +++
 .../apache/camel/component/pqc/PQCProducer.java    | 173 +++++++++++++++-
 .../lifecycle/FileBasedKeyLifecycleManager.java    | 186 +++++++++++++++++
 .../pqc/lifecycle/InMemoryKeyLifecycleManager.java |   9 +
 .../pqc/lifecycle/KeyLifecycleManager.java         |  33 +++
 .../component/pqc/PQCStatefulKeyAdoptionTest.java  | 153 ++++++++++++++
 .../component/pqc/PQCStatefulKeyClaimTest.java     | 148 ++++++++++++++
 ...eBasedKeyLifecycleManagerStatefulClaimTest.java | 223 +++++++++++++++++++++
 .../java/org/apache/camel/util/SecurityUtils.java  |   3 +
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  25 +++
 .../component/dsl/PqcComponentBuilderFactory.java  |  23 +++
 .../endpoint/dsl/PQCEndpointBuilderFactory.java    |  40 ++++
 19 files changed, 1150 insertions(+), 6 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/pqc.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/pqc.json
index b5f7f9a728e8..a9968dd70e60 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/pqc.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/pqc.json
@@ -54,7 +54,8 @@
     "symmetricKeyAlgorithm": { "index": 27, "kind": "property", "displayName": 
"Symmetric Key Algorithm", "group": "advanced", "label": "advanced", 
"required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ 
"AES", "ARIA", "RC2", "RC5", "CAMELLIA", "CAST5", "CAST6", "CHACHA7539", 
"DSTU7624", "GOST28147", "GOST3412_2015", "GRAIN128", "HC128", "HC256", 
"SALSA20", "SEED", "SM4", "DESEDE" ], "deprecated": false, "deprecationNote": 
"", "autowired": false, "secret": false, "co [...]
     "symmetricKeyLength": { "index": 28, "kind": "property", "displayName": 
"Symmetric Key Length", "group": "advanced", "label": "advanced", "required": 
false, "type": "integer", "javaType": "int", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
256, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "The required length of 
the symmetric key used. The default of 2 [...]
     "healthCheckConsumerEnabled": { "index": 29, "kind": "property", 
"displayName": "Health Check Consumer Enabled", "group": "health", "label": 
"health", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Used for enabling or disabling all consumer based health checks 
from this component" },
-    "healthCheckProducerEnabled": { "index": 30, "kind": "property", 
"displayName": "Health Check Producer Enabled", "group": "health", "label": 
"health", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Used for enabling or disabling all producer based health checks 
from this component. Notice: Camel has by default disabled all producer based 
health-checks. You can turn on produce [...]
+    "healthCheckProducerEnabled": { "index": 30, "kind": "property", 
"displayName": "Health Check Producer Enabled", "group": "health", "label": 
"health", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Used for enabling or disabling all producer based health checks 
from this component. Notice: Camel has by default disabled all producer based 
health-checks. You can turn on produce [...]
+    "allowSharedStatefulKey": { "index": 31, "kind": "property", 
"displayName": "Allow Shared Stateful Key", "group": "security (advanced)", 
"label": "security,advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "deprecationNote": "", "autowired": false, 
"secret": false, "security": "insecure:dev", "defaultValue": false, 
"configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": " [...]
   },
   "headers": {
     "CamelPQCOperation": { "index": 0, "kind": "header", "displayName": "", 
"group": "producer", "label": "", "required": false, "javaType": 
"org.apache.camel.component.pqc.PQCOperations", "enum": [ "sign", "verify", 
"hybridSign", "hybridVerify", "generateSecretKeyEncapsulation", 
"extractSecretKeyEncapsulation", "extractSecretKeyFromEncapsulation", 
"hybridGenerateSecretKeyEncapsulation", "hybridExtractSecretKeyEncapsulation", 
"hybridExtractSecretKeyFromEncapsulation", "getRemainingSignat [...]
@@ -109,6 +110,7 @@
     "storeExtractedSecretKeyAsHeader": { "index": 20, "kind": "parameter", 
"displayName": "Store Extracted Secret Key As Header", "group": "advanced", 
"label": "advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "deprecationNote": "", "autowired": false, 
"secret": false, "defaultValue": false, "configurationClass": 
"org.apache.camel.component.pqc.PQCConfiguration", "configurationField": 
"configuration", "description": "In the context of extractSe [...]
     "strictKeyLifecycle": { "index": 21, "kind": "parameter", "displayName": 
"Strict Key Lifecycle", "group": "advanced", "label": "advanced", "required": 
false, "type": "boolean", "javaType": "boolean", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
true, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "Whether to enforce key 
status checks before cryptographic [...]
     "symmetricKeyAlgorithm": { "index": 22, "kind": "parameter", 
"displayName": "Symmetric Key Algorithm", "group": "advanced", "label": 
"advanced", "required": false, "type": "enum", "javaType": "java.lang.String", 
"enum": [ "AES", "ARIA", "RC2", "RC5", "CAMELLIA", "CAST5", "CAST6", 
"CHACHA7539", "DSTU7624", "GOST28147", "GOST3412_2015", "GRAIN128", "HC128", 
"HC256", "SALSA20", "SEED", "SM4", "DESEDE" ], "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "c [...]
-    "symmetricKeyLength": { "index": 23, "kind": "parameter", "displayName": 
"Symmetric Key Length", "group": "advanced", "label": "advanced", "required": 
false, "type": "integer", "javaType": "int", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
256, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "The required length of 
the symmetric key used. The default of  [...]
+    "symmetricKeyLength": { "index": 23, "kind": "parameter", "displayName": 
"Symmetric Key Length", "group": "advanced", "label": "advanced", "required": 
false, "type": "integer", "javaType": "int", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
256, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "The required length of 
the symmetric key used. The default of  [...]
+    "allowSharedStatefulKey": { "index": 24, "kind": "parameter", 
"displayName": "Allow Shared Stateful Key", "group": "security (advanced)", 
"label": "security,advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "deprecationNote": "", "autowired": false, 
"secret": false, "security": "insecure:dev", "defaultValue": false, 
"configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description":  [...]
   }
 }
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 198f29c7e57d..156bf5be4573 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
@@ -1282,6 +1282,48 @@ For XMSS, XMSSMT, LMS, and HSS algorithms:
 * Never reuse a stateful signature state (critical security requirement)
 * Monitor usage percentage and rotate before exhaustion
 * Set appropriate `maxSignatures` based on tree height
+* Give the key a single owner - see <<_single_ownership_of_a_stateful_key>>
+
+=== Single Ownership of a Stateful Key
+
+XMSS, XMSSMT, LMS and HSS are one-time-signature schemes: every signature 
consumes an index, and signing twice from
+the same index breaks the key. The index is held inside the private key, so 
two processes that load the same key -
+two pods reading one volume, two replicas reading one vault secret - each 
advance their own copy and hand out the
+same index.
+
+The producer therefore *claims* a stateful key from the `KeyLifecycleManager` 
before it first signs with it, and
+holds the claim until the route stops. What the claim is worth depends on the 
store:
+
+[cols="2,3"]
+|===
+| 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.
+
+| `InMemoryKeyLifecycleManager`
+| Granted: the keys never leave the process.
+
+| `AwsSecretsManagerKeyLifecycleManager`, `AzureKeyVaultKeyLifecycleManager`, 
`HashicorpVaultKeyLifecycleManager`
+| Not granted. These stores hand the same secret to every reader and offer no 
lease Camel can take, so signing with a
+  stateful key through them is refused.
+|===
+
+A refused claim fails the exchange with a message naming the key. If a single 
signer is guaranteed by something
+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
+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.
 
 **5. Secure Key Export**
 
@@ -1317,6 +1359,9 @@ Both implementations are thread-safe:
 * **FileBasedKeyLifecycleManager**: Uses ConcurrentHashMap for caching and 
atomic file operations
 * **InMemoryKeyLifecycleManager**: Uses ConcurrentHashMap for all storage
 
+This covers concurrency *within one process*. It says nothing about two 
processes sharing a store, which matters for
+stateful signature keys - see <<_single_ownership_of_a_stateful_key>>.
+
 Concurrent key operations are safe:
 
 ._Java-only: concurrent key generation with thread pool_
diff --git 
a/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCComponentConfigurer.java
 
b/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCComponentConfigurer.java
index 83e1443ae9d5..7e6a0d571b79 100644
--- 
a/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCComponentConfigurer.java
+++ 
b/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCComponentConfigurer.java
@@ -31,6 +31,8 @@ public class PQCComponentConfigurer extends 
PropertyConfigurerSupport implements
     public boolean configure(CamelContext camelContext, Object obj, String 
name, Object value, boolean ignoreCase) {
         PQCComponent target = (PQCComponent) obj;
         switch (ignoreCase ? name.toLowerCase(Locale.ROOT) : name) {
+        case "allowsharedstatefulkey":
+        case "allowSharedStatefulKey": 
getOrCreateConfiguration(target).setAllowSharedStatefulKey(property(camelContext,
 boolean.class, value)); return true;
         case "autowiredenabled":
         case "autowiredEnabled": 
target.setAutowiredEnabled(property(camelContext, boolean.class, value)); 
return true;
         case "classicalkemalgorithm":
@@ -102,6 +104,8 @@ public class PQCComponentConfigurer extends 
PropertyConfigurerSupport implements
     @Override
     public Class<?> getOptionType(String name, boolean ignoreCase) {
         switch (ignoreCase ? name.toLowerCase(Locale.ROOT) : name) {
+        case "allowsharedstatefulkey":
+        case "allowSharedStatefulKey": return boolean.class;
         case "autowiredenabled":
         case "autowiredEnabled": return boolean.class;
         case "classicalkemalgorithm":
@@ -169,6 +173,8 @@ public class PQCComponentConfigurer extends 
PropertyConfigurerSupport implements
     public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
         PQCComponent target = (PQCComponent) obj;
         switch (ignoreCase ? name.toLowerCase(Locale.ROOT) : name) {
+        case "allowsharedstatefulkey":
+        case "allowSharedStatefulKey": return 
getOrCreateConfiguration(target).isAllowSharedStatefulKey();
         case "autowiredenabled":
         case "autowiredEnabled": return target.isAutowiredEnabled();
         case "classicalkemalgorithm":
diff --git 
a/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointConfigurer.java
 
b/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointConfigurer.java
index d3baf5637968..b510da0ec414 100644
--- 
a/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointConfigurer.java
+++ 
b/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointConfigurer.java
@@ -24,6 +24,8 @@ public class PQCEndpointConfigurer extends 
PropertyConfigurerSupport implements
     public boolean configure(CamelContext camelContext, Object obj, String 
name, Object value, boolean ignoreCase) {
         PQCEndpoint target = (PQCEndpoint) obj;
         switch (ignoreCase ? name.toLowerCase(Locale.ROOT) : name) {
+        case "allowsharedstatefulkey":
+        case "allowSharedStatefulKey": 
target.getConfiguration().setAllowSharedStatefulKey(property(camelContext, 
boolean.class, value)); return true;
         case "classicalkemalgorithm":
         case "classicalKEMAlgorithm": 
target.getConfiguration().setClassicalKEMAlgorithm(property(camelContext, 
java.lang.String.class, value)); return true;
         case "classicalkeyagreement":
@@ -80,6 +82,8 @@ public class PQCEndpointConfigurer extends 
PropertyConfigurerSupport implements
     @Override
     public Class<?> getOptionType(String name, boolean ignoreCase) {
         switch (ignoreCase ? name.toLowerCase(Locale.ROOT) : name) {
+        case "allowsharedstatefulkey":
+        case "allowSharedStatefulKey": return boolean.class;
         case "classicalkemalgorithm":
         case "classicalKEMAlgorithm": return java.lang.String.class;
         case "classicalkeyagreement":
@@ -132,6 +136,8 @@ public class PQCEndpointConfigurer extends 
PropertyConfigurerSupport implements
     public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
         PQCEndpoint target = (PQCEndpoint) obj;
         switch (ignoreCase ? name.toLowerCase(Locale.ROOT) : name) {
+        case "allowsharedstatefulkey":
+        case "allowSharedStatefulKey": return 
target.getConfiguration().isAllowSharedStatefulKey();
         case "classicalkemalgorithm":
         case "classicalKEMAlgorithm": return 
target.getConfiguration().getClassicalKEMAlgorithm();
         case "classicalkeyagreement":
diff --git 
a/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointUriFactory.java
 
b/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointUriFactory.java
index 0b38b6f82ec8..7e4a67faf2cf 100644
--- 
a/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointUriFactory.java
+++ 
b/components/camel-pqc/src/generated/java/org/apache/camel/component/pqc/PQCEndpointUriFactory.java
@@ -24,7 +24,8 @@ public class PQCEndpointUriFactory extends 
org.apache.camel.support.component.En
     private static final Set<String> ENDPOINT_IDENTITY_PROPERTY_NAMES;
     private static final Map<String, String> MULTI_VALUE_PREFIXES;
     static {
-        Set<String> props = new HashSet<>(24);
+        Set<String> props = new HashSet<>(25);
+        props.add("allowSharedStatefulKey");
         props.add("classicalKEMAlgorithm");
         props.add("classicalKeyAgreement");
         props.add("classicalKeyPair");
diff --git 
a/components/camel-pqc/src/generated/resources/META-INF/org/apache/camel/component/pqc/pqc.json
 
b/components/camel-pqc/src/generated/resources/META-INF/org/apache/camel/component/pqc/pqc.json
index b5f7f9a728e8..a9968dd70e60 100644
--- 
a/components/camel-pqc/src/generated/resources/META-INF/org/apache/camel/component/pqc/pqc.json
+++ 
b/components/camel-pqc/src/generated/resources/META-INF/org/apache/camel/component/pqc/pqc.json
@@ -54,7 +54,8 @@
     "symmetricKeyAlgorithm": { "index": 27, "kind": "property", "displayName": 
"Symmetric Key Algorithm", "group": "advanced", "label": "advanced", 
"required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ 
"AES", "ARIA", "RC2", "RC5", "CAMELLIA", "CAST5", "CAST6", "CHACHA7539", 
"DSTU7624", "GOST28147", "GOST3412_2015", "GRAIN128", "HC128", "HC256", 
"SALSA20", "SEED", "SM4", "DESEDE" ], "deprecated": false, "deprecationNote": 
"", "autowired": false, "secret": false, "co [...]
     "symmetricKeyLength": { "index": 28, "kind": "property", "displayName": 
"Symmetric Key Length", "group": "advanced", "label": "advanced", "required": 
false, "type": "integer", "javaType": "int", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
256, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "The required length of 
the symmetric key used. The default of 2 [...]
     "healthCheckConsumerEnabled": { "index": 29, "kind": "property", 
"displayName": "Health Check Consumer Enabled", "group": "health", "label": 
"health", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Used for enabling or disabling all consumer based health checks 
from this component" },
-    "healthCheckProducerEnabled": { "index": 30, "kind": "property", 
"displayName": "Health Check Producer Enabled", "group": "health", "label": 
"health", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Used for enabling or disabling all producer based health checks 
from this component. Notice: Camel has by default disabled all producer based 
health-checks. You can turn on produce [...]
+    "healthCheckProducerEnabled": { "index": 30, "kind": "property", 
"displayName": "Health Check Producer Enabled", "group": "health", "label": 
"health", "required": false, "type": "boolean", "javaType": "boolean", 
"deprecated": false, "autowired": false, "secret": false, "defaultValue": true, 
"description": "Used for enabling or disabling all producer based health checks 
from this component. Notice: Camel has by default disabled all producer based 
health-checks. You can turn on produce [...]
+    "allowSharedStatefulKey": { "index": 31, "kind": "property", 
"displayName": "Allow Shared Stateful Key", "group": "security (advanced)", 
"label": "security,advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "deprecationNote": "", "autowired": false, 
"secret": false, "security": "insecure:dev", "defaultValue": false, 
"configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": " [...]
   },
   "headers": {
     "CamelPQCOperation": { "index": 0, "kind": "header", "displayName": "", 
"group": "producer", "label": "", "required": false, "javaType": 
"org.apache.camel.component.pqc.PQCOperations", "enum": [ "sign", "verify", 
"hybridSign", "hybridVerify", "generateSecretKeyEncapsulation", 
"extractSecretKeyEncapsulation", "extractSecretKeyFromEncapsulation", 
"hybridGenerateSecretKeyEncapsulation", "hybridExtractSecretKeyEncapsulation", 
"hybridExtractSecretKeyFromEncapsulation", "getRemainingSignat [...]
@@ -109,6 +110,7 @@
     "storeExtractedSecretKeyAsHeader": { "index": 20, "kind": "parameter", 
"displayName": "Store Extracted Secret Key As Header", "group": "advanced", 
"label": "advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "deprecationNote": "", "autowired": false, 
"secret": false, "defaultValue": false, "configurationClass": 
"org.apache.camel.component.pqc.PQCConfiguration", "configurationField": 
"configuration", "description": "In the context of extractSe [...]
     "strictKeyLifecycle": { "index": 21, "kind": "parameter", "displayName": 
"Strict Key Lifecycle", "group": "advanced", "label": "advanced", "required": 
false, "type": "boolean", "javaType": "boolean", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
true, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "Whether to enforce key 
status checks before cryptographic [...]
     "symmetricKeyAlgorithm": { "index": 22, "kind": "parameter", 
"displayName": "Symmetric Key Algorithm", "group": "advanced", "label": 
"advanced", "required": false, "type": "enum", "javaType": "java.lang.String", 
"enum": [ "AES", "ARIA", "RC2", "RC5", "CAMELLIA", "CAST5", "CAST6", 
"CHACHA7539", "DSTU7624", "GOST28147", "GOST3412_2015", "GRAIN128", "HC128", 
"HC256", "SALSA20", "SEED", "SM4", "DESEDE" ], "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "c [...]
-    "symmetricKeyLength": { "index": 23, "kind": "parameter", "displayName": 
"Symmetric Key Length", "group": "advanced", "label": "advanced", "required": 
false, "type": "integer", "javaType": "int", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
256, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "The required length of 
the symmetric key used. The default of  [...]
+    "symmetricKeyLength": { "index": 23, "kind": "parameter", "displayName": 
"Symmetric Key Length", "group": "advanced", "label": "advanced", "required": 
false, "type": "integer", "javaType": "int", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 
256, "configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description": "The required length of 
the symmetric key used. The default of  [...]
+    "allowSharedStatefulKey": { "index": 24, "kind": "parameter", 
"displayName": "Allow Shared Stateful Key", "group": "security (advanced)", 
"label": "security,advanced", "required": false, "type": "boolean", "javaType": 
"boolean", "deprecated": false, "deprecationNote": "", "autowired": false, 
"secret": false, "security": "insecure:dev", "defaultValue": false, 
"configurationClass": "org.apache.camel.component.pqc.PQCConfiguration", 
"configurationField": "configuration", "description":  [...]
   }
 }
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 198f29c7e57d..156bf5be4573 100644
--- a/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
+++ b/components/camel-pqc/src/main/docs/pqc-key-lifecycle.adoc
@@ -1282,6 +1282,48 @@ For XMSS, XMSSMT, LMS, and HSS algorithms:
 * Never reuse a stateful signature state (critical security requirement)
 * Monitor usage percentage and rotate before exhaustion
 * Set appropriate `maxSignatures` based on tree height
+* Give the key a single owner - see <<_single_ownership_of_a_stateful_key>>
+
+=== Single Ownership of a Stateful Key
+
+XMSS, XMSSMT, LMS and HSS are one-time-signature schemes: every signature 
consumes an index, and signing twice from
+the same index breaks the key. The index is held inside the private key, so 
two processes that load the same key -
+two pods reading one volume, two replicas reading one vault secret - each 
advance their own copy and hand out the
+same index.
+
+The producer therefore *claims* a stateful key from the `KeyLifecycleManager` 
before it first signs with it, and
+holds the claim until the route stops. What the claim is worth depends on the 
store:
+
+[cols="2,3"]
+|===
+| 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.
+
+| `InMemoryKeyLifecycleManager`
+| Granted: the keys never leave the process.
+
+| `AwsSecretsManagerKeyLifecycleManager`, `AzureKeyVaultKeyLifecycleManager`, 
`HashicorpVaultKeyLifecycleManager`
+| Not granted. These stores hand the same secret to every reader and offer no 
lease Camel can take, so signing with a
+  stateful key through them is refused.
+|===
+
+A refused claim fails the exchange with a message naming the key. If a single 
signer is guaranteed by something
+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
+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.
 
 **5. Secure Key Export**
 
@@ -1317,6 +1359,9 @@ Both implementations are thread-safe:
 * **FileBasedKeyLifecycleManager**: Uses ConcurrentHashMap for caching and 
atomic file operations
 * **InMemoryKeyLifecycleManager**: Uses ConcurrentHashMap for all storage
 
+This covers concurrency *within one process*. It says nothing about two 
processes sharing a store, which matters for
+stateful signature keys - see <<_single_ownership_of_a_stateful_key>>.
+
 Concurrent key operations are safe:
 
 ._Java-only: concurrent key generation with thread pool_
diff --git 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCConfiguration.java
 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCConfiguration.java
index d224d5a56247..4cee078af9e2 100644
--- 
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCConfiguration.java
+++ 
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/PQCConfiguration.java
@@ -125,6 +125,15 @@ public class PQCConfiguration implements Cloneable {
     @Metadata(label = "advanced")
     private double statefulKeyWarningThreshold = 0.1;
 
+    @UriParam(defaultValue = "false", security = "insecure:dev",
+              description = "Whether to sign with a stateful key (XMSS, 
XMSSMT, LMS/HSS) that the configured "
+                            + "KeyLifecycleManager cannot claim for this 
process alone. These are one-time-signature "
+                            + "schemes: when two processes load the same key 
from a shared store, both sign from the "
+                            + "same index and the key is broken. Only enable 
it when something outside Camel "
+                            + "guarantees a single signer.")
+    @Metadata(label = "security,advanced")
+    private boolean allowSharedStatefulKey;
+
     public PQCOperations getOperation() {
         return operation;
     }
@@ -368,6 +377,20 @@ public class PQCConfiguration implements Cloneable {
         this.strictKeyLifecycle = strictKeyLifecycle;
     }
 
+    public boolean isAllowSharedStatefulKey() {
+        return allowSharedStatefulKey;
+    }
+
+    /**
+     * Whether to sign with a stateful key (XMSS, XMSSMT, LMS/HSS) that the 
configured KeyLifecycleManager cannot claim
+     * for this process alone. These are one-time-signature schemes: when two 
processes load the same key from a shared
+     * store, both sign from the same index and the key is broken. Only enable 
it when something outside Camel
+     * guarantees a single signer.
+     */
+    public void setAllowSharedStatefulKey(boolean allowSharedStatefulKey) {
+        this.allowSharedStatefulKey = allowSharedStatefulKey;
+    }
+
     public double getStatefulKeyWarningThreshold() {
         return statefulKeyWarningThreshold;
     }
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 1a2e44d06e4c..9bab2bbd683b 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
@@ -30,6 +30,8 @@ import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import javax.crypto.KeyAgreement;
@@ -126,12 +128,19 @@ public class PQCProducer extends DefaultProducer {
 
     private Signature signer;
     private KeyGenerator keyGenerator;
-    private KeyPair keyPair;
+    private volatile KeyPair keyPair;
 
     // Each keeps its warning to one line per producer instead of one per 
exchange
     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();
+
     // Set only when this producer created the Signature itself, so it knows 
how to create another one.
     // Left null when the user configured an instance, which then has to be 
shared and locked instead.
     private String signerAlgorithm;
@@ -572,13 +581,39 @@ public class PQCProducer extends DefaultProducer {
             healthCheckRepository.removeHealthCheck(producerHealthCheck);
             producerHealthCheck = null;
         }
+        releaseClaimedStatefulKeys();
         metrics.close();
         metrics = PQCMetrics.NOOP;
         super.doStop();
     }
 
+    /**
+     * Gives back the stateful keys this producer claimed, so another process 
can take them over once this one is
+     * stopped. A claim that cannot be released is logged rather than failing 
the stop: the operating system releases it
+     * with the process anyway.
+     */
+    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);
+            }
+        }
+        claimedStatefulKeys.clear();
+        statefulKeyClaims.clear();
+    }
+
     private void signature(Exchange exchange)
             throws Exception {
+        // the claim first: it may adopt the stored key, and that is the key 
the exhaustion check must look at
+        claimStatefulKeyBeforeSign(exchange);
         checkStatefulKeyBeforeSign();
 
         Signature signerForExchange = signerForExchange();
@@ -877,6 +912,8 @@ public class PQCProducer extends DefaultProducer {
     private void hybridSignature(Exchange exchange)
             throws InvalidPayloadException, InvalidKeyException, 
SignatureException, IOException,
             NoSuchAlgorithmException, NoSuchProviderException {
+        // the claim first: it may adopt the stored key, and that is the key 
the exhaustion check must look at
+        claimStatefulKeyBeforeSign(exchange);
         checkStatefulKeyBeforeSign();
 
         byte[] data = bodyToByteArray(exchange.getMessage());
@@ -1244,6 +1281,140 @@ public class PQCProducer extends DefaultProducer {
         getRequiredKeyLifecycleManager().deleteKey(keyId);
     }
 
+    /**
+     * Claims a stateful signature key for this process before it is used.
+     * <p/>
+     * XMSS, XMSSMT, LMS and HSS are one-time-signature schemes, and a store 
several processes read from - a shared
+     * 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.
+     */
+    private void claimStatefulKeyBeforeSign(Exchange exchange) {
+        if (keyPair == null || keyPair.getPrivate() == null
+                || getStatefulKeyRemaining(keyPair.getPrivate()) < 0) {
+            return;
+        }
+
+        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.
+            return;
+        }
+
+        if (claimedStatefulKeys.contains(keyId)) {
+            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;
+        });
+    }
+
+    private void doClaimStatefulKey(KeyLifecycleManager klm, String keyId) {
+        boolean claimed;
+        try {
+            claimed = klm.claimStatefulKey(keyId);
+        } catch (Exception e) {
+            throw new IllegalStateException(
+                    "Cannot claim the stateful key '" + 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,"
+                             + " 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());
+                }
+            } else {
+                throw new IllegalStateException(
+                        "The stateful key '" + keyId + "' cannot be claimed 
for this process: "
+                                                + 
klm.getClass().getSimpleName()
+                                                + " cannot guarantee that no 
other process holds it. "
+                                                + 
keyPair.getPrivate().getAlgorithm()
+                                                + " is a one-time-signature 
scheme, and two processes signing from the"
+                                                + " same index break the key. 
Use a KeyLifecycleManager that can claim"
+                                                + " a key, such as 
FileBasedKeyLifecycleManager, or set"
+                                                + " 
allowSharedStatefulKey=true if a single signer is guaranteed"
+                                                + " elsewhere.");
+            }
+        }
+    }
+
+    /**
+     * Re-reads the stored key once the claim is granted, and adopts it when 
its index has moved past the one this
+     * producer loaded.
+     * <p/>
+     * Without this the claim does not stop what it is meant to stop. {@code 
keyPair} is read once in
+     * {@link #doStart()}, so in a rolling update the arriving instance loads 
the key at some index, waits for the claim
+     * while the leaving instance keeps signing, and then starts from its own 
stale index - handing out every index the
+     * other instance already used. The claim only serialises the signers; it 
is this read that makes the second one
+     * resume where the first stopped.
+     */
+    private void adoptStoredStatefulKeyIfAhead(KeyLifecycleManager klm, String 
keyId) {
+        KeyPair stored;
+        try {
+            stored = klm.getKey(keyId);
+        } catch (Exception e) {
+            LOG.warn("Claimed the stateful key '{}' but could not re-read it 
from the store, so this producer keeps"
+                     + " the key it loaded at startup. If another process 
advanced that key meanwhile, signing now"
+                     + " reuses a one-time index.",
+                    keyId, e);
+            return;
+        }
+
+        if (stored == null || stored.getPrivate() == null) {
+            return;
+        }
+
+        if (isAnotherKey(stored.getPublic(), keyPair.getPublic())) {
+            LOG.warn("Claimed the stateful key '{}' but the stored public key 
is not the one loaded at startup, so"
+                     + " this producer keeps the key it loaded. The store 
holds another key under that id: a rotated"
+                     + " key, or a key directory that is shared by mistake.",
+                    keyId);
+            return;
+        }
+
+        long storedRemaining = getStatefulKeyRemaining(stored.getPrivate());
+        long loadedRemaining = getStatefulKeyRemaining(keyPair.getPrivate());
+        if (storedRemaining < 0) {
+            return;
+        }
+
+        // Fewer remaining signatures means the stored key has been used more 
than the one held here
+        if (storedRemaining < loadedRemaining) {
+            LOG.warn("The stored stateful key '{}' has {} signatures left 
while the one loaded at startup has {}."
+                     + " Another process advanced it. Adopting the stored key 
so this producer resumes from its"
+                     + " index instead of reusing indices that have already 
been handed out.",
+                    keyId, storedRemaining, loadedRemaining);
+            keyPair = stored;
+        }
+    }
+
+    /**
+     * Whether two public keys are known to be different keys. Keys whose 
encoding the provider does not give, or that
+     * are missing, cannot be told apart and are not reported as different.
+     */
+    private static boolean isAnotherKey(PublicKey stored, PublicKey loaded) {
+        if (stored == null || loaded == null) {
+            return false;
+        }
+        byte[] storedEncoded = stored.getEncoded();
+        byte[] loadedEncoded = loaded.getEncoded();
+        if (storedEncoded == null || loadedEncoded == null) {
+            return false;
+        }
+        return !MessageDigest.isEqual(storedEncoded, loadedEncoded);
+    }
+
     /**
      * Checks whether the current key is a stateful signature key (XMSS, 
XMSSMT, LMS/HSS) and if so, validates that it
      * has remaining signatures available. Logs a warning when remaining 
signatures fall below the configured threshold.
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 b7db7b6be41a..f7611996aeab 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
@@ -20,6 +20,10 @@ import java.io.BufferedInputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.ObjectInputStream;
+import java.io.UncheckedIOException;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -40,6 +44,8 @@ import java.util.Base64;
 import java.util.Date;
 import java.util.List;
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Stream;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
@@ -62,10 +68,25 @@ public class FileBasedKeyLifecycleManager implements 
KeyLifecycleManager {
     private static final Logger LOG = 
LoggerFactory.getLogger(FileBasedKeyLifecycleManager.class);
     private static final String METADATA_SUFFIX = ".metadata";
 
+    private static final String LOCK_SUFFIX = ".lock";
+
     private final Path keyDirectory;
     private final ObjectMapper objectMapper;
     private final ConcurrentHashMap<String, KeyPair> keyCache = new 
ConcurrentHashMap<>();
     private final ConcurrentHashMap<String, KeyMetadata> metadataCache = new 
ConcurrentHashMap<>();
+    /**
+     * The lock files held in this JVM, by their real path, with the manager 
that holds each. A manager that would take
+     * a lock another manager in this JVM already holds is refused here, 
before a second channel is opened on the file:
+     * a second channel is what must never exist, see {@link 
#openAndLock(String)}.
+     */
+    private static final ConcurrentHashMap<Path, FileBasedKeyLifecycleManager> 
JVM_CLAIMS = new ConcurrentHashMap<>();
+    /**
+     * Channels that turned out to hold a lock taken through another channel 
on the same file, see
+     * {@link #openAndLockChannel(String, Path)}. They stay referenced for the 
life of the JVM: closing them, or letting
+     * the garbage collector close them, would drop that other lock.
+     */
+    private static final List<FileChannel> FOREIGN_LOCK_CHANNELS = new 
CopyOnWriteArrayList<>();
+    private final ConcurrentHashMap<String, StatefulClaim> statefulClaims = 
new ConcurrentHashMap<>();
 
     public FileBasedKeyLifecycleManager(String keyDirectoryPath) throws 
IOException {
         this.keyDirectory = 
Paths.get(keyDirectoryPath).toAbsolutePath().normalize();
@@ -485,6 +506,171 @@ public class FileBasedKeyLifecycleManager implements 
KeyLifecycleManager {
         return resolveKeyFile(keyId, ".key");
     }
 
+    /**
+     * 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.
+     * <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 {
+        // 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) -> {
+                if (claim != null) {
+                    claim.holders.incrementAndGet();
+                    return claim;
+                }
+                return openAndLock(id);
+            });
+        } catch (UncheckedIOException e) {
+            throw e.getCause();
+        }
+        return true;
+    }
+
+    /**
+     * Takes the lock file of a key for this manager and returns the claim 
that holds it.
+     * <p/>
+     * One channel per lock file per JVM. On this platform closing any 
descriptor of a file releases the lock this
+     * process holds through every other descriptor, while {@code 
FileLock.isValid()} keeps reporting {@code true}, and
+     * a descriptor the garbage collector closes behind a forgotten channel 
does the same. So the file is registered in
+     * {@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
+     * 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);
+        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
+                                                + ". A stateful signature key 
must have a single owner: share the"
+                                                + " KeyLifecycleManager 
instance rather than the key directory.");
+            }
+            opened[0] = openAndLockChannel(keyId, 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);
+        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) 
{
+        FileChannel channel;
+        try {
+            channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, 
StandardOpenOption.WRITE);
+            restrictToOwner(lockFile);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        FileLock lock;
+        try {
+            lock = channel.tryLock();
+        } catch (OverlappingFileLockException e) {
+            // A lock on this file is held through a channel this class did 
not open. The channel is kept, not
+            // 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
+                                            + " outside of any 
FileBasedKeyLifecycleManager. A stateful signature key"
+                                            + " must have a single owner.",
+                    e);
+        } catch (IOException e) {
+            // No lock was taken on this channel, so closing it cannot drop one
+            closeQuietly(channel);
+            throw new UncheckedIOException(e);
+        }
+
+        if (lock == null) {
+            // 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
+                                            + ". 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.");
+        }
+
+        return channel;
+    }
+
+    /**
+     * Releases the claim by closing the channel that holds the lock, once the 
last holder has released it.
+     * <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.
+     */
+    @Override
+    public void releaseStatefulKey(String keyId) {
+        StatefulClaim[] released = new StatefulClaim[1];
+        statefulClaims.computeIfPresent(keyId, (id, claim) -> {
+            if (claim.holders.decrementAndGet() > 0) {
+                LOG.debug("The stateful key '{}' stays claimed, {} holders 
remain", id, claim.holders.get());
+                return claim;
+            }
+            released[0] = claim;
+            return null;
+        });
+        if (released[0] != null) {
+            // 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);
+        }
+    }
+
+    /**
+     * 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.
+     */
+    private static Path realPath(Path lockFile) {
+        try {
+            return 
lockFile.getParent().toRealPath().resolve(lockFile.getFileName());
+        } catch (IOException e) {
+            return lockFile.toAbsolutePath().normalize();
+        }
+    }
+
+    /**
+     * A claimed lock file: the channel holding its lock, and how many holders 
have claimed it.
+     */
+    private static final class StatefulClaim {
+        private final Path lockPath;
+        private final FileChannel channel;
+        private final AtomicInteger holders = new AtomicInteger(1);
+
+        private StatefulClaim(Path lockPath, FileChannel channel) {
+            this.lockPath = lockPath;
+            this.channel = channel;
+        }
+    }
+
+    private static void closeQuietly(FileChannel channel) {
+        try {
+            channel.close();
+        } catch (IOException e) {
+            LOG.debug("Could not close the lock file channel", e);
+        }
+    }
+
     /**
      * Resolves a key file inside the configured key directory, confining it 
to that directory. keyId values arrive from
      * message headers (CamelPQCKeyId / CamelPQCNewKeyId), so a value 
containing path separators, parent references or
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 625dfc20bf55..b7c8cb619139 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
@@ -181,6 +181,15 @@ public class InMemoryKeyLifecycleManager implements 
KeyLifecycleManager {
         }
     }
 
+    /**
+     * The keys of this manager live in its own maps, so no other process can 
hold the same one. The claim is therefore
+     * always granted.
+     */
+    @Override
+    public boolean claimStatefulKey(String keyId) {
+        return true;
+    }
+
     /**
      * Removes every key and its metadata. Convenient for resetting the 
manager between tests.
      */
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 f366960a9642..f5e806b07893 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
@@ -100,6 +100,39 @@ public interface KeyLifecycleManager {
      */
     void revokeKey(String keyId, String reason) throws Exception;
 
+    /**
+     * Claims a stateful signature key (XMSS, XMSSMT, LMS, HSS) for this 
process.
+     * <p/>
+     * These are one-time-signature schemes: signing twice from the same index 
breaks the key. A store that several
+     * 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.
+     *
+     * @param  keyId     the key to 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 {
+        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.
+     *
+     * @param  keyId     the key to release
+     * @throws Exception when the claim cannot be released
+     * @since            4.24
+     */
+    default void releaseStatefulKey(String keyId) throws Exception {
+        // nothing is claimed by default
+    }
+
     enum KeyFormat {
         PEM,
         DER,
diff --git 
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyAdoptionTest.java
 
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyAdoptionTest.java
new file mode 100644
index 000000000000..df954e01a574
--- /dev/null
+++ 
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyAdoptionTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.Security;
+import java.security.Signature;
+import java.security.spec.PKCS8EncodedKeySpec;
+
+import org.apache.camel.Produce;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.pqc.lifecycle.InMemoryKeyLifecycleManager;
+import org.apache.camel.component.pqc.lifecycle.KeyMetadata;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Taking the claim is not enough on its own. The key pair is read once when 
the producer starts, so an instance that
+ * waits for the claim while another instance keeps signing would otherwise 
resume from its own stale index and hand out
+ * every index the other one already used - the exact failover this feature 
exists to prevent.
+ * <p/>
+ * The two instances are modelled with two independent copies of one LMS key, 
decoded separately from the same PKCS#8
+ * encoding: signing with one leaves the other untouched, as it would across 
two processes.
+ */
+public class PQCStatefulKeyAdoptionTest extends CamelTestSupport {
+
+    private static final String KEY_ID = "lms-key";
+    private static final int BURNED_ELSEWHERE = 2;
+
+    private final InMemoryKeyLifecycleManager keyManager = new 
InMemoryKeyLifecycleManager();
+
+    private KeyPair loadedAtStartup;
+    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 {
+                KeyPair original = keyManager.generateKeyPair("LMS", KEY_ID);
+                byte[] encoded = original.getPrivate().getEncoded();
+
+                // what this producer loaded when it started
+                loadedAtStartup = new KeyPair(original.getPublic(), 
decode(encoded));
+                // what the other instance has been signing with, stored ahead 
of us
+                KeyPair elsewhere = new KeyPair(original.getPublic(), 
decode(encoded));
+                burn(elsewhere, BURNED_ELSEWHERE);
+
+                KeyMetadata metadata = keyManager.getKeyMetadata(KEY_ID);
+                keyManager.storeKey(KEY_ID, elsewhere, metadata);
+
+                remainingAtStartup = 
PQCProducer.getStatefulKeyRemaining(loadedAtStartup.getPrivate());
+                remainingInStore = 
PQCProducer.getStatefulKeyRemaining(elsewhere.getPrivate());
+
+                context.getRegistry().bind("keyManager", keyManager);
+                context.getRegistry().bind("startupKeyPair", loadedAtStartup);
+                from("direct:sign")
+                        
.to("pqc:sign?operation=sign&signatureAlgorithm=LMS&keyLifecycleManager=#keyManager"
+                            + "&keyPair=#startupKeyPair");
+            }
+        };
+    }
+
+    @Test
+    void aStoredKeyThatMovedAheadIsAdoptedOnTheFirstSign() 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");
+
+        templateSign.sendBodyAndHeader("direct:sign", "Hello", 
PQCConstants.KEY_ID, KEY_ID);
+
+        long remainingAfter = 
PQCProducer.getStatefulKeyRemaining(keyManager.getKey(KEY_ID).getPrivate());
+
+        // Signing from the stored index consumes one more of its remaining 
signatures. Signing from the stale
+        // startup index would instead store a key with more remaining than 
the store already had - every index in
+        // between handed out twice.
+        assertEquals(remainingInStore - 1, remainingAfter,
+                "the producer must sign from the stored index, not from the 
one it loaded at startup");
+        assertTrue(remainingAfter < remainingInStore, "the stored key must 
have advanced, not rewound");
+    }
+
+    @Test
+    void aStoredKeyThatIsAnotherKeyIsNotAdopted() throws Exception {
+        // another key under the same id, further ahead than the one loaded at 
startup: a rotated key, or a key
+        // directory shared by mistake
+        KeyPair another = keyManager.generateKeyPair("LMS", "another-" + 
KEY_ID);
+        burn(another, BURNED_ELSEWHERE + 1);
+        keyManager.storeKey(KEY_ID, another, 
keyManager.getKeyMetadata(KEY_ID));
+        assertTrue(PQCProducer.getStatefulKeyRemaining(another.getPrivate()) < 
remainingAtStartup,
+                "the other key must be ahead for this test to mean anything");
+
+        templateSign.sendBodyAndHeader("direct:sign", "Hello", 
PQCConstants.KEY_ID, KEY_ID);
+
+        KeyPair inStore = keyManager.getKey(KEY_ID);
+        // the producer kept the key it loaded: that is the key it signed with 
and stored back
+        assertArrayEquals(loadedAtStartup.getPublic().getEncoded(), 
inStore.getPublic().getEncoded(),
+                "a stored key with another public key must not become the 
signing key");
+        assertEquals(remainingAtStartup - 1, 
PQCProducer.getStatefulKeyRemaining(inStore.getPrivate()),
+                "the producer must have signed once from the index it loaded");
+    }
+
+    private static PrivateKey decode(byte[] pkcs8) throws Exception {
+        return KeyFactory.getInstance("LMS", "BC").generatePrivate(new 
PKCS8EncodedKeySpec(pkcs8));
+    }
+
+    /**
+     * 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();
+        }
+    }
+}
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
new file mode 100644
index 000000000000..d289fceb97e0
--- /dev/null
+++ 
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCStatefulKeyClaimTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.security.Security;
+
+import org.apache.camel.CamelExecutionException;
+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.InMemoryKeyLifecycleManager;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A stateful signature key must belong to one process. When the configured 
KeyLifecycleManager cannot promise that - a
+ * shared vault hands the same key to every replica - signing is refused 
rather than risking a one-time index being
+ * handed out twice.
+ */
+public class PQCStatefulKeyClaimTest extends CamelTestSupport {
+
+    private static final String KEY_ID = "lms-key";
+
+    private final UnclaimableKeyManager keyManager = new 
UnclaimableKeyManager();
+
+    @Produce("direct:sign")
+    protected ProducerTemplate templateSign;
+
+    @Produce("direct:signShared")
+    protected ProducerTemplate templateSignShared;
+
+    @Produce("direct:signStateless")
+    protected ProducerTemplate templateSignStateless;
+
+    @BeforeEach
+    void addProviders() {
+        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() {
+                context.getRegistry().bind("keyManager", keyManager);
+                from("direct:sign")
+                        
.to("pqc:sign?operation=sign&signatureAlgorithm=LMS&keyLifecycleManager=#keyManager");
+                from("direct:signShared")
+                        
.to("pqc:signShared?operation=sign&signatureAlgorithm=LMS&keyLifecycleManager=#keyManager"
+                            + "&allowSharedStatefulKey=true");
+                from("direct:signStateless")
+                        
.to("pqc:signStateless?operation=sign&signatureAlgorithm=MLDSA&keyLifecycleManager=#keyManager");
+            }
+        };
+    }
+
+    @Test
+    void aStatefulKeyThatCannotBeClaimedIsRefused() throws Exception {
+        keyManager.generateKeyPair("LMS", KEY_ID);
+
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> templateSign.sendBodyAndHeader("direct:sign", "Hello", 
PQCConstants.KEY_ID, KEY_ID));
+
+        String message = e.getCause().getMessage();
+        assertTrue(message.contains(KEY_ID), "the message must name the key: " 
+ message);
+        assertTrue(message.contains("allowSharedStatefulKey"),
+                "the message must say how to proceed deliberately: " + 
message);
+    }
+
+    @Test
+    void noSignatureIsPublishedForAKeyThatCannotBeClaimed() throws Exception {
+        keyManager.generateKeyPair("LMS", KEY_ID);
+
+        Exchange out = templateSign.send("direct:sign", e -> {
+            e.getMessage().setBody("Hello");
+            e.getMessage().setHeader(PQCConstants.KEY_ID, KEY_ID);
+        });
+
+        assertTrue(out.isFailed(), "the exchange must be marked failed");
+        assertNull(out.getMessage().getHeader(PQCConstants.SIGNATURE),
+                "the index must not be consumed by a key this process may not 
own");
+    }
+
+    @Test
+    void allowSharedStatefulKeySignsAnyway() throws Exception {
+        keyManager.generateKeyPair("LMS", KEY_ID);
+
+        Exchange out = templateSignShared.send("direct:signShared", e -> {
+            e.getMessage().setBody("Hello");
+            e.getMessage().setHeader(PQCConstants.KEY_ID, KEY_ID);
+        });
+
+        assertNull(out.getException(), "the opt-in must allow the signature");
+        assertTrue(out.getMessage().getHeader(PQCConstants.SIGNATURE, 
byte[].class).length > 0);
+    }
+
+    @Test
+    void aStatelessKeyIsNotClaimedAtAll() throws Exception {
+        keyManager.generateKeyPair("MLDSA", "mldsa-key");
+
+        Exchange out = templateSignStateless.send("direct:signStateless", e -> 
{
+            e.getMessage().setBody("Hello");
+            e.getMessage().setHeader(PQCConstants.KEY_ID, "mldsa-key");
+        });
+
+        assertNull(out.getException(), "ML-DSA has no one-time index, so no 
claim is needed");
+        assertTrue(out.getMessage().getHeader(PQCConstants.SIGNATURE, 
byte[].class).length > 0);
+    }
+
+    /**
+     * Stands in for the vault-backed managers, which cannot promise that the 
key they hand out is not also held
+     * somewhere else.
+     */
+    private static final class UnclaimableKeyManager extends 
InMemoryKeyLifecycleManager {
+
+        @Override
+        public boolean claimStatefulKey(String keyId) {
+            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
new file mode 100644
index 000000000000..797bdf2048b1
--- /dev/null
+++ 
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/FileBasedKeyLifecycleManagerStatefulClaimTest.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.pqc.lifecycle;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.Security;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+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.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.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.
+ */
+public class FileBasedKeyLifecycleManagerStatefulClaimTest {
+
+    private static final String KEY_ID = "lms-key";
+
+    static {
+        if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+            Security.addProvider(new BouncyCastleProvider());
+        }
+        if (Security.getProvider(BouncyCastlePQCProvider.PROVIDER_NAME) == 
null) {
+            Security.addProvider(new BouncyCastlePQCProvider());
+        }
+    }
+
+    @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");
+
+        // claiming what this manager already holds is not contention
+        assertTrue(manager.claimStatefulKey(KEY_ID), "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);
+        FileBasedKeyLifecycleManager other = new 
FileBasedKeyLifecycleManager(keyDir.toString());
+        assertThrows(IllegalStateException.class, () -> 
other.claimStatefulKey(KEY_ID),
+                "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);
+    }
+
+    @Test
+    void aSecondHolderOfTheSameKeyDirectoryIsRefused(@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));
+
+        IllegalStateException e = assertThrows(IllegalStateException.class, () 
-> second.claimStatefulKey(KEY_ID));
+        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),
+                "the claim must still be registered after a second holder was 
refused");
+
+        first.releaseStatefulKey(KEY_ID);
+    }
+
+    @Test
+    void aSecondHolderThroughAnotherPathToTheSameDirectoryIsRefused(@TempDir 
Path tempDir) throws Exception {
+        Path keyDir = tempDir.resolve("keys");
+        FileBasedKeyLifecycleManager first = new 
FileBasedKeyLifecycleManager(keyDir.toString());
+        Path link = tempDir.resolve("keys-link");
+        try {
+            Files.createSymbolicLink(link, keyDir);
+        } catch (IOException | UnsupportedOperationException e) {
+            Assumptions.abort("symbolic links are not available here: " + e);
+        }
+        FileBasedKeyLifecycleManager second = new 
FileBasedKeyLifecycleManager(link.toString());
+
+        assertTrue(first.claimStatefulKey(KEY_ID));
+        // 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),
+                "the same directory under another path must be refused");
+
+        first.releaseStatefulKey(KEY_ID);
+    }
+
+    @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));
+        // 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);
+
+        // 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);
+    }
+
+    @Test
+    void concurrentFirstClaimsOnOneManagerAllSucceed(@TempDir Path tempDir) 
throws Exception {
+        Path keyDir = tempDir.resolve("keys");
+        FileBasedKeyLifecycleManager manager = new 
FileBasedKeyLifecycleManager(keyDir.toString());
+
+        int threads = 8;
+        CountDownLatch start = new CountDownLatch(1);
+        List<Future<Boolean>> results = new ArrayList<>();
+        ExecutorService pool = Executors.newFixedThreadPool(threads);
+        try {
+            for (int i = 0; i < threads; i++) {
+                results.add(pool.submit(() -> {
+                    start.await();
+                    return manager.claimStatefulKey(KEY_ID);
+                }));
+            }
+            start.countDown();
+
+            for (Future<Boolean> r : results) {
+                // Every caller sharing the manager must get the claim. Before 
the claim was made atomic, the losers
+                // of the race opened a second channel, hit 
OverlappingFileLockException and were told to "share the
+                // KeyLifecycleManager instance" - which they already were.
+                assertTrue(assertDoesNotThrow(() -> r.get(20, 
TimeUnit.SECONDS)),
+                        "a concurrent claim on the same manager must succeed");
+            }
+        } finally {
+            pool.shutdownNow();
+        }
+
+        // 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));
+
+        // every holder releases the key, and only then is it given back
+        for (int i = 0; i < threads; i++) {
+            manager.releaseStatefulKey(KEY_ID);
+        }
+        assertDoesNotThrow(() -> other.claimStatefulKey(KEY_ID),
+                "once every holder has released the key, another manager must 
be able to claim it");
+        other.releaseStatefulKey(KEY_ID);
+    }
+
+    @Test
+    void aReleasedKeyCanBeClaimedAgain(@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));
+        first.releaseStatefulKey(KEY_ID);
+
+        assertDoesNotThrow(() -> second.claimStatefulKey(KEY_ID),
+                "a key given back must be available to the next owner");
+        second.releaseStatefulKey(KEY_ID);
+    }
+
+    @Test
+    void releasingAKeyThatWasNeverClaimedDoesNothing(@TempDir Path tempDir) 
throws Exception {
+        FileBasedKeyLifecycleManager manager = new 
FileBasedKeyLifecycleManager(tempDir.resolve("keys").toString());
+
+        assertDoesNotThrow(() -> manager.releaseStatefulKey(KEY_ID));
+    }
+
+    @Test
+    void aKeyIdThatEscapesTheDirectoryIsRejected(@TempDir Path tempDir) throws 
Exception {
+        FileBasedKeyLifecycleManager manager = new 
FileBasedKeyLifecycleManager(tempDir.resolve("keys").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"));
+    }
+}
diff --git 
a/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java 
b/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java
index af6662552350..bdfb91b88856 100644
--- a/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java
+++ b/core/camel-util/src/main/java/org/apache/camel/util/SecurityUtils.java
@@ -71,6 +71,7 @@ public final class SecurityUtils {
         map.put("allowpredicatefrommessage", new SecurityOption(INSECURE_DEV, 
"true"));
         map.put("allowqueryfromheader", new SecurityOption(INSECURE_DEV, 
"true"));
         map.put("allowserializedheaders", new 
SecurityOption(INSECURE_SERIALIZATION, "true"));
+        map.put("allowsharedstatefulkey", new SecurityOption(INSECURE_DEV, 
"true"));
         map.put("allowtemplatefromheader", new SecurityOption(INSECURE_DEV, 
"true"));
         map.put("allowurlbody", new SecurityOption(INSECURE_DEV, "true"));
         map.put("allowurlsource", new SecurityOption(INSECURE_DEV, "true"));
@@ -155,6 +156,8 @@ public final class SecurityUtils {
                 "component:jms",
                 "component:netty",
                 "component:nettyhttp"));
+        owners.put("allowsharedstatefulkey", Set.of(
+                "component:pqc"));
         owners.put("allowtemplatefromheader", Set.of(
                 "component:sqlstored"));
         owners.put("allowurlbody", Set.of(
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 9162438e1c97..25cad5f984cb 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
@@ -1553,6 +1553,31 @@ values such as `AES`, which are pure ASCII. A deployment 
running on a JVM whose
 name identically to UTF-8 would, however, derive a different key than the 
previous version, and could not decrypt
 material produced by it.
 
+=== camel-pqc - a stateful signature key is claimed for one process
+
+XMSS, XMSSMT, LMS and HSS are one-time-signature schemes: signing twice from 
the same index breaks the key. The index
+lives inside the private key, so two processes that load the same key from a 
shared store - two pods on one volume,
+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.
+* `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
+  message naming the key.
+
+Set the new `allowSharedStatefulKey=true` option on the endpoint to sign 
anyway, when a single signer is guaranteed
+outside Camel (one replica, an external lock, a leader election). A warning is 
logged once per key.
+
+Nothing changes for the stateless signature algorithms (ML-DSA, SLH-DSA, 
Falcon and the rest), for the KEM
+operations, or for an endpoint that signs with a stateful key and no 
`KeyLifecycleManager` - that case persists no
+index at all and keeps the warning it already had.
+
 === camel-pqc - the BouncyCastle providers stay registered when an endpoint 
stops
 
 Stopping a `pqc` endpoint no longer removes the `BC` and `BCPQC` providers 
from `java.security.Security`.
diff --git 
a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/PqcComponentBuilderFactory.java
 
b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/PqcComponentBuilderFactory.java
index a320934f3502..e61fd879b151 100644
--- 
a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/PqcComponentBuilderFactory.java
+++ 
b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/PqcComponentBuilderFactory.java
@@ -598,6 +598,28 @@ public interface PqcComponentBuilderFactory {
             doSetProperty("healthCheckProducerEnabled", 
healthCheckProducerEnabled);
             return this;
         }
+    
+        
+        /**
+         * Whether to sign with a stateful key (XMSS, XMSSMT, LMS/HSS) that the
+         * configured KeyLifecycleManager cannot claim for this process alone.
+         * These are one-time-signature schemes: when two processes load the
+         * same key from a shared store, both sign from the same index and the
+         * key is broken. Only enable it when something outside Camel 
guarantees
+         * a single signer.
+         * 
+         * The option is a: &lt;code&gt;boolean&lt;/code&gt; type.
+         * 
+         * Default: false
+         * Group: security (advanced)
+         * 
+         * @param allowSharedStatefulKey the value to set
+         * @return the dsl builder
+         */
+        default PqcComponentBuilder allowSharedStatefulKey(boolean 
allowSharedStatefulKey) {
+            doSetProperty("allowSharedStatefulKey", allowSharedStatefulKey);
+            return this;
+        }
     }
 
     class PqcComponentBuilderImpl
@@ -650,6 +672,7 @@ public interface PqcComponentBuilderFactory {
             case "symmetricKeyLength": getOrCreateConfiguration((PQCComponent) 
component).setSymmetricKeyLength((int) value); return true;
             case "healthCheckConsumerEnabled": ((PQCComponent) 
component).setHealthCheckConsumerEnabled((boolean) value); return true;
             case "healthCheckProducerEnabled": ((PQCComponent) 
component).setHealthCheckProducerEnabled((boolean) value); return true;
+            case "allowSharedStatefulKey": 
getOrCreateConfiguration((PQCComponent) 
component).setAllowSharedStatefulKey((boolean) value); return true;
             default: return false;
             }
         }
diff --git 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/PQCEndpointBuilderFactory.java
 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/PQCEndpointBuilderFactory.java
index 0ead2ec5d1f2..ca49f7de5fa8 100644
--- 
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/PQCEndpointBuilderFactory.java
+++ 
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/PQCEndpointBuilderFactory.java
@@ -662,6 +662,46 @@ public interface PQCEndpointBuilderFactory {
             doSetProperty("symmetricKeyLength", symmetricKeyLength);
             return this;
         }
+        /**
+         * Whether to sign with a stateful key (XMSS, XMSSMT, LMS/HSS) that the
+         * configured KeyLifecycleManager cannot claim for this process alone.
+         * These are one-time-signature schemes: when two processes load the
+         * same key from a shared store, both sign from the same index and the
+         * key is broken. Only enable it when something outside Camel 
guarantees
+         * a single signer.
+         * 
+         * The option is a: <code>boolean</code> type.
+         * 
+         * Default: false
+         * Group: security (advanced)
+         * 
+         * @param allowSharedStatefulKey the value to set
+         * @return the dsl builder
+         */
+        default AdvancedPQCEndpointBuilder allowSharedStatefulKey(boolean 
allowSharedStatefulKey) {
+            doSetProperty("allowSharedStatefulKey", allowSharedStatefulKey);
+            return this;
+        }
+        /**
+         * Whether to sign with a stateful key (XMSS, XMSSMT, LMS/HSS) that the
+         * configured KeyLifecycleManager cannot claim for this process alone.
+         * These are one-time-signature schemes: when two processes load the
+         * same key from a shared store, both sign from the same index and the
+         * key is broken. Only enable it when something outside Camel 
guarantees
+         * a single signer.
+         * 
+         * The option will be converted to a <code>boolean</code> type.
+         * 
+         * Default: false
+         * Group: security (advanced)
+         * 
+         * @param allowSharedStatefulKey the value to set
+         * @return the dsl builder
+         */
+        default AdvancedPQCEndpointBuilder allowSharedStatefulKey(String 
allowSharedStatefulKey) {
+            doSetProperty("allowSharedStatefulKey", allowSharedStatefulKey);
+            return this;
+        }
     }
 
     public interface PQCBuilders {

Reply via email to