Andrea Cosentino created CAMEL-25801:
----------------------------------------
Summary: camel-pqc - align the KeyLifecycleManager implementations
on metadata updates, listing and deletion
Key: CAMEL-25801
URL: https://issues.apache.org/jira/browse/CAMEL-25801
Project: Camel
Issue Type: Bug
Components: camel-pqc
Reporter: Andrea Cosentino
The five {{KeyLifecycleManager}} implementations all override all fifteen
interface methods, but they diverge behaviourally in ways the interface does
not document and callers cannot anticipate.
h3. 1. Three managers rewrite the private key on every metadata update
{{AwsSecretsManagerKeyLifecycleManager.java:370-376}} (identical at
{{AzureKeyVaultKeyLifecycleManager.java:297-303}} and
{{HashicorpVaultKeyLifecycleManager.java:410-416}}):
{code:java}
public void updateKeyMetadata(String keyId, KeyMetadata metadata) throws
Exception {
// Read existing key pair
KeyPair keyPair = getKey(keyId);
// Store updated metadata with existing key pair
storeKey(keyId, keyPair, metadata);
}
{code}
versus {{FileBasedKeyLifecycleManager.java:236-242}}, which writes only the
metadata file.
Consequences: every {{expireKey}}, {{revokeKey}} and rotation-deprecation
re-uploads the PKCS#8 private key and creates a new secret version — on Vault
KV v2 with the default {{max_versions = 10}}, ten metadata edits purge every
historical version of the private key. It also means {{revokeKey}} on a key
whose private secret is already gone throws from {{getKey}} instead of
recording the revocation, which is exactly when revocation matters most.
{{PQCProducer.persistStatefulKeyStateAfterSign}} compounds it at {{:1186-1189}}
by calling {{updateKeyMetadata}} and then {{storeKey}} — six remote writes per
signature where three suffice.
h3. 2. {{FileBasedKeyLifecycleManager.listKeys()}} reads a boot-time cache
{{FileBasedKeyLifecycleManager.java:257-259}}:
{code:java}
public List<KeyMetadata> listKeys() throws Exception {
return new ArrayList<>(metadataCache.values());
}
{code}
The other managers query the backing store (AWS {{:392}} {{listSecrets}}, Azure
{{:319}} {{listPropertiesOfSecrets}}, Vault {{:435}} {{vaultTemplate.list}}).
The directory is scanned exactly once, by {{loadExistingKeys()}} in the
constructor ({{:76}}, {{:384}}). Any key file written after start-up is
invisible to {{listKeys()}} forever, even though {{getKey}} would read it
happily. Since {{KeyRotationScheduler.checkAndRotate()}} drives entirely off
{{listKeys()}} ({{:123}}), a file-backed deployment silently stops rotating
anything it did not see at boot.
h3. 3. Opposite deletion semantics behind one interface
{{AwsSecretsManagerKeyLifecycleManager.java:525-529}}:
{code:java}
DeleteSecretRequest request = DeleteSecretRequest.builder()
.secretId(secretName)
.forceDeleteWithoutRecovery(true)
.build();
{code}
bypasses the 7–30 day recovery window entirely, while
{{AzureKeyVaultKeyLifecycleManager.java:405-408}}:
{code:java}
secretClient.beginDeleteSecret(secretName);
LOG.debug("Deletion initiated for secret: {}", secretName);
{code}
discards the returned {{SyncPoller}}, so {{deleteKey}} returns before the vault
has deleted anything and a subsequent {{storeKey}} with the same id can race
the in-flight delete. The whole contract of {{KeyLifecycleManager#deleteKey}}
({{:78-81}}) is {{/** Delete key */}}.
h3. 4. {{deleteKeyState}} silently succeeds with no manager configured
{{PQCProducer.java:1102-1105}}:
{code:java}
KeyLifecycleManager klm = getConfiguration().getKeyLifecycleManager();
if (klm != null) {
klm.deleteKey(keyId);
}
{code}
Every other lifecycle operation uses {{getRequiredKeyLifecycleManager()}}
({{:929-937}}) and fails loudly. Reporting success for a stateful key state
that was never deleted is the worst available failure mode here.
h3. 5. {{exportKey(PEM, includePrivate=true)}} cannot be re-imported
{{KeyFormatConverter.java:72-74}}:
{code:java}
if (format == KeyLifecycleManager.KeyFormat.PEM) {
return (new String(publicKeyBytes) + "\n" + new
String(privateKeyBytes)).getBytes();
{code}
{{KeyFormatConverter.java:168-173}}:
{code:java}
String pemString = new String(pemData);
pemString = pemString.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
return Base64.getDecoder().decode(pemString);
{code}
The combined blob carries both armour pairs; {{parsePEMPrivateKey}} strips only
the PRIVATE markers, leaving {{-----BEGIN PUBLIC KEY-----}} in the string
passed to {{Base64.getDecoder()}}, which rejects {{-}}. The {{importKey}}
fallback to {{importPublicKey}} hits the mirror-image failure at {{:155-162}}.
So the only export format that carries a private key does not round-trip. The
DER branch ({{:76-77}}) silently drops the public key.
The same class uses the platform default charset at {{:74}}, {{:134}},
{{:151}}, {{:158}} and {{:169}}.
h3. 6. AWS and Vault still duplicate {{KeyAlgorithmSupport}}
{{AwsSecretsManagerKeyLifecycleManager.java:177-218}} and
{{HashicorpVaultKeyLifecycleManager.java:191-232}} inline their own key
generation, provider resolution and {{getDefaultKeySize}}, while InMemory,
FileBased and Azure call {{KeyAlgorithmSupport.generateKeyPair}} in one line.
The copies are already dead-ish: both {{getDefaultKeySize(String algorithm)}}
implementations ignore their parameter and return {{256}}.
h3. 7. AWS {{listKeys()}} has no server-side filter
{{AwsSecretsManagerKeyLifecycleManager.java:398-399}} paginates correctly via
{{nextToken}}, but applies no {{filters(...)}}, so a shared AWS account is
paged through in full on every rotation tick and filtered client-side at
{{:410}}.
h3. Suggested fix
Give {{KeyLifecycleManager}} a real contract per method, make
{{updateKeyMetadata}} metadata-only everywhere, make {{FileBased.listKeys()}}
re-scan the directory, settle on one deletion semantic (recoverable, awaited),
route {{deleteKeyState}} through {{getRequiredKeyLifecycleManager()}}, fix the
combined-PEM round-trip with explicit UTF-8, migrate AWS/Vault onto
{{KeyAlgorithmSupport}}, and add a name filter to the AWS list call.
h3. Test gaps
No test round-trips {{exportKey(PEM, includePrivate=true)}} through
{{importKey}} — {{PQCKeyLifecycleTest.java:121}} exports and asserts on bytes
only, and every import test uses {{exportPublicKey}}. No test writes a key file
out of band and then calls {{FileBased.listKeys()}}. AWS and HashiCorp have
only {{*IT}} tests needing live services, so none of the above has a unit-level
net; a mocked client test mirroring {{AzureKeyVaultKeyLifecycleManagerTest}}
would close that.
----
_Reported by Claude Code (AI agent) on behalf of [~oscerd]. Every code
reference was verified against commit {{7aae27315946}} on {{main}}._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)