diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/common/KeyStoreSerializationKeyManager.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/common/KeyStoreSerializationKeyManager.java
new file mode 100644
index 0000000000..71e86ae257
--- /dev/null
+++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/provider/common/KeyStoreSerializationKeyManager.java
@@ -0,0 +1,231 @@
+/*
+ * KeyStoreSerializationKeyManager.java
+ *
+ * This source file is part of the FoundationDB open source project
+ *
+ * Copyright 2015-2025 Apple Inc. and the FoundationDB project authors
+ *
+ * Licensed 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 com.apple.foundationdb.record.provider.common;
+
+import com.apple.foundationdb.annotation.API;
+import com.apple.foundationdb.record.RecordCoreArgumentException;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.util.List;
+import java.util.Random;
+
+
+/**
+ * A {@link SerializationKeyManager} that uses a {@link KeyStore}.
+ *
+ * Key numbers are mapped to and from key names, which are looked up in the store.
+ */
+@API(API.Status.EXPERIMENTAL)
+public class KeyStoreSerializationKeyManager implements SerializationKeyManager {
+ @Nonnull
+ private final KeyStore keyStore;
+ @Nonnull
+ private final KeyStore.ProtectionParameter keyEntryPassword;
+ @Nonnull
+ private final List keyEntryAliases;
+ private final int defaultKeyNumber;
+ @Nonnull
+ private final String cipherName;
+ @Nonnull
+ private final SecureRandom secureRandom;
+
+ protected KeyStoreSerializationKeyManager(@Nonnull KeyStore keyStore, @Nonnull KeyStore.ProtectionParameter keyEntryPassword,
+ @Nonnull List keyEntryAliases, int defaultKeyNumber,
+ @Nonnull String cipherName, @Nonnull SecureRandom secureRandom) {
+ this.keyStore = keyStore;
+ this.keyEntryPassword = keyEntryPassword;
+ this.keyEntryAliases = List.copyOf(keyEntryAliases);
+ this.defaultKeyNumber = defaultKeyNumber;
+ this.cipherName = cipherName;
+ this.secureRandom = secureRandom;
+ }
+
+ @Override
+ public int getSerializationKey() {
+ return defaultKeyNumber;
+ }
+
+ @Override
+ public Key getKey(int keyNumber) {
+ if (keyNumber < 0 || keyNumber >= keyEntryAliases.size()) {
+ throw new RecordSerializationException("key number out of range");
+ }
+ final String keyEntryAlias = keyEntryAliases.get(keyNumber);
+ final KeyStore.SecretKeyEntry entry;
+ try {
+ entry = (KeyStore.SecretKeyEntry)keyStore.getEntry(keyEntryAlias, keyEntryPassword);
+ } catch (GeneralSecurityException ex) {
+ throw new RecordSerializationException("cannot load key", ex);
+ }
+ return entry.getSecretKey();
+ }
+
+ @Override
+ public String getCipher(int keyNumber) {
+ return cipherName;
+ }
+
+ @Override
+ public Random getRandom(int keyNumber) {
+ return secureRandom;
+ }
+
+ @Nonnull
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ @Nullable
+ String keyStoreFileName;
+ @Nullable
+ String keyStorePassword;
+ @Nullable
+ String keyEntryPassword;
+ @Nullable
+ String defaultKeyEntryAlias;
+ @Nullable
+ List keyEntryAliases;
+ @Nonnull
+ String cipherName = CipherPool.DEFAULT_CIPHER;
+ @Nullable
+ SecureRandom secureRandom;
+
+ protected Builder() {
+ }
+
+ @Nullable
+ public String getKeyStoreFileName() {
+ return keyStoreFileName;
+ }
+
+ public void setKeyStoreFileName(@Nonnull String keyStoreFileName) {
+ this.keyStoreFileName = keyStoreFileName;
+ }
+
+ @Nullable
+ public String getKeyStorePassword() {
+ return keyStorePassword;
+ }
+
+ public void setKeyStorePassword(@Nonnull String keyStorePassword) {
+ this.keyStorePassword = keyStorePassword;
+ }
+
+ @Nullable
+ public String getKeyEntryPassword() {
+ return keyEntryPassword;
+ }
+
+ public void setKeyEntryPassword(@Nonnull String keyEntryPassword) {
+ this.keyEntryPassword = keyEntryPassword;
+ }
+
+ @Nullable
+ public String getDefaultKeyEntryAlias() {
+ return defaultKeyEntryAlias;
+ }
+
+ public void setDefaultKeyEntryAlias(@Nonnull String defaultKeyEntryAlias) {
+ this.defaultKeyEntryAlias = defaultKeyEntryAlias;
+ }
+
+ @Nullable
+ public List getKeyEntryAliases() {
+ return keyEntryAliases;
+ }
+
+ public void setKeyEntryAliases(@Nonnull List keyEntryAliases) {
+ this.keyEntryAliases = keyEntryAliases;
+ }
+
+ @Nonnull
+ public String getCipherName() {
+ return cipherName;
+ }
+
+ public void setCipherName(@Nonnull String cipherName) {
+ this.cipherName = cipherName;
+ }
+
+ @Nullable
+ public SecureRandom getSecureRandom() {
+ return secureRandom;
+ }
+
+ public void setSecureRandom(@Nonnull SecureRandom secureRandom) {
+ this.secureRandom = secureRandom;
+ }
+
+ @Nonnull
+ public KeyStoreSerializationKeyManager build() {
+ if (keyStoreFileName == null) {
+ throw new RecordCoreArgumentException("must specify key store file name");
+ }
+ if (keyStorePassword == null) {
+ keyStorePassword = "";
+ }
+ final KeyStore keyStore;
+ try {
+ keyStore = KeyStore.getInstance(new File(keyStoreFileName), keyStorePassword.toCharArray());
+ } catch (FileNotFoundException ex) {
+ throw new RecordCoreArgumentException("Key store not found", ex);
+ } catch (GeneralSecurityException | IOException ex) {
+ throw new RecordCoreArgumentException("Key store loading failed", ex);
+ }
+ if (keyEntryPassword == null) {
+ keyEntryPassword = keyStorePassword;
+ }
+ final KeyStore.ProtectionParameter keyEntryProtection = new KeyStore.PasswordProtection(keyEntryPassword.toCharArray());
+ if (keyEntryAliases == null && defaultKeyEntryAlias == null) {
+ throw new RecordCoreArgumentException("must specify key alias list or single default alias");
+ }
+ final int defaultKeyNumber;
+ if (keyEntryAliases == null) {
+ keyEntryAliases = List.of(defaultKeyEntryAlias);
+ defaultKeyNumber = 0;
+ } else if (defaultKeyEntryAlias == null) {
+ if (keyEntryAliases.isEmpty()) {
+ throw new RecordCoreArgumentException("need at least one key alias");
+ }
+ defaultKeyNumber = keyEntryAliases.size() - 1;
+ } else {
+ defaultKeyNumber = keyEntryAliases.indexOf(defaultKeyEntryAlias);
+ if (defaultKeyNumber < 0) {
+ throw new RecordCoreArgumentException("default key alias not in key alias list");
+ }
+ }
+ if (secureRandom == null) {
+ secureRandom = new SecureRandom();
+ }
+ return new KeyStoreSerializationKeyManager(keyStore, keyEntryProtection, keyEntryAliases, defaultKeyNumber,
+ cipherName, secureRandom);
+ }
+ }
+}
diff --git a/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/Options.java b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/Options.java
index 69a37404f7..a14b06e8fc 100644
--- a/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/Options.java
+++ b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/Options.java
@@ -25,6 +25,7 @@
import com.apple.foundationdb.relational.api.options.CollectionContract;
import com.apple.foundationdb.relational.api.options.OptionContract;
import com.apple.foundationdb.relational.api.options.OptionContractWithConversion;
+import com.apple.foundationdb.relational.api.options.OrderedCollectionContract;
import com.apple.foundationdb.relational.api.options.RangeContract;
import com.apple.foundationdb.relational.api.options.TypeContract;
import com.google.common.annotations.VisibleForTesting;
@@ -241,6 +242,11 @@ public enum Name {
*/
ENCRYPTION_KEY_ENTRY,
+ /**
+ * All the key store entries available as encryption keys.
+ */
+ ENCRYPTION_KEY_ENTRY_LIST,
+
/**
* The integrity key of the key store and the encryption key of the key entry.
*/
@@ -496,6 +502,7 @@ public static Properties toProperties(final Options options) {
prop = bytesToHex(((Continuation)entry.getValue()).serialize());
break;
case DISABLED_PLANNER_RULES:
+ case ENCRYPTION_KEY_ENTRY_LIST:
prop = String.join(",", (Collection)entry.getValue());
break;
default:
@@ -549,6 +556,7 @@ private static Map> makeContracts() {
data.put(Name.ENCRYPT_WHEN_SERIALIZING, List.of(TypeContract.booleanType()));
data.put(Name.ENCRYPTION_KEY_STORE, List.of(TypeContract.nullableStringType()));
data.put(Name.ENCRYPTION_KEY_ENTRY, List.of(TypeContract.nullableStringType()));
+ data.put(Name.ENCRYPTION_KEY_ENTRY_LIST, List.of(new OrderedCollectionContract<>(TypeContract.stringType())));
data.put(Name.ENCRYPTION_KEY_PASSWORD, List.of(TypeContract.nullableStringType()));
data.put(Name.COMPRESS_WHEN_SERIALIZING, List.of(TypeContract.booleanType()));
diff --git a/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/options/OrderedCollectionContract.java b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/options/OrderedCollectionContract.java
new file mode 100644
index 0000000000..9dad9ec32a
--- /dev/null
+++ b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/options/OrderedCollectionContract.java
@@ -0,0 +1,48 @@
+/*
+ * OrderedCollectionTypeContract.java
+ *
+ * This source file is part of the FoundationDB open source project
+ *
+ * Copyright 2025 Apple Inc. and the FoundationDB project authors
+ *
+ * Licensed 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 com.apple.foundationdb.relational.api.options;
+
+import com.apple.foundationdb.relational.api.Options;
+import com.apple.foundationdb.relational.api.exceptions.ErrorCode;
+
+import javax.annotation.Nonnull;
+import java.sql.SQLException;
+import java.util.List;
+
+/**
+ * Ordered ({@code List}) version of {@link CollectionContract}.
+ * @param the type parameter of the collection
+ */
+public class OrderedCollectionContract extends CollectionContract {
+ public OrderedCollectionContract(@Nonnull TypeContract elementContract) {
+ super(elementContract);
+ }
+
+ @Override
+ public void validate(final Options.Name name, final Object value) throws SQLException {
+ if (!(value instanceof List>)) {
+ throw new SQLException("Option " + name + " should be of a list type instead of " + value.getClass().getName(), ErrorCode.INVALID_PARAMETER.getErrorCode());
+ }
+ super.validate(name, value);
+ }
+
+ // No need to override fromString, as that already returns a list in value element order.
+}
diff --git a/fdb-relational-api/src/testFixtures/java/com/apple/foundationdb/relational/utils/OptionsTestHelper.java b/fdb-relational-api/src/testFixtures/java/com/apple/foundationdb/relational/utils/OptionsTestHelper.java
index fceed5ffc1..2442a7cfcf 100644
--- a/fdb-relational-api/src/testFixtures/java/com/apple/foundationdb/relational/utils/OptionsTestHelper.java
+++ b/fdb-relational-api/src/testFixtures/java/com/apple/foundationdb/relational/utils/OptionsTestHelper.java
@@ -62,6 +62,7 @@ public static Options nonDefaultOptions() throws SQLException {
builder = builder.withOption(Options.Name.ENCRYPT_WHEN_SERIALIZING, true);
builder = builder.withOption(Options.Name.ENCRYPTION_KEY_STORE, "secrets.ks");
builder = builder.withOption(Options.Name.ENCRYPTION_KEY_ENTRY, "mykey");
+ builder = builder.withOption(Options.Name.ENCRYPTION_KEY_ENTRY_LIST, List.of("mykey", "anotherkey"));
builder = builder.withOption(Options.Name.ENCRYPTION_KEY_PASSWORD, "mypass");
builder = builder.withOption(Options.Name.COMPRESS_WHEN_SERIALIZING, false);
Options options = builder.build();
diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/storage/StoreConfig.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/storage/StoreConfig.java
index ea7d446189..291db2671a 100644
--- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/storage/StoreConfig.java
+++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/storage/StoreConfig.java
@@ -24,7 +24,9 @@
import com.apple.foundationdb.record.RecordCoreException;
import com.apple.foundationdb.record.RecordMetaDataProvider;
import com.apple.foundationdb.record.metadata.MetaDataException;
+import com.apple.foundationdb.record.provider.common.KeyStoreSerializationKeyManager;
import com.apple.foundationdb.record.provider.common.RecordSerializer;
+import com.apple.foundationdb.record.provider.common.SerializationKeyManager;
import com.apple.foundationdb.record.provider.common.TransformedRecordSerializer;
import com.apple.foundationdb.record.provider.common.TransformedRecordSerializerJCE;
import com.apple.foundationdb.record.provider.foundationdb.FDBRecordStoreBase;
@@ -41,13 +43,10 @@
import com.apple.foundationdb.relational.recordlayer.util.ExceptionUtil;
import com.google.protobuf.Message;
-import javax.crypto.SecretKey;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import java.net.URI;
-import java.security.GeneralSecurityException;
-import java.security.KeyStore;
+import java.util.List;
import java.util.zip.Deflater;
@API(API.Status.EXPERIMENTAL)
@@ -127,11 +126,12 @@ public static StoreConfig create(RecordLayerConfig recordLayerConfig,
return new StoreConfig(recordLayerConfig, schemaName, schemaPath, metaDataProvider, serializer);
}
- static RecordSerializer serializerFromOptions(Options options) throws RelationalException {
+ @Nonnull
+ static RecordSerializer serializerFromOptions(@Nonnull Options options) throws RelationalException {
final boolean encrypted = options.getOption(Options.Name.ENCRYPT_WHEN_SERIALIZING);
final boolean compressed = options.getOption(Options.Name.COMPRESS_WHEN_SERIALIZING);
- final String keyStoreFile = options.getOption(Options.Name.ENCRYPTION_KEY_STORE);
- if (!encrypted && compressed && keyStoreFile == null) {
+ final SerializationKeyManager keyManager = keyManagerFromOptions(options);
+ if (!encrypted && compressed && keyManager == null) {
return DEFAULT_RELATIONAL_SERIALIZER;
}
final TransformedRecordSerializerJCE.Builder serializerBuilder = TransformedRecordSerializerJCE.newDefaultBuilder()
@@ -139,30 +139,38 @@ static RecordSerializer serializerFromOptions(Options options) throws R
.setCompressWhenSerializing(compressed)
.setCompressionLevel(Deflater.DEFAULT_COMPRESSION)
.setWriteValidationRatio(0.0);
- if (keyStoreFile != null) {
- final SecretKey key;
- try {
- final String keyEntryAlias = options.getOption(Options.Name.ENCRYPTION_KEY_ENTRY);
- if (keyEntryAlias == null) {
- throw new RelationalException("Key entry not specified", ErrorCode.UNSUPPORTED_OPERATION);
- }
- final String keyPassword = options.getOption(Options.Name.ENCRYPTION_KEY_PASSWORD);
- KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
- try (FileInputStream fis = new FileInputStream(keyStoreFile)) {
- keystore.load(fis, keyPassword.toCharArray());
- }
- KeyStore.ProtectionParameter protParam = new KeyStore.PasswordProtection(keyPassword.toCharArray());
- KeyStore.SecretKeyEntry entry = (KeyStore.SecretKeyEntry)keystore.getEntry(keyEntryAlias, protParam);
- key = entry.getSecretKey();
- } catch (FileNotFoundException ex) {
- throw new RelationalException("Key store not found", ErrorCode.UNSUPPORTED_OPERATION, ex);
- } catch (GeneralSecurityException | IOException ex) {
- throw new RelationalException("Key loading failed", ErrorCode.UNSUPPORTED_OPERATION, ex);
- }
- serializerBuilder.setEncryptionKey(key);
+ if (keyManager != null) {
+ serializerBuilder.setKeyManager(keyManager);
} else if (encrypted) {
throw new RelationalException("Key store not specified", ErrorCode.UNSUPPORTED_OPERATION);
}
return serializerBuilder.build();
}
+
+ @Nullable
+ static SerializationKeyManager keyManagerFromOptions(@Nonnull Options options) throws RelationalException {
+ final String keyStoreFileName = options.getOption(Options.Name.ENCRYPTION_KEY_STORE);
+ if (keyStoreFileName == null) {
+ return null;
+ }
+ final KeyStoreSerializationKeyManager.Builder builder = KeyStoreSerializationKeyManager.newBuilder();
+ builder.setKeyStoreFileName(keyStoreFileName);
+ final String defaultKeyEntryAlias = options.getOption(Options.Name.ENCRYPTION_KEY_ENTRY);
+ if (defaultKeyEntryAlias != null) {
+ builder.setDefaultKeyEntryAlias(defaultKeyEntryAlias);
+ }
+ final List keyEntryAliases = options.getOption(Options.Name.ENCRYPTION_KEY_ENTRY_LIST);
+ if (keyEntryAliases != null) {
+ builder.setKeyEntryAliases(keyEntryAliases);
+ }
+ final String keyStorePassword = options.getOption(Options.Name.ENCRYPTION_KEY_PASSWORD);
+ if (keyStorePassword != null) {
+ builder.setKeyStorePassword(keyStorePassword);
+ }
+ try {
+ return builder.build();
+ } catch (RecordCoreException ex) {
+ throw new RelationalException("problem with encryption options", ErrorCode.UNSUPPORTED_OPERATION, ex);
+ }
+ }
}
diff --git a/fdb-relational-grpc/src/main/java/com/apple/foundationdb/relational/jdbc/TypeConversion.java b/fdb-relational-grpc/src/main/java/com/apple/foundationdb/relational/jdbc/TypeConversion.java
index 5eff8e61bc..cf6f9d56b5 100644
--- a/fdb-relational-grpc/src/main/java/com/apple/foundationdb/relational/jdbc/TypeConversion.java
+++ b/fdb-relational-grpc/src/main/java/com/apple/foundationdb/relational/jdbc/TypeConversion.java
@@ -692,6 +692,11 @@ static com.apple.foundationdb.relational.jdbc.grpc.v1.Options.Builder toProtobuf
builder.setEncryptionKeyEntry((String)entry.getValue());
}
break;
+ case ENCRYPTION_KEY_ENTRY_LIST:
+ for (String rule : (List)entry.getValue()) {
+ builder.addEncryptionKeyEntryList(rule);
+ }
+ break;
case ENCRYPTION_KEY_PASSWORD:
if (Options.isNull(entry.getValue())) {
builder.clearEncryptionKeyPassword();
@@ -813,6 +818,9 @@ public static Options fromProtobuf(com.apple.foundationdb.relational.jdbc.grpc.v
if (protoOptions.hasEncryptionKeyEntry()) {
builder.withOption(Options.Name.ENCRYPTION_KEY_ENTRY, protoOptions.getEncryptionKeyEntry());
}
+ if (protoOptions.getEncryptionKeyEntryListCount() > 0) {
+ builder.withOption(Options.Name.ENCRYPTION_KEY_ENTRY_LIST, protoOptions.getEncryptionKeyEntryListList());
+ }
if (protoOptions.hasEncryptionKeyPassword()) {
builder.withOption(Options.Name.ENCRYPTION_KEY_PASSWORD, protoOptions.getEncryptionKeyPassword());
}
diff --git a/fdb-relational-grpc/src/main/proto/grpc/relational/jdbc/v1/jdbc.proto b/fdb-relational-grpc/src/main/proto/grpc/relational/jdbc/v1/jdbc.proto
index f91aa7fd6c..342c9b3104 100644
--- a/fdb-relational-grpc/src/main/proto/grpc/relational/jdbc/v1/jdbc.proto
+++ b/fdb-relational-grpc/src/main/proto/grpc/relational/jdbc/v1/jdbc.proto
@@ -152,6 +152,7 @@ message Options {
optional string encryption_key_entry = 29;
optional string encryption_key_password = 30;
optional bool compress_when_serializing = 31;
+ repeated string encryption_key_entry_list = 32;
}
// Request that is part of a transactional (stateful) exchange
diff --git a/yaml-tests/src/test/resources/serialization-options.yamsql b/yaml-tests/src/test/resources/serialization-options.yamsql
index 81030eae5b..ea696b05a9 100644
--- a/yaml-tests/src/test/resources/serialization-options.yamsql
+++ b/yaml-tests/src/test/resources/serialization-options.yamsql
@@ -151,4 +151,26 @@ test_block:
{S: 'uvw'},
{S: 'ghi'},
{S: 'rst'}]
+---
+test_block:
+ name: multiple-keys
+ preset: single_repetition_ordered
+ options:
+ supported_version: !current_version
+ connection_options:
+ ENCRYPTION_KEY_ENTRY_LIST: [key-1, key-2]
+ ENCRYPTION_KEY_ENTRY: key-2
+ tests:
+ -
+ - query: INSERT INTO t VALUES (7, 'jkl', null)
+ - count: 1
+ -
+ - query: SELECT * FROM t
+ - unorderedResult: [{ID: 1, S: 'abc', PAD: '________________________________'},
+ {ID: 2, S: 'def', PAD: '________________________________'},
+ {ID: 3, S: 'xyz', PAD: !null },
+ {ID: 4, S: 'uvw', PAD: !null },
+ {ID: 5, S: 'ghi', PAD: '********************************'},
+ {ID: 6, S: 'rst', PAD: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'},
+ {ID: 7, S: 'jkl', PAD: !null }]
...