This is an automated email from the ASF dual-hosted git repository.
oscerd 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 feea08e7847f CAMEL-23726: Use JSON instead of Java serialization for
key metadata in AWS and HashiCorp Vault lifecycle managers (#23912)
feea08e7847f is described below
commit feea08e7847f35dc0e177652b0b02bd45f6c1b4f
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Jun 11 08:51:34 2026 +0200
CAMEL-23726: Use JSON instead of Java serialization for key metadata in AWS
and HashiCorp Vault lifecycle managers (#23912)
Co-Authored-By: Claude Fable 5 <[email protected]>
---
.../AwsSecretsManagerKeyLifecycleManager.java | 79 +++--------
.../lifecycle/FileBasedKeyLifecycleManager.java | 24 +++-
.../HashicorpVaultKeyLifecycleManager.java | 28 ++--
.../component/pqc/lifecycle/KeyMetadataCodec.java | 144 +++++++++++++++++++++
.../camel/component/pqc/PQCKeyLifecycleTest.java | 55 ++++++++
.../pqc/lifecycle/KeyMetadataCodecTest.java | 122 +++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_18.adoc | 11 ++
.../ROOT/pages/camel-4x-upgrade-guide-4_21.adoc | 11 ++
8 files changed, 395 insertions(+), 79 deletions(-)
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
index 6aba8fb4122a..267625497f12 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/AwsSecretsManagerKeyLifecycleManager.java
@@ -17,9 +17,7 @@
package org.apache.camel.component.pqc.lifecycle;
import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
import java.net.URI;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@@ -34,6 +32,7 @@ import java.util.Date;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.camel.component.pqc.PQCKeyEncapsulationAlgorithms;
import org.apache.camel.component.pqc.PQCSignatureAlgorithms;
@@ -264,7 +263,6 @@ public class AwsSecretsManagerKeyLifecycleManager
implements KeyLifecycleManager
byte[] publicKeyBytes = keyPair.getPublic().getEncoded(); //
X.509/SubjectPublicKeyInfo format
String privateKeyBase64 =
Base64.getEncoder().encodeToString(privateKeyBytes);
String publicKeyBase64 =
Base64.getEncoder().encodeToString(publicKeyBytes);
- String metadataBase64 = serializeMetadata(metadata);
// Store private key separately (strict IAM policy recommended in
production)
String privateSecretName = getSecretName(keyId, "private");
@@ -284,12 +282,9 @@ public class AwsSecretsManagerKeyLifecycleManager
implements KeyLifecycleManager
createOrUpdateSecret(publicSecretName, publicSecretValue, "PQC Public
Key: " + keyId);
- // Store metadata separately
+ // Store metadata separately as JSON (see KeyMetadataCodec)
String metadataSecretName = getSecretName(keyId, "metadata");
- String metadataSecretValue = objectMapper.writeValueAsString(new
MetadataData(
- metadataBase64,
- keyId,
- metadata.getAlgorithm()));
+ String metadataSecretValue = KeyMetadataCodec.toJson(metadata);
createOrUpdateSecret(metadataSecretName, metadataSecretValue, "PQC Key
Metadata: " + keyId);
@@ -347,8 +342,16 @@ public class AwsSecretsManagerKeyLifecycleManager
implements KeyLifecycleManager
try {
GetSecretValueResponse response = getSecret(metadataSecretName);
- MetadataData metadataData =
objectMapper.readValue(response.secretString(), MetadataData.class);
- KeyMetadata metadata =
deserializeMetadata(metadataData.getMetadata());
+ String secret = response.secretString();
+
+ KeyMetadata metadata;
+ JsonNode node = objectMapper.readTree(secret);
+ if (node.has("metadata")) {
+ // Legacy format: a Base64-encoded, Java-serialized
KeyMetadata wrapped in an envelope
+ metadata = deserializeMetadata(node.get("metadata").asText());
+ } else {
+ metadata = KeyMetadataCodec.fromJson(secret);
+ }
// Cache it
metadataCache.put(keyId, metadata);
@@ -525,18 +528,16 @@ public class AwsSecretsManagerKeyLifecycleManager
implements KeyLifecycleManager
}
}
- private String serializeMetadata(KeyMetadata metadata) throws Exception {
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
- oos.writeObject(metadata);
- }
- return Base64.getEncoder().encodeToString(baos.toByteArray());
- }
-
+ /**
+ * Reads a legacy (pre-JSON) Base64-encoded, Java-serialized {@link
KeyMetadata}. The deserialization is constrained
+ * to the expected types via {@link KeyMetadataCodec#METADATA_FILTER}.
Retained for backward compatibility with
+ * metadata written by older versions; new metadata is stored as JSON.
+ */
private KeyMetadata deserializeMetadata(String base64) throws Exception {
byte[] data = Base64.getDecoder().decode(base64);
ByteArrayInputStream bais = new ByteArrayInputStream(data);
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
+ ois.setObjectInputFilter(KeyMetadataCodec.METADATA_FILTER);
return (KeyMetadata) ois.readObject();
}
}
@@ -675,46 +676,4 @@ public class AwsSecretsManagerKeyLifecycleManager
implements KeyLifecycleManager
this.algorithm = algorithm;
}
}
-
- /**
- * Helper class for storing metadata in JSON format
- */
- private static class MetadataData {
- private String metadata;
- private String keyId;
- private String algorithm;
-
- public MetadataData() {
- }
-
- public MetadataData(String metadata, String keyId, String algorithm) {
- this.metadata = metadata;
- this.keyId = keyId;
- this.algorithm = algorithm;
- }
-
- public String getMetadata() {
- return metadata;
- }
-
- public void setMetadata(String metadata) {
- this.metadata = metadata;
- }
-
- public String getKeyId() {
- return keyId;
- }
-
- public void setKeyId(String keyId) {
- this.keyId = keyId;
- }
-
- public String getAlgorithm() {
- return algorithm;
- }
-
- public void setAlgorithm(String algorithm) {
- this.algorithm = algorithm;
- }
- }
}
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 46b44bde4415..543b0088e37d 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
@@ -248,10 +248,10 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
return null;
}
- String content = Files.readString(metadataFile,
StandardCharsets.UTF_8);
-
- // Detect format: JSON starts with '{', legacy Java serialization
starts with binary
- if (content.trim().startsWith("{")) {
+ // Detect the format from the raw bytes: a JSON document starts with
'{', whereas a legacy
+ // Java-serialized file is binary (and not valid UTF-8), so it must
not be read as a String first.
+ byte[] content = Files.readAllBytes(metadataFile);
+ if (isJsonContent(content)) {
MetadataFileData data = objectMapper.readValue(content,
MetadataFileData.class);
KeyMetadata metadata = data.toKeyMetadata();
metadataCache.put(keyId, metadata);
@@ -369,6 +369,7 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
KeyPair keyPair;
try (ObjectInputStream ois = new ObjectInputStream(new
BufferedInputStream(Files.newInputStream(legacyKeyFile)))) {
+ ois.setObjectInputFilter(KeyMetadataCodec.KEY_PAIR_FILTER);
keyPair = (KeyPair) ois.readObject();
}
@@ -395,6 +396,7 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
KeyMetadata metadata;
try (ObjectInputStream ois = new ObjectInputStream(new
BufferedInputStream(Files.newInputStream(metadataFile)))) {
+ ois.setObjectInputFilter(KeyMetadataCodec.METADATA_FILTER);
metadata = (KeyMetadata) ois.readObject();
}
@@ -444,6 +446,20 @@ public class FileBasedKeyLifecycleManager implements
KeyLifecycleManager {
return keyDirectory.resolve(keyId + ".key");
}
+ /**
+ * Detects whether the given file content is a JSON document (the current
format) by inspecting the first
+ * non-whitespace byte, without decoding the bytes as text (a legacy
Java-serialized file is binary).
+ */
+ private static boolean isJsonContent(byte[] content) {
+ for (byte b : content) {
+ if (b == ' ' || b == '\t' || b == '\n' || b == '\r') {
+ continue;
+ }
+ return b == '{';
+ }
+ return false;
+ }
+
private String determineProvider(String algorithm) {
try {
PQCSignatureAlgorithms sigAlg =
PQCSignatureAlgorithms.valueOf(algorithm);
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
index 5d80fc1373a4..b9a3dade2430 100644
---
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/HashicorpVaultKeyLifecycleManager.java
@@ -17,9 +17,7 @@
package org.apache.camel.component.pqc.lifecycle;
import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
@@ -280,7 +278,7 @@ public class HashicorpVaultKeyLifecycleManager implements
KeyLifecycleManager {
byte[] publicKeyBytes = keyPair.getPublic().getEncoded(); //
X.509/SubjectPublicKeyInfo format
String privateKeyBase64 =
Base64.getEncoder().encodeToString(privateKeyBytes);
String publicKeyBase64 =
Base64.getEncoder().encodeToString(publicKeyBytes);
- String metadataBase64 = serializeMetadata(metadata);
+ String metadataJson = KeyMetadataCodec.toJson(metadata);
VaultKeyValueOperations keyValue =
vaultTemplate.opsForKeyValue(secretsEngine,
VaultKeyValueOperationsSupport.KeyValueBackend.versioned());
@@ -299,9 +297,9 @@ public class HashicorpVaultKeyLifecycleManager implements
KeyLifecycleManager {
publicKeyData.put("algorithm", metadata.getAlgorithm());
keyValue.put(getKeyPath(keyId) + "/public", publicKeyData);
- // Store metadata separately
+ // Store metadata separately as JSON (see KeyMetadataCodec)
Map<String, Object> metadataData = new HashMap<>();
- metadataData.put("metadata", metadataBase64);
+ metadataData.put("metadata", metadataJson);
metadataData.put("keyId", keyId);
metadataData.put("algorithm", metadata.getAlgorithm());
keyValue.put(getKeyPath(keyId) + "/metadata", metadataData);
@@ -393,8 +391,10 @@ public class HashicorpVaultKeyLifecycleManager implements
KeyLifecycleManager {
return null;
}
- String metadataBase64 = (String) secretData.get("metadata");
- KeyMetadata metadata = deserializeMetadata(metadataBase64);
+ String storedMetadata = (String) secretData.get("metadata");
+ KeyMetadata metadata = KeyMetadataCodec.isJson(storedMetadata)
+ ? KeyMetadataCodec.fromJson(storedMetadata)
+ : deserializeMetadata(storedMetadata);
// Cache it
metadataCache.put(keyId, metadata);
@@ -551,18 +551,16 @@ public class HashicorpVaultKeyLifecycleManager implements
KeyLifecycleManager {
}
}
- private String serializeMetadata(KeyMetadata metadata) throws Exception {
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
- oos.writeObject(metadata);
- }
- return Base64.getEncoder().encodeToString(baos.toByteArray());
- }
-
+ /**
+ * Reads a legacy (pre-JSON) Base64-encoded, Java-serialized {@link
KeyMetadata}. The deserialization is constrained
+ * to the expected types via {@link KeyMetadataCodec#METADATA_FILTER}.
Retained for backward compatibility with
+ * metadata written by older versions; new metadata is stored as JSON.
+ */
private KeyMetadata deserializeMetadata(String base64) throws Exception {
byte[] data = Base64.getDecoder().decode(base64);
ByteArrayInputStream bais = new ByteArrayInputStream(data);
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
+ ois.setObjectInputFilter(KeyMetadataCodec.METADATA_FILTER);
return (KeyMetadata) ois.readObject();
}
}
diff --git
a/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadataCodec.java
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadataCodec.java
new file mode 100644
index 000000000000..309f2b03d5e7
--- /dev/null
+++
b/components/camel-pqc/src/main/java/org/apache/camel/component/pqc/lifecycle/KeyMetadataCodec.java
@@ -0,0 +1,144 @@
+/*
+ * 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.ObjectInputFilter;
+import java.time.Instant;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Shared helpers for persisting {@link KeyMetadata} as JSON instead of Java
serialization, and for safely reading
+ * values written by older versions that used Java serialization.
+ * <p>
+ * {@link AwsSecretsManagerKeyLifecycleManager} and {@link
HashicorpVaultKeyLifecycleManager} store key metadata as
+ * JSON, consistent with {@link FileBasedKeyLifecycleManager}. Values written
by older versions (a Base64-encoded,
+ * Java-serialized {@link KeyMetadata}) are still read for backward
compatibility, but the deserialization is
+ * constrained to the expected types through an {@link ObjectInputFilter}.
+ */
+final class KeyMetadataCodec {
+
+ private static final String METADATA_PATTERN
+ =
"maxdepth=20;java.lang.*;java.time.**;org.apache.camel.component.pqc.lifecycle.*;!*";
+
+ private static final String KEY_PAIR_PATTERN
+ =
"maxdepth=20;java.lang.*;java.util.*;java.time.**;java.security.**;javax.crypto.**;"
+ +
"org.apache.camel.component.pqc.lifecycle.*;org.bouncycastle.**;!*";
+
+ /**
+ * Allow-list filter for reading a legacy Java-serialized {@link
KeyMetadata}. Only the JDK types that make up a
+ * {@code KeyMetadata} (its {@link Instant} timestamps and {@link
KeyMetadata.KeyStatus} enum) and the metadata
+ * class itself are permitted; everything else is rejected.
+ */
+ static final ObjectInputFilter METADATA_FILTER =
ObjectInputFilter.Config.createFilter(METADATA_PATTERN);
+
+ /**
+ * Allow-list filter for reading a legacy Java-serialized {@link
java.security.KeyPair}. In addition to the metadata
+ * types it permits the JDK and Bouncy Castle key classes required to
reconstruct a key pair; everything else is
+ * rejected.
+ */
+ static final ObjectInputFilter KEY_PAIR_FILTER =
ObjectInputFilter.Config.createFilter(KEY_PAIR_PATTERN);
+
+ private static final ObjectMapper MAPPER
+ = new
ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
+
+ private KeyMetadataCodec() {
+ }
+
+ /**
+ * Whether the stored value is the JSON representation (new format) rather
than a Base64-encoded, Java-serialized
+ * value (legacy format).
+ */
+ static boolean isJson(String value) {
+ return value != null && value.stripLeading().startsWith("{");
+ }
+
+ /**
+ * Serializes the given metadata to its JSON representation.
+ */
+ static String toJson(KeyMetadata metadata) throws Exception {
+ return MAPPER.writeValueAsString(Data.from(metadata));
+ }
+
+ /**
+ * Parses metadata from its JSON representation.
+ */
+ static KeyMetadata fromJson(String json) throws Exception {
+ return MAPPER.readValue(json, Data.class).toKeyMetadata();
+ }
+
+ /**
+ * JSON structure mirroring {@link KeyMetadata}, matching the
representation already used by
+ * {@link FileBasedKeyLifecycleManager}.
+ */
+ static final class Data {
+ @JsonProperty("keyId")
+ String keyId;
+ @JsonProperty("algorithm")
+ String algorithm;
+ @JsonProperty("createdAt")
+ String createdAt;
+ @JsonProperty("lastUsedAt")
+ String lastUsedAt;
+ @JsonProperty("expiresAt")
+ String expiresAt;
+ @JsonProperty("nextRotationAt")
+ String nextRotationAt;
+ @JsonProperty("usageCount")
+ long usageCount;
+ @JsonProperty("status")
+ String status;
+ @JsonProperty("description")
+ String description;
+
+ Data() {
+ }
+
+ static Data from(KeyMetadata metadata) {
+ Data data = new Data();
+ data.keyId = metadata.getKeyId();
+ data.algorithm = metadata.getAlgorithm();
+ data.createdAt = metadata.getCreatedAt().toString();
+ data.lastUsedAt = metadata.getLastUsedAt() != null ?
metadata.getLastUsedAt().toString() : null;
+ data.expiresAt = metadata.getExpiresAt() != null ?
metadata.getExpiresAt().toString() : null;
+ data.nextRotationAt = metadata.getNextRotationAt() != null ?
metadata.getNextRotationAt().toString() : null;
+ data.usageCount = metadata.getUsageCount();
+ data.status = metadata.getStatus().name();
+ data.description = metadata.getDescription();
+ return data;
+ }
+
+ KeyMetadata toKeyMetadata() {
+ KeyMetadata metadata = new KeyMetadata(keyId, algorithm,
Instant.parse(createdAt));
+ if (lastUsedAt != null) {
+ metadata.setLastUsedAt(Instant.parse(lastUsedAt));
+ }
+ if (expiresAt != null) {
+ metadata.setExpiresAt(Instant.parse(expiresAt));
+ }
+ if (nextRotationAt != null) {
+ metadata.setNextRotationAt(Instant.parse(nextRotationAt));
+ }
+ metadata.setUsageCount(usageCount);
+ metadata.setStatus(KeyMetadata.KeyStatus.valueOf(status));
+ metadata.setDescription(description);
+ return metadata;
+ }
+ }
+}
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCKeyLifecycleTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCKeyLifecycleTest.java
index 842d887f3c82..c52302d4fb93 100644
---
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCKeyLifecycleTest.java
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/PQCKeyLifecycleTest.java
@@ -16,10 +16,13 @@
*/
package org.apache.camel.component.pqc;
+import java.io.ObjectOutputStream;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.Security;
import java.time.Duration;
+import java.time.Instant;
import java.util.Arrays;
import java.util.List;
@@ -346,6 +349,58 @@ public class PQCKeyLifecycleTest {
assertEquals(0, age);
}
+ @Test
+ void testLegacyKeyPairMigration() throws Exception {
+ // Seed a real PQC key pair via the manager
+ FileBasedKeyLifecycleManager seedManager = new
FileBasedKeyLifecycleManager(tempDir.toString());
+ KeyPair original = seedManager.generateKeyPair("DILITHIUM",
"seed-key", DilithiumParameterSpec.dilithium2);
+
+ // Write it out in the legacy Java-serialized ".key" format under a
fresh keyId
+ Path legacyKeyFile = tempDir.resolve("legacy-key.key");
+ try (ObjectOutputStream oos = new
ObjectOutputStream(Files.newOutputStream(legacyKeyFile))) {
+ oos.writeObject(original);
+ }
+
+ // A fresh manager must transparently migrate the legacy key (with the
deserialization filter applied)
+ keyManager = new FileBasedKeyLifecycleManager(tempDir.toString());
+ KeyPair migrated = keyManager.getKey("legacy-key");
+
+ assertNotNull(migrated);
+ assertArrayEquals(original.getPublic().getEncoded(),
migrated.getPublic().getEncoded());
+ assertArrayEquals(original.getPrivate().getEncoded(),
migrated.getPrivate().getEncoded());
+
+ // Legacy file is replaced by the PKCS#8/X.509 JSON format
+ assertFalse(Files.exists(legacyKeyFile));
+ assertTrue(Files.exists(tempDir.resolve("legacy-key.private.json")));
+ assertTrue(Files.exists(tempDir.resolve("legacy-key.public.json")));
+ }
+
+ @Test
+ void testLegacyMetadataMigration() throws Exception {
+ // Write a legacy Java-serialized ".metadata" file
+ KeyMetadata original = new KeyMetadata("legacy-meta", "DILITHIUM",
Instant.parse("2026-01-02T03:04:05Z"));
+ original.setStatus(KeyMetadata.KeyStatus.EXPIRED);
+ original.setUsageCount(11);
+ Path metadataFile = tempDir.resolve("legacy-meta.metadata");
+ try (ObjectOutputStream oos = new
ObjectOutputStream(Files.newOutputStream(metadataFile))) {
+ oos.writeObject(original);
+ }
+
+ // A fresh manager migrates legacy metadata to JSON (with the
deserialization filter applied)
+ keyManager = new FileBasedKeyLifecycleManager(tempDir.toString());
+ KeyMetadata migrated = keyManager.getKeyMetadata("legacy-meta");
+
+ assertNotNull(migrated);
+ assertEquals("legacy-meta", migrated.getKeyId());
+ assertEquals("DILITHIUM", migrated.getAlgorithm());
+ assertEquals(KeyMetadata.KeyStatus.EXPIRED, migrated.getStatus());
+ assertEquals(11, migrated.getUsageCount());
+
+ // The file is now stored as JSON
+ String content = Files.readString(metadataFile);
+ assertTrue(content.stripLeading().startsWith("{"));
+ }
+
@Test
void testMultipleKeyFormats() throws Exception {
keyManager = new FileBasedKeyLifecycleManager(tempDir.toString());
diff --git
a/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyMetadataCodecTest.java
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyMetadataCodecTest.java
new file mode 100644
index 000000000000..a939638e5ba5
--- /dev/null
+++
b/components/camel-pqc/src/test/java/org/apache/camel/component/pqc/lifecycle/KeyMetadataCodecTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InvalidClassException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.time.Instant;
+import java.util.ArrayList;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class KeyMetadataCodecTest {
+
+ @Test
+ void jsonRoundTripPreservesAllFields() throws Exception {
+ KeyMetadata original = new KeyMetadata("key-1", "DILITHIUM",
Instant.parse("2026-01-01T00:00:00Z"));
+ original.setLastUsedAt(Instant.parse("2026-02-01T10:15:30Z"));
+ original.setExpiresAt(Instant.parse("2027-01-01T00:00:00Z"));
+ original.setNextRotationAt(Instant.parse("2026-06-01T00:00:00Z"));
+ original.setUsageCount(42);
+ original.setStatus(KeyMetadata.KeyStatus.DEPRECATED);
+ original.setDescription("rotated");
+
+ String json = KeyMetadataCodec.toJson(original);
+ assertTrue(KeyMetadataCodec.isJson(json));
+
+ KeyMetadata restored = KeyMetadataCodec.fromJson(json);
+ assertEquals(original.getKeyId(), restored.getKeyId());
+ assertEquals(original.getAlgorithm(), restored.getAlgorithm());
+ assertEquals(original.getCreatedAt(), restored.getCreatedAt());
+ assertEquals(original.getLastUsedAt(), restored.getLastUsedAt());
+ assertEquals(original.getExpiresAt(), restored.getExpiresAt());
+ assertEquals(original.getNextRotationAt(),
restored.getNextRotationAt());
+ assertEquals(original.getUsageCount(), restored.getUsageCount());
+ assertEquals(original.getStatus(), restored.getStatus());
+ assertEquals(original.getDescription(), restored.getDescription());
+ }
+
+ @Test
+ void jsonRoundTripWithMinimalMetadata() throws Exception {
+ KeyMetadata original = new KeyMetadata("key-2", "FALCON");
+
+ KeyMetadata restored =
KeyMetadataCodec.fromJson(KeyMetadataCodec.toJson(original));
+ assertEquals("key-2", restored.getKeyId());
+ assertEquals("FALCON", restored.getAlgorithm());
+ assertEquals(KeyMetadata.KeyStatus.ACTIVE, restored.getStatus());
+ assertEquals(0, restored.getUsageCount());
+ }
+
+ @Test
+ void isJsonDistinguishesJsonFromLegacyBase64() {
+ assertTrue(KeyMetadataCodec.isJson("{\"keyId\":\"x\"}"));
+ assertTrue(KeyMetadataCodec.isJson(" \n {\"keyId\":\"x\"}"));
+ // Base64 of a Java-serialized object never starts with '{'
+ assertFalse(KeyMetadataCodec.isJson("rO0ABXNyAB1vcmcuYXBhY2hl"));
+ assertFalse(KeyMetadataCodec.isJson(null));
+ assertFalse(KeyMetadataCodec.isJson(""));
+ }
+
+ @Test
+ void metadataFilterAllowsLegacySerializedKeyMetadata() throws Exception {
+ KeyMetadata original = new KeyMetadata("legacy", "DILITHIUM",
Instant.parse("2026-03-03T03:03:03Z"));
+ original.setExpiresAt(Instant.parse("2027-03-03T03:03:03Z"));
+ original.setStatus(KeyMetadata.KeyStatus.REVOKED);
+ original.setUsageCount(7);
+
+ byte[] serialized = javaSerialize(original);
+
+ KeyMetadata restored;
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(serialized))) {
+ ois.setObjectInputFilter(KeyMetadataCodec.METADATA_FILTER);
+ restored = (KeyMetadata) ois.readObject();
+ }
+
+ assertEquals("legacy", restored.getKeyId());
+ assertEquals(KeyMetadata.KeyStatus.REVOKED, restored.getStatus());
+ assertEquals(original.getExpiresAt(), restored.getExpiresAt());
+ assertEquals(7, restored.getUsageCount());
+ }
+
+ @Test
+ void metadataFilterRejectsUnexpectedType() throws Exception {
+ ArrayList<String> notMetadata = new ArrayList<>();
+ notMetadata.add("payload");
+ byte[] serialized = javaSerialize(notMetadata);
+
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(serialized))) {
+ ois.setObjectInputFilter(KeyMetadataCodec.METADATA_FILTER);
+ assertThrows(InvalidClassException.class, ois::readObject);
+ }
+ }
+
+ private static byte[] javaSerialize(Object o) throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(o);
+ }
+ return baos.toByteArray();
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
index efec0a0beafd..fcf43e8de478 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
@@ -1625,3 +1625,14 @@ non-`Camel`-prefixed application headers and map them to
the corresponding
the `salesforce:` `to`. As defence-in-depth, strip inbound Camel-internal
headers
arriving from untrusted producers with `removeHeaders("CamelSalesforce*")` (or
the
broader `removeHeaders("Camel*")`) before the producer.
+
+=== camel-pqc
+
+The key lifecycle managers now store key metadata as JSON instead of using
Java serialization.
+`AwsSecretsManagerKeyLifecycleManager` and `HashicorpVaultKeyLifecycleManager`
previously stored the
+`KeyMetadata` as a Base64-encoded, Java-serialized value; they now store it as
JSON, consistent with
+`FileBasedKeyLifecycleManager`. Metadata written by previous versions is still
read transparently and
+is migrated to JSON the next time the metadata is updated.
+
+Because older versions cannot read the new JSON metadata, downgrading after
new key metadata has been
+written is not supported.
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
index fa6b65448c7b..f4dab43820a5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
@@ -2183,3 +2183,14 @@ non-`Camel`-prefixed application headers and map them to
the corresponding
the `salesforce:` `to`. As defence-in-depth, strip inbound Camel-internal
headers
arriving from untrusted producers with `removeHeaders("CamelSalesforce*")` (or
the
broader `removeHeaders("Camel*")`) before the producer.
+
+=== camel-pqc
+
+The key lifecycle managers now store key metadata as JSON instead of using
Java serialization.
+`AwsSecretsManagerKeyLifecycleManager` and `HashicorpVaultKeyLifecycleManager`
previously stored the
+`KeyMetadata` as a Base64-encoded, Java-serialized value; they now store it as
JSON, consistent with
+`FileBasedKeyLifecycleManager`. Metadata written by previous versions is still
read transparently and
+is migrated to JSON the next time the metadata is updated.
+
+Because older versions cannot read the new JSON metadata, downgrading after
new key metadata has been
+written is not supported.