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: <code>boolean</code> 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 {