diff --git a/build.gradle b/build.gradle index 418ad1573..b25beeefc 100644 --- a/build.gradle +++ b/build.gradle @@ -136,7 +136,8 @@ allprojects { importOrder 'tech.pegasys', 'java', '' trimTrailingWhitespace() endWithNewline() - licenseHeaderFile "${rootDir}/gradle/spotless.java.license" + licenseHeaderFile("${rootDir}/gradle/spotless.java.former.license").named("older.year").onlyIfContentMatches("^/\\*\\r?\\n.* Copyright \\d{4} ConsenSys AG\\.") + licenseHeaderFile("${rootDir}/gradle/spotless.java.license").named("current").onlyIfContentMatches("^(?!/\\*\\r?\\n \\*.*(ConsenSys AG)\\.)") } } @@ -430,6 +431,7 @@ distributions { exclude "**/project-licenses-for-check-license-task.json" } from("./slashing-protection/src/main/resources/migrations") { into "./migrations" } + from("./keys-postgres/src/main/resources/migrations") { into "./migrations" } } } } diff --git a/commandline/src/main/java/tech/pegasys/web3signer/commandline/PicoCliPostgresAwsKmsKekParameters.java b/commandline/src/main/java/tech/pegasys/web3signer/commandline/PicoCliPostgresAwsKmsKekParameters.java new file mode 100644 index 000000000..147eb434a --- /dev/null +++ b/commandline/src/main/java/tech/pegasys/web3signer/commandline/PicoCliPostgresAwsKmsKekParameters.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.commandline; + +import tech.pegasys.web3signer.common.config.AwsAuthenticationMode; +import tech.pegasys.web3signer.signing.config.PostgresAwsKmsKekParameters; + +import java.net.URI; +import java.util.Optional; + +import picocli.CommandLine.Option; + +/** + * Credentials used to call AWS KMS to unwrap a tenant's DEK, when bulk loading BLS keys from the + * postgres keystore. Deliberately separate from {@link PicoCliAwsSecretsManagerParameters} and + * {@link PicoCliAwsKmsParameters} - "unwrap N specific keys" and "list/sign against an entire + * vault" are different privilege scopes that may reasonably use different identities. + */ +public class PicoCliPostgresAwsKmsKekParameters implements PostgresAwsKmsKekParameters { + + public static final String POSTGRES_KEYSTORE_AWS_KMS_AUTH_MODE_OPTION = + "--postgres-keystore-aws-kms-auth-mode"; + public static final String POSTGRES_KEYSTORE_AWS_KMS_ACCESS_KEY_ID_OPTION = + "--postgres-keystore-aws-kms-access-key-id"; + public static final String POSTGRES_KEYSTORE_AWS_KMS_SECRET_ACCESS_KEY_OPTION = + "--postgres-keystore-aws-kms-secret-access-key"; + public static final String POSTGRES_KEYSTORE_AWS_KMS_ENDPOINT_OVERRIDE_OPTION = + "--postgres-keystore-aws-kms-endpoint-override"; + + @Option( + names = POSTGRES_KEYSTORE_AWS_KMS_AUTH_MODE_OPTION, + description = + "Authentication mode to use to call AWS KMS when unwrapping postgres keystore DEKs." + + " Valid Values: [${COMPLETION-CANDIDATES}] (Default: ${DEFAULT-VALUE})", + paramLabel = "") + private AwsAuthenticationMode authenticationMode = AwsAuthenticationMode.SPECIFIED; + + @Option( + names = POSTGRES_KEYSTORE_AWS_KMS_ACCESS_KEY_ID_OPTION, + description = + "AWS Access Key Id to authenticate to AWS KMS. Required for SPECIFIED authentication" + + " mode.", + paramLabel = "") + private String accessKeyId; + + @Option( + names = POSTGRES_KEYSTORE_AWS_KMS_SECRET_ACCESS_KEY_OPTION, + description = + "AWS Secret Access Key to authenticate to AWS KMS. Required for SPECIFIED authentication" + + " mode.", + paramLabel = "") + private String secretAccessKey; + + @Option( + names = POSTGRES_KEYSTORE_AWS_KMS_ENDPOINT_OVERRIDE_OPTION, + description = "Override the AWS KMS endpoint.", + paramLabel = "") + private Optional endpointOverride = Optional.empty(); + + @Override + public AwsAuthenticationMode getAuthenticationMode() { + return authenticationMode; + } + + @Override + public String getAccessKeyId() { + return accessKeyId; + } + + @Override + public String getSecretAccessKey() { + return secretAccessKey; + } + + @Override + public Optional getEndpointOverride() { + return endpointOverride; + } +} diff --git a/commandline/src/main/java/tech/pegasys/web3signer/commandline/PicoCliPostgresKeystoreParameters.java b/commandline/src/main/java/tech/pegasys/web3signer/commandline/PicoCliPostgresKeystoreParameters.java new file mode 100644 index 000000000..68ffae5ae --- /dev/null +++ b/commandline/src/main/java/tech/pegasys/web3signer/commandline/PicoCliPostgresKeystoreParameters.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.commandline; + +import tech.pegasys.web3signer.signing.config.PostgresKeystoreParameters; + +import java.nio.file.Path; +import java.time.Duration; + +import picocli.CommandLine.Option; + +public class PicoCliPostgresKeystoreParameters implements PostgresKeystoreParameters { + + public static final String POSTGRES_KEYSTORE_ENABLED_OPTION = "--postgres-keystore-enabled"; + public static final String POSTGRES_KEYSTORE_DB_URL_OPTION = "--postgres-keystore-db-url"; + public static final String POSTGRES_KEYSTORE_DB_USERNAME_OPTION = + "--postgres-keystore-db-username"; + public static final String POSTGRES_KEYSTORE_DB_PASSWORD_OPTION = + "--postgres-keystore-db-password"; + public static final String POSTGRES_KEYSTORE_DB_POOL_CONFIG_FILE_OPTION = + "--postgres-keystore-db-pool-configuration-file"; + public static final String POSTGRES_KEYSTORE_DEK_CACHE_TTL_MINUTES_OPTION = + "--postgres-keystore-dek-cache-ttl-minutes"; + public static final String POSTGRES_KEYSTORE_DECRYPTION_PARALLELISM_OPTION = + "--postgres-keystore-decryption-parallelism"; + public static final String POSTGRES_KEYSTORE_DB_HEALTH_CHECK_TIMEOUT_OPTION = + "--postgres-keystore-db-health-check-timeout-milliseconds"; + + @Option( + names = POSTGRES_KEYSTORE_ENABLED_OPTION, + description = + "Set to true to enable bulk loading of BLS keys from a PostgreSQL database." + + " (Default: ${DEFAULT-VALUE})", + paramLabel = "", + arity = "1") + private boolean enabled = false; + + @Option( + names = POSTGRES_KEYSTORE_DB_URL_OPTION, + description = "The jdbc url to use to connect to the postgres keystore database", + paramLabel = "") + private String dbUrl; + + @Option( + names = POSTGRES_KEYSTORE_DB_USERNAME_OPTION, + description = "The username to use when connecting to the postgres keystore database", + paramLabel = "") + private String dbUsername; + + @Option( + names = POSTGRES_KEYSTORE_DB_PASSWORD_OPTION, + description = "The password to use when connecting to the postgres keystore database", + paramLabel = "") + private String dbPassword; + + @Option( + names = POSTGRES_KEYSTORE_DB_POOL_CONFIG_FILE_OPTION, + description = "Optional configuration file for Hikari database connection pool.", + paramLabel = "") + private Path dbPoolConfigurationFile; + + @Option( + names = POSTGRES_KEYSTORE_DEK_CACHE_TTL_MINUTES_OPTION, + description = + "Minutes to cache a tenant's resolved DEK before re-resolving it via the vault." + + " (Default: ${DEFAULT-VALUE})", + paramLabel = "") + private long dekCacheTtlMinutes = 15; + + @Option( + names = POSTGRES_KEYSTORE_DECRYPTION_PARALLELISM_OPTION, + description = + "Number of threads used to decrypt keys in parallel. (Default: ${DEFAULT-VALUE})", + paramLabel = "", + hidden = true) + private int decryptionParallelism = 8; + + @Option( + names = POSTGRES_KEYSTORE_DB_HEALTH_CHECK_TIMEOUT_OPTION, + description = + "Number of milliseconds after which the postgres keystore database health check will be" + + " failed (Default: ${DEFAULT-VALUE})", + paramLabel = "") + private long dbHealthCheckTimeoutMilliseconds = 3000; + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public String getDbUrl() { + return dbUrl; + } + + @Override + public String getDbUsername() { + return dbUsername; + } + + @Override + public String getDbPassword() { + return dbPassword; + } + + @Override + public Path getDbPoolConfigurationFile() { + return dbPoolConfigurationFile; + } + + @Override + public Duration getDekCacheTtl() { + return Duration.ofMinutes(dekCacheTtlMinutes); + } + + @Override + public int getDecryptionParallelism() { + return decryptionParallelism; + } + + @Override + public long getDbHealthCheckTimeoutMilliseconds() { + return dbHealthCheckTimeoutMilliseconds; + } +} diff --git a/commandline/src/main/java/tech/pegasys/web3signer/commandline/subcommands/Eth2SubCommand.java b/commandline/src/main/java/tech/pegasys/web3signer/commandline/subcommands/Eth2SubCommand.java index 8c6a91aaf..06e363e5c 100644 --- a/commandline/src/main/java/tech/pegasys/web3signer/commandline/subcommands/Eth2SubCommand.java +++ b/commandline/src/main/java/tech/pegasys/web3signer/commandline/subcommands/Eth2SubCommand.java @@ -28,6 +28,8 @@ import tech.pegasys.web3signer.commandline.PicoCliAwsSecretsManagerParameters; import tech.pegasys.web3signer.commandline.PicoCliAzureKeyVaultParameters; import tech.pegasys.web3signer.commandline.PicoCliGcpSecretManagerParameters; +import tech.pegasys.web3signer.commandline.PicoCliPostgresAwsKmsKekParameters; +import tech.pegasys.web3signer.commandline.PicoCliPostgresKeystoreParameters; import tech.pegasys.web3signer.commandline.PicoCliSlashingProtectionParameters; import tech.pegasys.web3signer.commandline.VersionProvider; import tech.pegasys.web3signer.commandline.config.KeyManagerApiParameters; @@ -170,6 +172,8 @@ private static class NetworkCliCompletionCandidates extends ArrayList { @Mixin private PicoKeystoresParameters keystoreParameters; @Mixin private PicoCliAwsSecretsManagerParameters awsSecretsManagerParameters; @Mixin private PicoCliGcpSecretManagerParameters gcpSecretManagerParameters; + @Mixin private PicoCliPostgresKeystoreParameters postgresKeystoreParameters; + @Mixin private PicoCliPostgresAwsKmsKekParameters postgresAwsKmsKekParameters; @Mixin private KeyManagerApiParameters keyManagerApiParameters; @Mixin private PicoCommitBoostApiParameters commitBoostApiParameters; private tech.pegasys.teku.spec.Spec eth2Spec; @@ -189,6 +193,8 @@ public Runner createRunner() { keystoreParameters, awsSecretsManagerParameters, gcpSecretManagerParameters, + postgresKeystoreParameters, + postgresAwsKmsKekParameters, eth2Spec, keyManagerApiParameters, signingExtEnabled, @@ -273,9 +279,45 @@ protected void validateArgs() { validateKeystoreParameters(keystoreParameters); validateAwsSecretsManageParameters(); validateGcpSecretManagerParameters(); + validatePostgresKeystoreParameters(); commitBoostApiParameters.validateParameters(); } + private void validatePostgresKeystoreParameters() { + if (postgresKeystoreParameters.isEnabled()) { + final List missingFields = missingPostgresKeystoreFields(); + if (!missingFields.isEmpty()) { + final String errorMsg = + String.format( + "%s=true, but the following parameters were missing [%s].", + PicoCliPostgresKeystoreParameters.POSTGRES_KEYSTORE_ENABLED_OPTION, + String.join(", ", missingFields)); + throw new ParameterException(commandSpec.commandLine(), errorMsg); + } + validatePositiveValue( + postgresKeystoreParameters.getDecryptionParallelism(), + "Postgres keystore decryption parallelism"); + } + } + + private List missingPostgresKeystoreFields() { + final List missingFields = Lists.newArrayList(); + if (postgresKeystoreParameters.getDbUrl() == null) { + missingFields.add(PicoCliPostgresKeystoreParameters.POSTGRES_KEYSTORE_DB_URL_OPTION); + } + if (postgresAwsKmsKekParameters.getAuthenticationMode() == AwsAuthenticationMode.SPECIFIED) { + if (postgresAwsKmsKekParameters.getAccessKeyId() == null) { + missingFields.add( + PicoCliPostgresAwsKmsKekParameters.POSTGRES_KEYSTORE_AWS_KMS_ACCESS_KEY_ID_OPTION); + } + if (postgresAwsKmsKekParameters.getSecretAccessKey() == null) { + missingFields.add( + PicoCliPostgresAwsKmsKekParameters.POSTGRES_KEYSTORE_AWS_KMS_SECRET_ACCESS_KEY_OPTION); + } + } + return missingFields; + } + private void validateGcpSecretManagerParameters() { if (gcpSecretManagerParameters.isEnabled()) { final List specifiedAuthModeMissingFields = diff --git a/commandline/src/test/java/tech/pegasys/web3signer/commandline/CommandlineParserTest.java b/commandline/src/test/java/tech/pegasys/web3signer/commandline/CommandlineParserTest.java index b7dccf66e..3c2563558 100644 --- a/commandline/src/test/java/tech/pegasys/web3signer/commandline/CommandlineParserTest.java +++ b/commandline/src/test/java/tech/pegasys/web3signer/commandline/CommandlineParserTest.java @@ -452,6 +452,48 @@ void gcpSpecifiedProjectIdFailsToParseWithoutRequiredParameters() { "Error parsing parameters: --gcp-secrets-enabled=true, but the following parameters were missing [--gcp-project-id]."); } + @Test + void postgresKeystoreEnabledFailsToParseWithoutRequiredParameters() { + String cmdline = validBaseCommandOptions(); + cmdline += + String.format( + "eth2 --slashing-protection-enabled=false %s=true", + PicoCliPostgresKeystoreParameters.POSTGRES_KEYSTORE_ENABLED_OPTION); + + parser.registerSubCommands(new MockEth2SubCommand()); + final int result = parser.parseCommandLine(cmdline.split(" ")); + + assertThat(result).isNotZero(); + assertThat(commandError.toString()) + .contains( + String.format( + "Error parsing parameters: %s=true, but the following parameters were missing" + + " [%s, %s, %s].", + PicoCliPostgresKeystoreParameters.POSTGRES_KEYSTORE_ENABLED_OPTION, + PicoCliPostgresKeystoreParameters.POSTGRES_KEYSTORE_DB_URL_OPTION, + PicoCliPostgresAwsKmsKekParameters.POSTGRES_KEYSTORE_AWS_KMS_ACCESS_KEY_ID_OPTION, + PicoCliPostgresAwsKmsKekParameters + .POSTGRES_KEYSTORE_AWS_KMS_SECRET_ACCESS_KEY_OPTION)); + } + + @Test + void postgresKeystoreEnabledParsesSuccessfullyWithRequiredParameters() { + String cmdline = validBaseCommandOptions(); + cmdline += + String.format( + "eth2 --slashing-protection-enabled=false %s=true %s=jdbc:postgresql://localhost/keys" + + " %s=key %s=secret", + PicoCliPostgresKeystoreParameters.POSTGRES_KEYSTORE_ENABLED_OPTION, + PicoCliPostgresKeystoreParameters.POSTGRES_KEYSTORE_DB_URL_OPTION, + PicoCliPostgresAwsKmsKekParameters.POSTGRES_KEYSTORE_AWS_KMS_ACCESS_KEY_ID_OPTION, + PicoCliPostgresAwsKmsKekParameters.POSTGRES_KEYSTORE_AWS_KMS_SECRET_ACCESS_KEY_OPTION); + + parser.registerSubCommands(new MockEth2SubCommand()); + final int result = parser.parseCommandLine(cmdline.split(" ")); + + assertThat(result).isZero(); + } + @Test void awsSpecifiedAuthModeFailsToParseWithoutRequiredParameters() { String cmdline = validBaseCommandOptions(); diff --git a/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java b/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java index a482d2550..9d5bbbe36 100644 --- a/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java +++ b/core/src/main/java/tech/pegasys/web3signer/core/Eth2Runner.java @@ -17,11 +17,13 @@ import static tech.pegasys.web3signer.core.config.HealthCheckNames.KEYS_CHECK_CONFIG_FILE_LOADING; import static tech.pegasys.web3signer.core.config.HealthCheckNames.KEYS_CHECK_GCP_BULK_LOADING; import static tech.pegasys.web3signer.core.config.HealthCheckNames.KEYS_CHECK_KEYSTORE_BULK_LOADING; +import static tech.pegasys.web3signer.core.config.HealthCheckNames.KEYS_CHECK_POSTGRES_BULK_LOADING; import static tech.pegasys.web3signer.core.config.HealthCheckNames.SLASHING_PROTECTION_DB; import tech.pegasys.teku.bls.BLSKeyPair; import tech.pegasys.teku.bls.BLSSecretKey; import tech.pegasys.teku.spec.Spec; +import tech.pegasys.web3signer.common.Web3SignerMetricCategory; import tech.pegasys.web3signer.core.config.BaseConfig; import tech.pegasys.web3signer.core.config.KeyManagerApiConfig; import tech.pegasys.web3signer.core.routes.PublicKeysListRoute; @@ -44,12 +46,15 @@ import tech.pegasys.web3signer.signing.bulkloading.BlsAwsBulkLoader; import tech.pegasys.web3signer.signing.bulkloading.BlsGcpBulkLoader; import tech.pegasys.web3signer.signing.bulkloading.BlsKeystoreBulkLoader; +import tech.pegasys.web3signer.signing.bulkloading.BlsPostgresBulkLoader; import tech.pegasys.web3signer.signing.config.AwsVaultParameters; import tech.pegasys.web3signer.signing.config.AzureKeyVaultFactory; import tech.pegasys.web3signer.signing.config.AzureKeyVaultParameters; import tech.pegasys.web3signer.signing.config.DefaultArtifactSignerProvider; import tech.pegasys.web3signer.signing.config.GcpSecretManagerParameters; import tech.pegasys.web3signer.signing.config.KeystoresParameters; +import tech.pegasys.web3signer.signing.config.PostgresAwsKmsKekParameters; +import tech.pegasys.web3signer.signing.config.PostgresKeystoreParameters; import tech.pegasys.web3signer.signing.config.SignerLoader; import tech.pegasys.web3signer.signing.config.metadata.AbstractArtifactSignerFactory; import tech.pegasys.web3signer.signing.config.metadata.BlsArtifactSignerFactory; @@ -81,6 +86,9 @@ import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; import org.hyperledger.besu.plugin.services.MetricsSystem; +import org.hyperledger.besu.plugin.services.metrics.Counter; +import org.hyperledger.besu.plugin.services.metrics.LabelledMetric; +import org.hyperledger.besu.plugin.services.metrics.OperationTimer; public class Eth2Runner extends Runner { private static final Logger LOG = LogManager.getLogger(); @@ -89,6 +97,8 @@ public class Eth2Runner extends Runner { private final AzureKeyVaultParameters azureKeyVaultParameters; private final AwsVaultParameters awsVaultParameters; private final GcpSecretManagerParameters gcpSecretManagerParameters; + private final PostgresKeystoreParameters postgresKeystoreParameters; + private final PostgresAwsKmsKekParameters postgresAwsKmsKekParameters; private final SlashingProtectionParameters slashingProtectionParameters; private final boolean pruningEnabled; private final KeystoresParameters keystoresParameters; @@ -104,6 +114,8 @@ public Eth2Runner( final KeystoresParameters keystoresParameters, final AwsVaultParameters awsVaultParameters, final GcpSecretManagerParameters gcpSecretManagerParameters, + final PostgresKeystoreParameters postgresKeystoreParameters, + final PostgresAwsKmsKekParameters postgresAwsKmsKekParameters, final Spec eth2Spec, final KeyManagerApiConfig keyManagerApiConfig, final boolean signingExtEnabled, @@ -118,6 +130,8 @@ public Eth2Runner( this.keyManagerApiConfig = keyManagerApiConfig; this.awsVaultParameters = awsVaultParameters; this.gcpSecretManagerParameters = gcpSecretManagerParameters; + this.postgresKeystoreParameters = postgresKeystoreParameters; + this.postgresAwsKmsKekParameters = postgresAwsKmsKekParameters; this.signingExtEnabled = signingExtEnabled; this.commitBoostApiParameters = commitBoostApiParameters; } @@ -164,23 +178,58 @@ protected List createArtifactSignerProvider( // Register for cleanup ONCE registerClose(signerLoader); + // create postgres bulk loader ONCE at startup, so its DEK cache persists across reloads + final Optional blsPostgresBulkLoader = createPostgresBulkLoader(); + blsPostgresBulkLoader.ifPresent(this::registerClose); + return List.of( new DefaultArtifactSignerProvider( - createArtifactSignerSupplier(signerLoader, metricsSystem), + createArtifactSignerSupplier(signerLoader, blsPostgresBulkLoader, metricsSystem), slashingProtectionContext., Set>>map( PostLoadingValidatorsProcessor::new), Optional.of(commitBoostApiParameters))); } + private Optional createPostgresBulkLoader() { + if (!postgresKeystoreParameters.isEnabled()) { + return Optional.empty(); + } + try { + return Optional.of( + new BlsPostgresBulkLoader(postgresKeystoreParameters, postgresAwsKmsKekParameters)); + } catch (final IllegalStateException e) { + throw new InitializationException(e.getMessage(), e); + } + } + private Supplier> createArtifactSignerSupplier( - final SignerLoader signerLoader, final MetricsSystem metricsSystem) { + final SignerLoader signerLoader, + final Optional blsPostgresBulkLoader, + final MetricsSystem metricsSystem) { + final LabelledMetric postgresBulkLoadTimer = + metricsSystem.createLabelledTimer( + Web3SignerMetricCategory.SIGNING, + "postgres_bulk_load_time", + "Time taken for a full Postgres keystore bulk key load", + "phase"); + final Counter postgresKekVaultCallsCounter = + metricsSystem.createCounter( + Web3SignerMetricCategory.SIGNING, + "postgres_kek_vault_calls_total", + "Number of KEK vault calls made during Postgres keystore bulk key loads"); + return () -> { try (final AzureKeyVaultFactory azureKeyVaultFactory = new AzureKeyVaultFactory()) { // load keys from key config files MappedResults configFileResults = loadSignersFromKeyConfigFiles(signerLoader, azureKeyVaultFactory, metricsSystem); // bulkload keys - MappedResults bulkLoadResults = bulkLoadSigners(azureKeyVaultFactory); + MappedResults bulkLoadResults = + bulkLoadSigners( + azureKeyVaultFactory, + blsPostgresBulkLoader, + postgresBulkLoadTimer, + postgresKekVaultCallsCounter); return MappedResults.merge(configFileResults, bulkLoadResults); } @@ -217,7 +266,10 @@ private MappedResults loadSignersFromKeyConfigFiles( } private MappedResults bulkLoadSigners( - final AzureKeyVaultFactory azureKeyVaultFactory) { + final AzureKeyVaultFactory azureKeyVaultFactory, + final Optional blsPostgresBulkLoader, + final LabelledMetric postgresBulkLoadTimer, + final Counter postgresKekVaultCallsCounter) { MappedResults results = MappedResults.newSetInstance(); if (azureKeyVaultParameters.isAzureKeyVaultEnabled()) { LOG.info("Bulk loading keys from Azure key vault ... "); @@ -280,6 +332,21 @@ private MappedResults bulkLoadSigners( results = MappedResults.merge(results, gcpResult); } + if (blsPostgresBulkLoader.isPresent()) { + LOG.info("Bulk loading keys from PostgreSQL ... "); + final MappedResults postgresResult; + try (final var _ = postgresBulkLoadTimer.labels("full").startTimer()) { + postgresResult = blsPostgresBulkLoader.get().load(); + } + postgresKekVaultCallsCounter.inc(blsPostgresBulkLoader.get().getLastVaultCallCount()); + LOG.info( + "Keys loaded from PostgreSQL: [{}], with error count: [{}]", + postgresResult.getValues().size(), + postgresResult.getErrorCount()); + registerSignerLoadingHealthCheck(KEYS_CHECK_POSTGRES_BULK_LOADING, postgresResult); + results = MappedResults.merge(results, postgresResult); + } + return results; } diff --git a/core/src/main/java/tech/pegasys/web3signer/core/config/HealthCheckNames.java b/core/src/main/java/tech/pegasys/web3signer/core/config/HealthCheckNames.java index 64e3861f3..29773e075 100644 --- a/core/src/main/java/tech/pegasys/web3signer/core/config/HealthCheckNames.java +++ b/core/src/main/java/tech/pegasys/web3signer/core/config/HealthCheckNames.java @@ -22,4 +22,5 @@ public interface HealthCheckNames { String KEYS_CHECK_KEYSTORE_BULK_LOADING = "keys-check/keystores-bulk-loading"; String KEYS_CHECK_CONFIG_FILE_LOADING = "keys-check/config-files-loading"; String KEYS_CHECK_V3_KEYSTORES_BULK_LOADING = "keys-check/v3-keystores-bulk-loading"; + String KEYS_CHECK_POSTGRES_BULK_LOADING = "keys-check/postgres-bulk-loading"; } diff --git a/designs/postgres-bulk-key-loading.md b/designs/postgres-bulk-key-loading.md new file mode 100644 index 000000000..010c3e986 --- /dev/null +++ b/designs/postgres-bulk-key-loading.md @@ -0,0 +1,244 @@ +# PostgreSQL-Backed Bulk Key Loading (eth2/BLS) + +## 1. Problem and goal + +Web3Signer's `eth2` mode loads BLS validator signing keys from Azure Key Vault secrets or AWS Secrets Manager secrets, one HTTP call per secret. At 20,000 keys this takes roughly 8 minutes at startup and on every `/reload`. `eth1`/secp256k1 mode is unaffected — it uses Azure Keys/AWS KMS purely for remote signing, so key material never leaves the vault and there is nothing to bulk-fetch. + +This feature replaces the per-key vault calls with envelope encryption: BLS private keys are pre-encrypted by an external provisioning process and stored in PostgreSQL. Web3Signer performs one streaming bulk `SELECT`, exactly one vault call per *tenant* (not per key) to unwrap that tenant's Data Encryption Key (DEK), then decrypts every key belonging to that tenant locally and in parallel. Target: full reload of 20,000 keys across 5 tenants in under 1 second, with exactly 5 vault calls. + +This document covers the loading (read) side only. Provisioning (writing rows) is a separate, external process — section 5 below is the contract that process must satisfy for Web3Signer to be able to decrypt what it writes. + +## 2. Architecture + +Two-tier envelope encryption, one KEK (Key Encrypting Key) per tenant: + +- **KEK**: lives in a vault — Azure Key Vault, AWS KMS, or HashiCorp Vault's Transit secrets engine. It never leaves the vault; every use is a remote unwrap/decrypt API call. +- **DEK (Data Encryption Key)**: AES-256, one per tenant, generated at provisioning time. Stored in PostgreSQL wrapped by the tenant's KEK. Unwrapped once per tenant per load cycle (cached for 15 minutes), not once per key. +- **BLS private key**: encrypted at rest with the tenant's DEK using AES-256-GCM, stored per validator key row. + +Loading flow: +1. One streaming JDBC query (`fetchSize=1000`, forward-only, read-only) returns every `(tenant, key)` row, ordered by tenant. +2. Rows are grouped by tenant as they stream. For each new tenant encountered, its DEK is resolved via a single vault call (or served from a 15-minute in-memory cache). +3. Each tenant's rows are decrypted in parallel across a small worker pool (sized to available CPU cores, capped at 8). +4. Decrypted keys are wrapped as `BlsArtifactSigner`s and swapped into Web3Signer's signer registry atomically — the existing `/reload` endpoint and signer-provider machinery need no changes to support this. + +### New Gradle module: `keys-postgres` + +Sits between `keystorage` and `signing` in the dependency graph: + +``` +common → keystorage → keys-postgres → signing → slashing-protection → core → commandline → app +``` + +It depends only on `common` and `keystorage` (reusing existing Azure/AWS/HashiCorp SDK client-building code), plus HikariCP, the PostgreSQL JDBC driver, and Caffeine. It has no dependency on Teku/BLS types — it returns a generic `DecryptedBlsKey(keyIdentifier, rawSecretKeyBytes)` DTO. The one class that does need Teku types, `BlsPostgresBulkLoader`, lives in the `signing` module (which now depends on `keys-postgres`) and mirrors the existing `BlsAwsBulkLoader`. + +Flyway migration SQL is packaged into the distribution but **never run automatically** — Web3Signer only checks a `database_version` table at startup and fails fast with a clear message if it doesn't match, exactly like the existing `slashing-protection` module's convention. Running migrations against a production database is an operator responsibility. + +## 3. Schema + +```sql +CREATE TABLE tenants ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + vault_type VARCHAR(32) NOT NULL, -- 'AZURE' | 'AWS_KMS' | 'HASHICORP' + kek_key_id VARCHAR(1024) NOT NULL, -- vault-specific KEK reference (key name/version, ARN, or Transit key name) + encrypted_dek BYTEA NOT NULL, -- DEK wrapped by the tenant's KEK; 12-byte IV || ciphertext || 16-byte GCM tag + dek_version INTEGER NOT NULL DEFAULT 1, -- bumped by provisioning whenever the DEK is rotated + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE TABLE bls_signing_keys ( + id BIGSERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id), + key_identifier VARCHAR(256) NOT NULL, -- BLS public key hex; also used as AAD + encrypted_bls_key BYTEA NOT NULL, -- 12-byte IV || ciphertext || 16-byte GCM tag + dek_version INTEGER NOT NULL, -- version of the tenant DEK this row was encrypted under + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + UNIQUE (tenant_id, key_identifier) +); +CREATE INDEX idx_bls_signing_keys_tenant_id ON bls_signing_keys (tenant_id); + +CREATE TABLE database_version (id INTEGER PRIMARY KEY, version INTEGER NOT NULL); +INSERT INTO database_version (id, version) VALUES (1, 1); +``` + +Notable design choices: + +1. **`encrypted_dek` lives once per tenant, not once per key row.** Storing it per-row would duplicate the same wrapped-DEK blob across every one of a tenant's (potentially 20,000) keys, and risks the per-row copy drifting out of sync with the tenant's canonical value during a rotation. A single canonical value per tenant matches the "one vault call per tenant" model exactly. +2. **`dek_version` exists on both tables.** Key rotation tooling is out of scope for this feature, but reserving the column now avoids a breaking schema change later, and — more immediately — lets the read side's DEK cache key on `(tenant, dek_version)` instead of just `tenant`, so a rotation is automatically treated as a cache miss rather than silently serving a stale DEK for up to 15 minutes. +3. **`vault_type` is a plain string**, not a Postgres enum type, so a future additional backend doesn't require an `ALTER TYPE` migration. + +## 4. Cryptography + +- **Algorithm**: AES-256-GCM, no padding. On-disk/on-wire layout for both `encrypted_dek` and `encrypted_bls_key`: **`IV (12 bytes, random) || ciphertext || GCM tag (16 bytes)`**. +- **IV/nonce**: a fresh random 12-byte IV per row. For `n` random 96-bit IVs sharing one key, birthday-bound collision probability is `≈ n²/2^97`; at `n = 20,000` that is `≈ 2.5×10⁻²¹` — around 30 orders of magnitude below any conventional audit threshold, and NIST SP 800-38D's own cap for safe random-IV reuse under one key is roughly 2³² invocations. No counter-based or derived-nonce scheme is required. +- **Additional Authenticated Data (AAD) is mandatory.** Every `encrypted_bls_key` ciphertext must be bound, via `Cipher.updateAAD(...)`, to the identity of its own row: `(tenant_id, key_identifier, dek_version)`. Every `encrypted_dek` ciphertext must be bound to `(tenant_id, dek_version)`. **This is the single most important contract in this document** — the read side always verifies AAD and fails closed (`AEADBadTagException`) if it doesn't match, which is what stops a ciphertext being silently decrypted under the wrong row's identity if it's ever copied or moved between rows in the database. If the provisioning side does not set matching AAD, every decrypt will fail. +- **Vault-backed KEKs only.** There is no environment-variable or local-passphrase KEK option — every tenant's DEK-unwrap is a genuine remote call to an auditable, access-controlled vault API (Azure Key Vault, AWS KMS, or HashiCorp Vault Transit). No raw KEK material is ever constructed or held by Web3Signer. +- **DEK caching**: resolved DEKs are cached in memory for 15 minutes, keyed by `(tenant, dek_version)`, and the underlying bytes are wiped (zeroed) once no in-flight decrypt operation is using them. +- **Memory hygiene**: decrypted BLS private key bytes are wiped (`Arrays.fill(..., 0)`) immediately after being consumed into the BLS key object. + +### AAD encoding + +Both the write side (provisioning) and the read side (Web3Signer) must construct byte-for-byte identical AAD. The encoding is **length-prefixed fields**, not delimited/concatenated strings, to avoid ambiguity (e.g. `tenant="A"`, `key="BC"` must never collide with `tenant="AB"`, `key="C"`): + +``` +AAD = LEN(field_1) || field_1_bytes || LEN(field_2) || field_2_bytes || ... || dek_version (4 bytes, big-endian) + +Where each LEN(field) is a 4-byte big-endian unsigned integer giving the length of field_bytes, +and each field's *_bytes is its UTF-8 encoding (for tenant_id / key_identifier, which are text). +``` + +- **AAD for a `bls_signing_keys` row**: `LEN(tenant_id) || tenant_id (UTF-8) || LEN(key_identifier) || key_identifier (UTF-8) || dek_version (4-byte BE int)` +- **AAD for a `tenants.encrypted_dek` value**: `LEN(tenant_id) || tenant_id (UTF-8) || dek_version (4-byte BE int)` + +`tenant_id` here is the tenant's `name` (a stable, human-assigned string), not the numeric surrogate `id` — this keeps the AAD independent of database-internal identifiers. + +## 5. Provisioning-side contract (for whoever builds the write side) + +Web3Signer never writes to these tables. Whatever process provisions tenants and keys must produce data conforming exactly to the schema and cryptographic contract above. This section is written to be usable regardless of what language that process is implemented in. + +### 5.1 Per-vault-type notes for producing `encrypted_dek` + +- **AWS KMS**: call `Encrypt` (or `GenerateDataKey` if generating a fresh DEK at the same time) against the tenant's KMS key, passing `EncryptionContext = {"tenant_id": }` if using KMS's own AAD-equivalent. Store the returned ciphertext blob directly in `encrypted_dek` — it is not the `IV||ciphertext||tag` format described above (that layout only applies to values *we* encrypt with our own AES-GCM code, i.e. the DEK-wrapping step done locally, not via a vault's own wrap API). If instead using a vault's raw `Encrypt` API as the "local AES-GCM" step is not applicable, the value stored is whatever the vault's decrypt call expects as input — for KMS that's the `Encrypt` API's ciphertext blob. +- **Azure Key Vault**: call `CryptographyClient.encrypt`/`wrapKey` against the tenant's Key Vault key. Store the returned ciphertext/wrapped-key bytes verbatim. +- **HashiCorp Vault Transit**: call `POST {vaultAddr}/v1/{mount}/encrypt/{tenantKeyName}` with the DEK as the plaintext body. Vault's response `data.ciphertext` is a text token of the form `vault:v1:` — store the **UTF-8 bytes of that string verbatim** in `encrypted_dek`, not a decoded/binary form. + +`kek_key_id` on the `tenants` row must hold whatever reference the corresponding `KekResolver` needs to call the unwrap operation again later: an Azure key name/version, a full AWS KMS key ARN, or a HashiCorp Transit key name. + +### 5.2 Example `INSERT` statements + +The ciphertext/AAD computation must happen in application code before the `INSERT` — Postgres's `pgcrypto` extension does not support GCM mode, so this cannot be done in raw SQL. + +```sql +INSERT INTO tenants (name, vault_type, kek_key_id, encrypted_dek, dek_version) +VALUES (:tenant_name, :vault_type, :kek_key_id, :wrapped_dek_bytes, :dek_version); + +INSERT INTO bls_signing_keys (tenant_id, key_identifier, encrypted_bls_key, dek_version) +VALUES (:tenant_id, :bls_pubkey_hex, :iv_ciphertext_tag_bytes, :dek_version); +``` + +### 5.3 Worked encrypt examples + +The AAD-building step is shown separately from the cipher call in each example below — building it inline is the most likely place for a subtle, hard-to-diagnose mismatch (field order, length-prefix encoding, or string encoding differences between languages). + +**Java** (identical primitives to what Web3Signer's read side uses): + +```java +static byte[] buildRowAad(String tenantId, String keyIdentifier, int dekVersion) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(out); + byte[] tenantBytes = tenantId.getBytes(StandardCharsets.UTF_8); + byte[] keyBytes = keyIdentifier.getBytes(StandardCharsets.UTF_8); + dos.writeInt(tenantBytes.length); dos.write(tenantBytes); + dos.writeInt(keyBytes.length); dos.write(keyBytes); + dos.writeInt(dekVersion); + return out.toByteArray(); +} + +static byte[] encryptBlsKey(byte[] dek, byte[] plaintext, String tenantId, String keyIdentifier, int dekVersion) + throws GeneralSecurityException { + byte[] iv = new byte[12]; + SecureRandom.getInstanceStrong().nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(dek, "AES"), new GCMParameterSpec(128, iv)); + cipher.updateAAD(buildRowAad(tenantId, keyIdentifier, dekVersion)); + byte[] ciphertextAndTag = cipher.doFinal(plaintext); // GCM appends the 16-byte tag automatically + ByteBuffer buf = ByteBuffer.allocate(iv.length + ciphertextAndTag.length); + buf.put(iv).put(ciphertextAndTag); + return buf.array(); +} +``` + +**Python** (`cryptography` library — `AESGCM` concatenates ciphertext and tag for you; only the IV needs manual prepending): + +```python +import struct +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +import os + +def build_row_aad(tenant_id: str, key_identifier: str, dek_version: int) -> bytes: + tenant_bytes = tenant_id.encode("utf-8") + key_bytes = key_identifier.encode("utf-8") + return ( + struct.pack(">I", len(tenant_bytes)) + tenant_bytes + + struct.pack(">I", len(key_bytes)) + key_bytes + + struct.pack(">I", dek_version) + ) + +def encrypt_bls_key(dek: bytes, plaintext: bytes, tenant_id: str, key_identifier: str, dek_version: int) -> bytes: + iv = os.urandom(12) + aad = build_row_aad(tenant_id, key_identifier, dek_version) + ciphertext_and_tag = AESGCM(dek).encrypt(iv, plaintext, aad) + return iv + ciphertext_and_tag +``` + +**Node.js** (`crypto` module — GCM ciphertext and tag are returned separately and must be concatenated manually, in the same order as the other two languages): + +```javascript +const crypto = require('crypto'); + +function buildRowAad(tenantId, keyIdentifier, dekVersion) { + const tenantBytes = Buffer.from(tenantId, 'utf8'); + const keyBytes = Buffer.from(keyIdentifier, 'utf8'); + const tenantLen = Buffer.alloc(4); tenantLen.writeUInt32BE(tenantBytes.length); + const keyLen = Buffer.alloc(4); keyLen.writeUInt32BE(keyBytes.length); + const version = Buffer.alloc(4); version.writeUInt32BE(dekVersion); + return Buffer.concat([tenantLen, tenantBytes, keyLen, keyBytes, version]); +} + +function encryptBlsKey(dek, plaintext, tenantId, keyIdentifier, dekVersion) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv); + cipher.setAAD(buildRowAad(tenantId, keyIdentifier, dekVersion)); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, ciphertext, tag]); +} +``` + +The same pattern (only the AAD fields change — `(tenant_id, dek_version)` instead of `(tenant_id, key_identifier, dek_version)`) applies to locally wrapping a DEK, if a vault's own wrap API is not used for that step. + +### 5.4 Verifying provisioned data before relying on it + +Web3Signer's own acceptance tests seed their fixtures using the exact same encrypt-side code the production decrypt path verifies against (a small `encrypt(...)` counterpart to the internal `AesGcmKeyCipher`/`AadCodec` classes) — this guarantees the tests validate the real contract rather than a self-consistent-but-wrong approximation of it. Anyone building a provisioning tool is encouraged to do the same: write one row using the pseudocode above, then confirm Web3Signer can load it (start it against a test database with `--postgres-keystore-enabled=true` and check `/api/v1/eth2/publicKeys`) before provisioning at scale. + +### 5.5 Key rotation and lifecycle operations + +None of this is implemented by Web3Signer — it's the provisioning side's responsibility end-to-end. It's documented here because the read side's behavior (what it caches, what it re-checks on reload, what it fails closed on) constrains how these operations must be sequenced to be safe. + +**DEK rotation (generating brand-new DEK bytes for a tenant) is the expensive operation**, since every one of that tenant's `bls_signing_keys` rows is encrypted under the DEK and must be re-encrypted. Safe procedure, all inside a single database transaction: + +1. Generate a new DEK (version `N+1`) and wrap it with the tenant's KEK. +2. Re-encrypt every one of the tenant's `bls_signing_keys` rows under the new DEK: new random IV per row, AAD rebuilt with `dek_version = N+1`, and update both `encrypted_bls_key` and `dek_version` in the same `UPDATE` statement per row — a row whose ciphertext was updated but whose `dek_version` column still says `N` (or vice versa) will fail every future decrypt with an AAD mismatch. +3. Only once every row is confirmed re-encrypted, update `tenants.encrypted_dek` and `tenants.dek_version` to `N+1` — this is the "publish" step. Do this in the same transaction as step 2, not a separate one. +4. Commit. PostgreSQL's per-statement snapshot under the default `READ COMMITTED` isolation means a concurrent `PostgresBulkKeyLoader.loadAll()` query sees either entirely the pre-rotation state or entirely the post-rotation state, never a mix — the read side needs no locking or awareness of an in-progress rotation as long as the rotation is one commit. +5. No action is needed on the read side to pick this up: the DEK cache is keyed on `(tenant, dek_version)`, so the version bump is automatically a cache miss on the next load/reload, triggering fresh KEK resolution. Trigger a `POST /reload` (or wait for the next scheduled one) to make the rotation take effect; there's no push-based invalidation. +6. Once the rotation is confirmed stable, the old DEK bytes and old wrapped-DEK ciphertext can be discarded — Web3Signer never retains a superseded version once its cache entry is evicted or replaced. + +**KEK rotation (rotating the vault-side key itself) is comparatively cheap**, and — importantly — does **not** require touching `bls_signing_keys` or bumping `dek_version` at all, since the DEK's own bytes don't change, only what wraps them: + +- **AWS KMS**: if using KMS's built-in automatic key rotation (new backing material under the same key ID/ARN), nothing needs to change in the `tenants` row at all — KMS transparently decrypts ciphertext wrapped under prior key material for the same key ID. +- **Azure Key Vault**: key rotation creates a new key *version*. Re-wrap the existing DEK bytes under the new version, then update `tenants.kek_key_id` (to the new `/`) and `tenants.encrypted_dek` together, in one transaction/statement. +- **HashiCorp Vault Transit**: similar to Azure — Transit key rotation creates a new key version; re-wrap (`transit/encrypt`) under the new version and update `tenants.encrypted_dek` (the `vault:vN:...` token's version prefix changes accordingly; `kek_key_id`, i.e. the Transit key *name*, is typically unchanged). +- In all cases, update the `tenants` row (new `kek_key_id`/`encrypted_dek`) *before* revoking the old KEK version's decrypt permission in the vault — otherwise a read still resolving against the stale row could fail transiently. +- Because `dek_version` doesn't change, a running Web3Signer instance won't notice a KEK rotation until its cached DEK entry naturally expires (15-minute TTL) and it re-resolves — at which point it reads the tenant row fresh, unwraps with the new KEK, and gets back the identical DEK bytes. This is intentionally transparent; trigger a manual `/reload` only if the cutover needs to happen sooner than the TTL. + +**Adding a new key** to an existing tenant: encrypt it under that tenant's *current* DEK/`dek_version`, `INSERT` the row, and trigger a reload (or wait for the next scheduled one/restart). No vault call is needed for this specific key beyond what's already cached for the tenant. + +**Removing a key**: `DELETE` the row from `bls_signing_keys` and trigger a reload — `DefaultArtifactSignerProvider.load()` rebuilds its signer map from scratch on every load rather than merging into the previous one, so a deleted row is guaranteed to disappear from the active signer set on the next reload, not just fail to be added. Two caveats worth knowing: +- This schema has no soft-delete column (e.g. an `enabled`/`revoked_at` flag) today — hard deletion is the only supported mechanism. A future migration could add one if an audit trail of revoked keys is wanted, with the loader's query adding a `WHERE` filter; not implemented as of this writing. +- Removal only takes effect on the next reload/restart, not immediately. If a key needs to stop signing sooner than that, note that the existing Key Manager API's dynamic key removal endpoint only applies to keys with a *mutable* origin (`BlsArtifactSigner.isReadOnlyKey()` returns `true` for every origin except `FILE_KEYSTORE`) — Postgres-loaded keys are read-only through that API, same as Azure/AWS/GCP-loaded keys today, so a reload (or restart) is the only way to revoke a Postgres-loaded key at runtime. + +## 6. CLI + +New option group on the existing `eth2` subcommand (alongside the existing Azure/AWS/GCP option groups — this is not a new subcommand): + +- `--postgres-keystore-enabled`, `--postgres-keystore-db-url`, `--postgres-keystore-db-username`, `--postgres-keystore-db-password`, `--postgres-keystore-db-pool-configuration-file`, `--postgres-keystore-dek-cache-ttl-minutes` (default 15), `--postgres-keystore-decryption-parallelism` (hidden; default `min(8, available CPU cores)`), `--postgres-keystore-db-health-check-timeout-milliseconds`. +- Separate, dedicated credential option groups per KEK backend (`--postgres-keystore-azure-*`, `--postgres-keystore-aws-kms-*`, `--postgres-keystore-hashicorp-*`) — intentionally not shared with the existing Azure/AWS bulk-secret-scan credential options, since "unwrap N specific keys" and "list an entire vault" are different privilege scopes that may reasonably use different identities. + +## 7. Operational notes + +- **Thread count auto-scales to available cores** (capped at 8), not a hardcoded literal — on a 1-vCPU host or a CPU-limited container, running more CPU-bound decrypt threads than cores buys no parallelism and only adds context-switch overhead, competing with the rest of the application for that single core. +- **No new reload mechanism.** The existing `/reload` endpoint already re-runs the full signer-loading pipeline atomically; wiring this loader into that existing pipeline is sufficient to get correct reload behavior, including the "unaffected tenants incur zero additional vault calls within the 15-minute cache window" property. +- **Migrations are never run automatically.** Operators must apply `V00001__initial.sql` (and any future migrations) themselves before enabling `--postgres-keystore-enabled`; Web3Signer only verifies the applied version matches what it expects and refuses to start otherwise. diff --git a/gradle/spotless.java.former.license b/gradle/spotless.java.former.license new file mode 100644 index 000000000..c894c92fb --- /dev/null +++ b/gradle/spotless.java.former.license @@ -0,0 +1,12 @@ +/* + * Copyright $YEAR ConsenSys AG. + * + * 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. + */ diff --git a/gradle/spotless.java.license b/gradle/spotless.java.license index c894c92fb..975f0722b 100644 --- a/gradle/spotless.java.license +++ b/gradle/spotless.java.license @@ -1,5 +1,5 @@ /* - * Copyright $YEAR ConsenSys AG. + * Copyright $YEAR Consensys Software Inc. * * 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 diff --git a/keys-postgres/build.gradle b/keys-postgres/build.gradle new file mode 100644 index 000000000..395bf5e7b --- /dev/null +++ b/keys-postgres/build.gradle @@ -0,0 +1,71 @@ +/* + * Copyright 2026 ConsenSys AG. + * + * 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. + */ + +apply plugin: 'java-library' +apply plugin: 'java-test-fixtures' + +jar { + archiveBaseName = 'web3signer-keys-postgres' + manifest { + attributes( + 'Specification-Title': archiveBaseName, + 'Specification-Version': project.version, + 'Implementation-Title': archiveBaseName, + 'Implementation-Version': calculateVersion() + ) + } +} + +dependencies { + implementation project(':common') + implementation project(':keystorage') + implementation 'com.zaxxer:HikariCP' + implementation 'org.postgresql:postgresql' + implementation 'com.github.ben-manes.caffeine:caffeine:3.2.3' + implementation 'software.amazon.awssdk:auth' + implementation 'software.amazon.awssdk:kms' + implementation 'org.apache.commons:commons-lang3' + implementation 'org.apache.logging.log4j:log4j-api' + implementation 'io.consensys.tuweni:tuweni-bytes' + implementation 'com.google.guava:guava' + + runtimeOnly 'software.amazon.awssdk:sts' + runtimeOnly 'org.apache.logging.log4j:log4j-core' + runtimeOnly 'org.apache.logging.log4j:log4j-slf4j2-impl' + + // Test dependencies + testImplementation 'de.neuland-bfi:assertj-logging-log4j' + testImplementation 'org.apache.logging.log4j:log4j-api' + testImplementation 'org.apache.logging.log4j:log4j-core' + testImplementation 'io.zonky.test:embedded-postgres' + testImplementation 'org.assertj:assertj-core' + testImplementation 'org.awaitility:awaitility' + testImplementation 'org.flywaydb:flyway-core' + testImplementation 'org.flywaydb:flyway-database-postgresql' + testImplementation 'org.mockito:mockito-junit-jupiter' + testImplementation enforcedPlatform('io.zonky.test.postgres:embedded-postgres-binaries-bom') + testImplementation sourceSets.testFixtures.output + + // JUnit test dependencies + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testImplementation 'org.junit.jupiter:junit-jupiter-params' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Test fixtures dependencies + testFixturesImplementation 'io.zonky.test:embedded-postgres' + testFixturesImplementation 'org.apache.logging.log4j:log4j-api' + testFixturesImplementation 'org.flywaydb:flyway-core' + testFixturesImplementation 'org.flywaydb:flyway-database-postgresql' + testFixturesImplementation enforcedPlatform('io.zonky.test.postgres:embedded-postgres-binaries-bom') +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/DecryptedBlsKey.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/DecryptedBlsKey.java new file mode 100644 index 000000000..cf0de66cc --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/DecryptedBlsKey.java @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres; + +import org.apache.tuweni.bytes.Bytes; + +/** + * A decrypted BLS private key read from the postgres keystore. Deliberately free of any BLS/Teku + * type so this module has no dependency on the {@code signing} module - the {@code signing} + * module's {@code BlsPostgresBulkLoader} maps this to a {@code BlsArtifactSigner}. + */ +public record DecryptedBlsKey(String keyIdentifier, Bytes rawSecretKeyBytes) {} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresBulkKeyLoader.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresBulkKeyLoader.java new file mode 100644 index 000000000..4511e6b8c --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresBulkKeyLoader.java @@ -0,0 +1,285 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres; + +import tech.pegasys.web3signer.keystorage.common.MappedResults; +import tech.pegasys.web3signer.keystorage.postgres.crypto.AadCodec; +import tech.pegasys.web3signer.keystorage.postgres.crypto.AesGcmKeyCipher; +import tech.pegasys.web3signer.keystorage.postgres.crypto.TenantDek; +import tech.pegasys.web3signer.keystorage.postgres.crypto.TenantDekCache; +import tech.pegasys.web3signer.keystorage.postgres.kek.KekResolver; + +import java.io.Closeable; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tuweni.bytes.Bytes; + +/** + * Bulk-loads and decrypts every BLS key from the postgres keystore in a single streaming pass, + * making exactly one vault call per distinct tenant encountered (via the long-lived {@link + * TenantDekCache} shared across calls to {@link #loadAll()}). + * + *

Long-lived by design: construct once and reuse across startup and every {@code /reload} so the + * DEK cache actually has a chance to serve cache hits across reload cycles - re-creating this class + * per load would defeat the cache entirely. + */ +public final class PostgresBulkKeyLoader implements Closeable { + + private static final Logger LOG = LogManager.getLogger(); + + private static final String QUERY = + "SELECT t.id AS tenant_id, t.name AS tenant_name, t.vault_type, t.kek_key_id, " + + "t.encrypted_dek, t.dek_version AS tenant_dek_version, " + + "k.key_identifier, k.encrypted_bls_key, k.dek_version AS key_dek_version " + + "FROM bls_signing_keys k JOIN tenants t ON t.id = k.tenant_id ORDER BY t.id"; + + private final DataSource dataSource; + private final Map resolversByVaultType; + private final TenantDekCache dekCache; + private final int decryptionParallelism; + + private volatile int lastVaultCallCount; + + public PostgresBulkKeyLoader( + final DataSource dataSource, + final Map resolversByVaultType, + final Duration dekCacheTtl, + final int decryptionParallelism) { + this.dataSource = dataSource; + this.resolversByVaultType = Map.copyOf(resolversByVaultType); + this.dekCache = new TenantDekCache(dekCacheTtl); + this.decryptionParallelism = + Math.clamp(decryptionParallelism, 1, Runtime.getRuntime().availableProcessors()); + } + + /** The number of KEK vault calls made during the most recent {@link #loadAll()} invocation. */ + public int getLastVaultCallCount() { + return lastVaultCallCount; + } + + public MappedResults loadAll() { + final Set results = ConcurrentHashMap.newKeySet(); + final AtomicInteger errorCount = new AtomicInteger(); + final AtomicInteger vaultCalls = new AtomicInteger(); + final ThreadLocal cipherThreadLocal = + ThreadLocal.withInitial(AesGcmKeyCipher::new); + final ExecutorService decryptExecutor = newDecryptExecutor(); + + try (final Connection connection = dataSource.getConnection()) { + connection.setAutoCommit(false); + try (final PreparedStatement statement = + connection.prepareStatement( + QUERY, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { + statement.setFetchSize(1000); + try (final ResultSet resultSet = statement.executeQuery()) { + processRows( + resultSet, decryptExecutor, cipherThreadLocal, results, errorCount, vaultCalls); + } + } + connection.commit(); + } catch (final SQLException e) { + LOG.error("Unexpected error during Postgres bulk key scan", e); + errorCount.incrementAndGet(); + } finally { + shutdownGracefully(decryptExecutor); + } + + this.lastVaultCallCount = vaultCalls.get(); + return MappedResults.newInstance(results, errorCount.intValue()); + } + + private void processRows( + final ResultSet resultSet, + final ExecutorService decryptExecutor, + final ThreadLocal cipherThreadLocal, + final Set results, + final AtomicInteger errorCount, + final AtomicInteger vaultCalls) + throws SQLException { + final List> pendingDecrypts = new ArrayList<>(); + Integer currentTenantId = null; + TenantRecord currentTenant = null; + TenantDek currentDek = null; + + while (resultSet.next()) { + final int tenantId = resultSet.getInt("tenant_id"); + if (currentTenantId == null || tenantId != currentTenantId) { + currentTenantId = tenantId; + currentTenant = + new TenantRecord( + tenantId, + resultSet.getString("tenant_name"), + resultSet.getString("vault_type"), + resultSet.getString("kek_key_id"), + Bytes.of(resultSet.getBytes("encrypted_dek")), + resultSet.getInt("tenant_dek_version")); + currentDek = tryResolveDek(currentTenant, vaultCalls); + } + + final String keyIdentifier = resultSet.getString("key_identifier"); + final int keyDekVersion = resultSet.getInt("key_dek_version"); + final byte[] encryptedBlsKey = resultSet.getBytes("encrypted_bls_key"); + + if (currentDek == null) { + errorCount.incrementAndGet(); + continue; + } + if (keyDekVersion != currentTenant.dekVersion()) { + errorCount.incrementAndGet(); + LOG.warn( + "Key '{}' for tenant '{}' was encrypted under DEK version [{}] but the tenant's" + + " current DEK version is [{}] - skipping; re-provisioning required", + keyIdentifier, + currentTenant.name(), + keyDekVersion, + currentTenant.dekVersion()); + continue; + } + + final TenantRecord tenant = currentTenant; + final TenantDek dekForRow = currentDek; + pendingDecrypts.add( + decryptExecutor.submit( + () -> + decryptRow( + tenant, + keyIdentifier, + encryptedBlsKey, + dekForRow, + cipherThreadLocal, + results, + errorCount))); + } + + awaitAll(pendingDecrypts, errorCount); + } + + private TenantDek tryResolveDek(final TenantRecord tenant, final AtomicInteger vaultCalls) { + final KekResolver resolver = resolversByVaultType.get(tenant.vaultType()); + if (resolver == null) { + LOG.warn( + "No KekResolver configured for vault type '{}' (tenant '{}')", + tenant.vaultType(), + tenant.name()); + return null; + } + try { + return dekCache.getOrLoad( + tenant.name(), + tenant.dekVersion(), + () -> { + vaultCalls.incrementAndGet(); + return resolver.unwrapDek(tenant); + }); + } catch (final RuntimeException e) { + LOG.warn( + "Failed to resolve DEK for tenant '{}': {}", tenant.name(), e.getClass().getSimpleName()); + return null; + } + } + + private void decryptRow( + final TenantRecord tenant, + final String keyIdentifier, + final byte[] encryptedBlsKey, + final TenantDek dek, + final ThreadLocal cipherThreadLocal, + final Set results, + final AtomicInteger errorCount) { + final byte[] aad = AadCodec.forRow(tenant.name(), keyIdentifier, tenant.dekVersion()); + try (final TenantDek.Lease lease = dek.acquireForRead()) { + final Bytes plaintext = + Bytes.of(cipherThreadLocal.get().decrypt(lease.keyBytes(), encryptedBlsKey, aad)); + results.add(new DecryptedBlsKey(keyIdentifier, plaintext)); + } catch (final GeneralSecurityException | IllegalStateException e) { + errorCount.incrementAndGet(); + LOG.warn( + "Failed to decrypt BLS key for tenant '{}', key '{}': {}", + tenant.name(), + keyIdentifier, + e.getClass().getSimpleName()); + } + } + + private void awaitAll(final List> futures, final AtomicInteger errorCount) { + for (final Future future : futures) { + try { + future.get(); + } catch (final InterruptedException _) { + Thread.currentThread().interrupt(); + errorCount.incrementAndGet(); + } catch (final ExecutionException e) { + errorCount.incrementAndGet(); + LOG.warn( + "Unexpected error while decrypting a key: {}", + e.getCause() != null + ? e.getCause().getClass().getSimpleName() + : e.getClass().getSimpleName()); + } + } + } + + private ExecutorService newDecryptExecutor() { + return Executors.newFixedThreadPool( + decryptionParallelism, + runnable -> { + final Thread thread = new Thread(runnable, "postgres-keystore-decrypt"); + thread.setDaemon(true); + return thread; + }); + } + + private void shutdownGracefully(final ExecutorService executorService) { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (final InterruptedException _) { + Thread.currentThread().interrupt(); + executorService.shutdownNow(); + } + } + + @Override + public void close() { + dekCache.close(); + if (dataSource instanceof final Closeable closeableDataSource) { + try { + closeableDataSource.close(); + } catch (final IOException e) { + LOG.warn("Error closing Postgres keystore datasource", e); + } + } + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresConnectionFactory.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresConnectionFactory.java new file mode 100644 index 000000000..1d9658ac6 --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresConnectionFactory.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Properties; +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +/** + * Builds the HikariCP-pooled, read-only datasource used to bulk-read keys. Returns the generic + * {@link DataSource} type (rather than {@link HikariDataSource}) so that modules consuming this + * factory (e.g. {@code signing}) never need HikariCP on their own compile classpath. + */ +public final class PostgresConnectionFactory { + + private static final String PG_SOCKET_TIMEOUT_PARAM = "socketTimeout"; + private static final long DEFAULT_PG_SOCKET_TIMEOUT_SECONDS = Duration.ofMinutes(5).toSeconds(); + + private PostgresConnectionFactory() {} + + public static DataSource createDataSource( + final String jdbcUrl, + final String username, + final String password, + final Path poolConfigurationFile) { + final Properties properties = loadProperties(poolConfigurationFile); + final String timeoutKey = "dataSource." + PG_SOCKET_TIMEOUT_PARAM; + if (!properties.containsKey(timeoutKey)) { + properties.put(timeoutKey, String.valueOf(DEFAULT_PG_SOCKET_TIMEOUT_SECONDS)); + } + + final HikariConfig config = new HikariConfig(properties); + config.setJdbcUrl(jdbcUrl); + if (username != null && !username.isEmpty()) { + config.setUsername(username); + } + if (password != null && !password.isEmpty()) { + config.setPassword(password); + } + // this datasource is only ever used for the bulk read-only key scan + config.setReadOnly(true); + + return new HikariDataSource(config); + } + + private static Properties loadProperties(final Path configurationFile) { + final Properties properties = new Properties(); + if (configurationFile != null) { + try (final FileInputStream inputStream = new FileInputStream(configurationFile.toFile())) { + properties.load(inputStream); + } catch (final IOException e) { + throw new UncheckedIOException( + "Unable to read Postgres keystore pool configuration file: " + configurationFile, e); + } + } + return properties; + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresKeystoreVersionChecker.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresKeystoreVersionChecker.java new file mode 100644 index 000000000..aa0e3a2a0 --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/PostgresKeystoreVersionChecker.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import javax.sql.DataSource; + +/** + * Verifies the applied schema version of the postgres keystore database matches what this version + * of Web3Signer expects. Migrations are packaged with the distribution but are never run + * automatically - operators are expected to apply them out-of-band before enabling this feature, + * mirroring the slashing-protection module's convention. + */ +public final class PostgresKeystoreVersionChecker { + + public static final int EXPECTED_DATABASE_VERSION = 1; + + private PostgresKeystoreVersionChecker() {} + + public static void verifyVersion(final DataSource dataSource) { + final int actualVersion; + try (final Connection connection = dataSource.getConnection(); + final Statement statement = connection.createStatement(); + final ResultSet resultSet = + statement.executeQuery("SELECT version FROM database_version WHERE id = 1")) { + if (!resultSet.next()) { + throw new IllegalStateException( + "Postgres keystore database_version table contains no rows - please run migrations" + + " and try again."); + } + actualVersion = resultSet.getInt("version"); + } catch (final SQLException e) { + throw new IllegalStateException( + "Unable to determine Postgres keystore database version - please run migrations and" + + " try again.", + e); + } + + if (actualVersion != EXPECTED_DATABASE_VERSION) { + throw new IllegalStateException( + String.format( + "Postgres keystore database version [%d] does not match expected version [%d] -" + + " please run migrations and try again.", + actualVersion, EXPECTED_DATABASE_VERSION)); + } + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/TenantRecord.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/TenantRecord.java new file mode 100644 index 000000000..a7ecadf21 --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/TenantRecord.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres; + +import org.apache.tuweni.bytes.Bytes; + +/** + * A tenant row from the {@code tenants} table, as needed to resolve its DEK. + * + * @param id the tenant's surrogate id (used for joining to {@code bls_signing_keys}) + * @param name the tenant's stable name - used (not {@code id}) as the AAD tenant identifier, and as + * the {@code KekResolver} cache key + * @param vaultType one of "AZURE", "AWS_KMS", "HASHICORP" - selects which {@code KekResolver} to + * use + * @param kekKeyId the vault-specific KEK reference (key name/version, ARN, or Transit key name) + * @param encryptedDek the tenant's DEK, wrapped by its KEK + * @param dekVersion the version of the DEK - bumped by provisioning on rotation + */ +public record TenantRecord( + int id, String name, String vaultType, String kekKeyId, Bytes encryptedDek, int dekVersion) {} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AadCodec.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AadCodec.java new file mode 100644 index 000000000..4a3a62108 --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AadCodec.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import java.io.ByteArrayOutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; + +/** + * Builds the Additional Authenticated Data (AAD) that every AES-GCM ciphertext in the postgres + * keystore is bound to, so a ciphertext copied or moved between rows fails decryption instead of + * silently succeeding under the wrong identity. + * + *

Encoding is length-prefixed fields (4-byte big-endian length + UTF-8 bytes), never + * delimited/concatenated strings, so that e.g. tenant="A", key="BC" cannot collide with + * tenant="AB", key="C". This encoding is a contract shared with the provisioning/write side - see + * designs/postgres-bulk-key-loading.md. + */ +public final class AadCodec { + + private AadCodec() {} + + public static byte[] forRow( + final String tenantId, final String keyIdentifier, final int dekVersion) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeLengthPrefixed(out, tenantId); + writeLengthPrefixed(out, keyIdentifier); + writeInt(out, dekVersion); + return out.toByteArray(); + } + + public static byte[] forTenant(final String tenantId, final int dekVersion) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeLengthPrefixed(out, tenantId); + writeInt(out, dekVersion); + return out.toByteArray(); + } + + private static void writeLengthPrefixed(final ByteArrayOutputStream out, final String value) { + final byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + writeInt(out, bytes.length); + out.writeBytes(bytes); + } + + private static void writeInt(final ByteArrayOutputStream out, final int value) { + try { + out.write( + new byte[] { + (byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value + }); + } catch (final java.io.IOException e) { + // ByteArrayOutputStream never throws IOException on write + throw new UncheckedIOException(e); + } + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AesGcmKeyCipher.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AesGcmKeyCipher.java new file mode 100644 index 000000000..2d4861bcb --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AesGcmKeyCipher.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * AES-256-GCM encrypt/decrypt for the postgres keystore's on-disk format: a 12-byte random IV + * prepended to the ciphertext, followed by the 16-byte GCM tag (the tag is appended automatically + * by {@link Cipher#doFinal}). + * + *

Not thread-safe - holds a single {@link Cipher} instance so it can be reused across many calls + * (avoiding repeated {@code Cipher.getInstance} provider lookups and, since the JDK caches the + * expanded AES key schedule across {@code init()} calls for an unchanged key, repeated calls with + * the same key are cheaper than the first). Callers running parallel decryption should hold one + * instance per worker thread (e.g. via {@link ThreadLocal}), scoped to a single load cycle. + * + *

{@code encrypt} is provided so that test fixtures (and any provisioning-side tooling written + * against this class) produce ciphertext using the exact same code path this class uses to decrypt, + * rather than a second, independently-written implementation that could silently drift from the + * real contract. + */ +public final class AesGcmKeyCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + public static final int IV_LENGTH_BYTES = 12; + + private final Cipher cipher; + + public AesGcmKeyCipher() { + try { + this.cipher = Cipher.getInstance(TRANSFORMATION); + } catch (final GeneralSecurityException e) { + throw new IllegalStateException("AES/GCM/NoPadding is not available on this JVM", e); + } + } + + /** + * Returns the decrypted plaintext. + * + * @param key the AES-256 key + * @param ivCiphertextAndTag {@code IV(12) || ciphertext || tag(16)} + * @param aad the additional authenticated data expected for this ciphertext + */ + public byte[] decrypt(final byte[] key, final byte[] ivCiphertextAndTag, final byte[] aad) + throws GeneralSecurityException { + final GCMParameterSpec spec = + new GCMParameterSpec(GCM_TAG_BITS, ivCiphertextAndTag, 0, IV_LENGTH_BYTES); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), spec); + cipher.updateAAD(aad); + return cipher.doFinal( + ivCiphertextAndTag, IV_LENGTH_BYTES, ivCiphertextAndTag.length - IV_LENGTH_BYTES); + } + + /** + * Returns {@code IV(12) || ciphertext || tag(16)}. + * + * @param key the AES-256 key + * @param plaintext the plaintext to encrypt + * @param aad the additional authenticated data to bind this ciphertext to + */ + public byte[] encrypt(final byte[] key, final byte[] plaintext, final byte[] aad) + throws GeneralSecurityException { + final byte[] iv = new byte[IV_LENGTH_BYTES]; + SecureRandom.getInstanceStrong().nextBytes(iv); + cipher.init( + Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(GCM_TAG_BITS, iv)); + cipher.updateAAD(aad); + final byte[] ciphertextAndTag = cipher.doFinal(plaintext); + final byte[] result = new byte[IV_LENGTH_BYTES + ciphertextAndTag.length]; + System.arraycopy(iv, 0, result, 0, IV_LENGTH_BYTES); + System.arraycopy(ciphertextAndTag, 0, result, IV_LENGTH_BYTES, ciphertextAndTag.length); + return result; + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDek.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDek.java new file mode 100644 index 000000000..6150dcde3 --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDek.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import java.util.Arrays; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * A reference-counted holder for a decrypted tenant DEK, allowing it to be safely wiped once + * evicted from the cache without racing a concurrent decrypt operation. + * + *

Caffeine's removal listener runs on a maintenance thread with no relationship to whether a + * decrypt thread is mid-operation holding this exact byte array - directly zeroing the array in the + * removal listener risks a concurrent thread observing a partially-wiped key. Instead, callers take + * a short-lived read-lock {@link Lease} to use the key bytes; eviction/rotation/shutdown marks this + * instance for wipe and attempts a non-blocking write-lock, which only succeeds once every + * outstanding lease has been closed. Since no new leases can be acquired once a wipe has been + * requested (see {@link #acquireForRead()}), the outstanding-lease count is monotonically + * decreasing and the wipe is guaranteed to eventually succeed. + */ +public final class TenantDek { + + private final byte[] keyBytes; + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private volatile boolean pendingWipe = false; + private volatile boolean wiped = false; + + public TenantDek(final byte[] keyBytes) { + this.keyBytes = keyBytes; + } + + public Lease acquireForRead() { + lock.readLock().lock(); + try { + if (pendingWipe || wiped) { + throw new IllegalStateException("TenantDek has been invalidated and can no longer be used"); + } + return new Lease(); + } catch (final RuntimeException e) { + lock.readLock().unlock(); + throw e; + } + } + + /** Marks this DEK for wipe and attempts it immediately; safe to call more than once. */ + public void markForWipeAndAttempt() { + pendingWipe = true; + attemptWipe(); + } + + private void attemptWipe() { + if (wiped) { + return; + } + if (lock.writeLock().tryLock()) { + try { + if (!wiped) { + Arrays.fill(keyBytes, (byte) 0); + wiped = true; + } + } finally { + lock.writeLock().unlock(); + } + } + // else: readers still active - the next Lease#close() retries the wipe. + } + + public final class Lease implements AutoCloseable { + + public byte[] keyBytes() { + return keyBytes; + } + + @Override + public void close() { + lock.readLock().unlock(); + if (pendingWipe) { + attemptWipe(); + } + } + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekCache.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekCache.java new file mode 100644 index 000000000..02a0e8a19 --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekCache.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import java.time.Duration; +import java.util.function.Supplier; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalCause; + +/** + * Caches resolved tenant DEKs for a configurable TTL, keyed by {@code (tenantId, dekVersion)} - + * keying on the DEK version (rather than tenant alone) means a DEK rotation is automatically a + * cache miss instead of silently serving a stale DEK for up to the remainder of the TTL window. + * + *

A cache hit means zero vault calls for that tenant on a subsequent load/reload within the TTL + * window - this is the mechanism behind the sub-100ms tenant-scoped reload target. + */ +public final class TenantDekCache implements AutoCloseable { + + public record TenantDekKey(String tenantId, int dekVersion) {} + + private final Cache cache; + + public TenantDekCache(final Duration ttl) { + this.cache = + Caffeine.newBuilder() + .expireAfterWrite(ttl) + .removalListener( + (final TenantDekKey key, final TenantDek value, final RemovalCause cause) -> { + if (value != null) { + value.markForWipeAndAttempt(); + } + }) + .build(); + } + + /** + * Returns the cached DEK for the given tenant/version, resolving (and caching) it via {@code + * resolver} on a cache miss. Caffeine guarantees {@code resolver} runs at most once per key even + * under concurrent callers. + */ + public TenantDek getOrLoad( + final String tenantId, final int dekVersion, final Supplier resolver) { + return cache.get(new TenantDekKey(tenantId, dekVersion), key -> new TenantDek(resolver.get())); + } + + @Override + public void close() { + cache.asMap().values().forEach(TenantDek::markForWipeAndAttempt); + cache.invalidateAll(); + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/KekResolutionException.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/KekResolutionException.java new file mode 100644 index 000000000..0ea6c87ab --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/KekResolutionException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.kek; + +/** + * Thrown when a tenant's DEK cannot be resolved. Messages must never include key material - callers + * should log only the tenant identifier and this exception's class name/message, which by contract + * never embed key bytes. + */ +public class KekResolutionException extends RuntimeException { + + public KekResolutionException(final String message) { + super(message); + } + + public KekResolutionException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/KekResolver.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/KekResolver.java new file mode 100644 index 000000000..d7326799c --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/KekResolver.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.kek; + +import tech.pegasys.web3signer.keystorage.postgres.TenantRecord; + +/** + * Resolves a tenant's DEK by unwrapping it via that tenant's Key Encrypting Key (KEK), which lives + * in a vault and never leaves it. Each implementation performs exactly one remote vault operation + * per call - callers are expected to cache the result (see {@code TenantDekCache}) so that a full + * reload makes exactly one vault call per distinct tenant, not per key. + */ +public interface KekResolver { + + /** The {@code tenants.vault_type} value this resolver handles, e.g. "AWS_KMS". */ + String vaultType(); + + /** + * Unwraps the given tenant's DEK. + * + * @param tenant the tenant whose DEK is being resolved + * @return the tenant's plaintext DEK bytes + * @throws KekResolutionException if the vault call fails or the tenant's KEK reference is invalid + */ + byte[] unwrapDek(TenantRecord tenant) throws KekResolutionException; +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/awskms/AwsKmsKekCredentials.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/awskms/AwsKmsKekCredentials.java new file mode 100644 index 000000000..faf3ee742 --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/awskms/AwsKmsKekCredentials.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.kek.awskms; + +import tech.pegasys.web3signer.common.config.AwsAuthenticationMode; +import tech.pegasys.web3signer.common.config.AwsCredentials; + +import java.net.URI; +import java.util.Optional; + +/** + * Credentials for the AWS KMS-backed {@code KekResolver}. Deliberately not shared with the existing + * Azure/AWS bulk-secret-scan CLI parameters - "unwrap N specific keys" and "list an entire vault" + * are different privilege scopes that may reasonably use different identities. + */ +public interface AwsKmsKekCredentials { + + AwsAuthenticationMode getAuthenticationMode(); + + Optional getCredentials(); + + Optional getEndpointOverride(); +} diff --git a/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/awskms/PostgresAwsKmsKekResolver.java b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/awskms/PostgresAwsKmsKekResolver.java new file mode 100644 index 000000000..446ebc9dc --- /dev/null +++ b/keys-postgres/src/main/java/tech/pegasys/web3signer/keystorage/postgres/kek/awskms/PostgresAwsKmsKekResolver.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.kek.awskms; + +import tech.pegasys.web3signer.common.config.AwsCredentials; +import tech.pegasys.web3signer.keystorage.postgres.TenantRecord; +import tech.pegasys.web3signer.keystorage.postgres.kek.KekResolutionException; +import tech.pegasys.web3signer.keystorage.postgres.kek.KekResolver; + +import java.io.Closeable; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.common.base.Splitter; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.KmsClientBuilder; +import software.amazon.awssdk.services.kms.model.DecryptRequest; +import software.amazon.awssdk.services.kms.model.DecryptResponse; + +/** + * Resolves a tenant's DEK by calling AWS KMS's {@code Decrypt} API against the tenant's KMS key + * (identified by its full ARN in {@code tenants.kek_key_id}). {@code GenerateDataKey} is a + * provisioning/write-side-only API - this read side only ever calls {@code Decrypt}. + * + *

One {@link KmsClient} is built and cached per distinct AWS region encountered (parsed from + * each tenant's key ARN), since a client must be region-scoped but tenants may have keys in + * different regions. + */ +public class PostgresAwsKmsKekResolver implements KekResolver, Closeable { + + private static final String TENANT_ID_ENCRYPTION_CONTEXT_KEY = "tenant_id"; + + private final AwsCredentialsProvider credentialsProvider; + private final AwsKmsKekCredentials credentials; + private final Map clientsByRegion = new ConcurrentHashMap<>(); + + public PostgresAwsKmsKekResolver(final AwsKmsKekCredentials credentials) { + this.credentials = credentials; + this.credentialsProvider = createCredentialsProvider(credentials); + } + + @Override + public String vaultType() { + return "AWS_KMS"; + } + + @Override + public byte[] unwrapDek(final TenantRecord tenant) { + final String region = extractRegion(tenant.kekKeyId()); + final KmsClient client = clientsByRegion.computeIfAbsent(region, this::buildClient); + try { + final DecryptResponse response = + client.decrypt( + DecryptRequest.builder() + .keyId(tenant.kekKeyId()) + .ciphertextBlob(SdkBytes.fromByteArray(tenant.encryptedDek().toArray())) + .encryptionContext(Map.of(TENANT_ID_ENCRYPTION_CONTEXT_KEY, tenant.name())) + .build()); + return response.plaintext().asByteArray(); + } catch (final RuntimeException e) { + throw new KekResolutionException( + "Failed to unwrap DEK for tenant '" + + tenant.name() + + "' via AWS KMS: " + + e.getClass().getSimpleName(), + e); + } + } + + private KmsClient buildClient(final String region) { + final KmsClientBuilder builder = + KmsClient.builder().credentialsProvider(credentialsProvider).region(Region.of(region)); + credentials.getEndpointOverride().ifPresent(builder::endpointOverride); + return builder.build(); + } + + private static String extractRegion(final String kmsKeyArn) { + final List parts = Splitter.on(':').splitToList(kmsKeyArn); + if (parts.size() < 4 || !"arn".equals(parts.getFirst())) { + throw new KekResolutionException( + "tenants.kek_key_id is not a valid KMS key ARN: " + kmsKeyArn); + } + return parts.get(3); + } + + private static AwsCredentialsProvider createCredentialsProvider( + final AwsKmsKekCredentials credentials) { + return switch (credentials.getAuthenticationMode()) { + case ENVIRONMENT -> DefaultCredentialsProvider.builder().build(); + case SPECIFIED -> + StaticCredentialsProvider.create( + toAwsSdkCredentials( + credentials + .getCredentials() + .orElseThrow( + () -> + new IllegalArgumentException( + "AWS credentials must be provided for SPECIFIED mode")))); + }; + } + + private static software.amazon.awssdk.auth.credentials.AwsCredentials toAwsSdkCredentials( + final AwsCredentials credentials) { + return credentials + .getSessionToken() + .map( + token -> + AwsSessionCredentials.create( + credentials.getAccessKeyId(), credentials.getSecretAccessKey(), token)) + .orElseGet( + () -> + AwsBasicCredentials.create( + credentials.getAccessKeyId(), credentials.getSecretAccessKey())); + } + + @Override + public void close() { + clientsByRegion.values().forEach(KmsClient::close); + } +} diff --git a/keys-postgres/src/main/resources/migrations/keystore-postgresql/V00001__initial.sql b/keys-postgres/src/main/resources/migrations/keystore-postgresql/V00001__initial.sql new file mode 100644 index 000000000..e998b0cb4 --- /dev/null +++ b/keys-postgres/src/main/resources/migrations/keystore-postgresql/V00001__initial.sql @@ -0,0 +1,29 @@ +CREATE TABLE tenants ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + vault_type VARCHAR(32) NOT NULL, + kek_key_id VARCHAR(1024) NOT NULL, + encrypted_dek BYTEA NOT NULL, + dek_version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + UNIQUE (name) +); + +CREATE TABLE bls_signing_keys ( + id BIGSERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id), + key_identifier VARCHAR(256) NOT NULL, + encrypted_bls_key BYTEA NOT NULL, + dek_version INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + UNIQUE (tenant_id, key_identifier) +); + +CREATE INDEX idx_bls_signing_keys_tenant_id ON bls_signing_keys (tenant_id); + +CREATE TABLE database_version ( + id INTEGER PRIMARY KEY, + version INTEGER NOT NULL +); +INSERT INTO database_version (id, version) VALUES (1, 1); diff --git a/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/PostgresBulkKeyLoaderTest.java b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/PostgresBulkKeyLoaderTest.java new file mode 100644 index 000000000..a02bfbc4d --- /dev/null +++ b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/PostgresBulkKeyLoaderTest.java @@ -0,0 +1,230 @@ +/* + * Copyright 2026 ConsenSys AG. + * + * 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 tech.pegasys.web3signer.keystorage.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import tech.pegasys.web3signer.keystorage.postgres.crypto.AadCodec; +import tech.pegasys.web3signer.keystorage.postgres.crypto.AesGcmKeyCipher; +import tech.pegasys.web3signer.keystorage.postgres.kek.KekResolutionException; +import tech.pegasys.web3signer.keystorage.postgres.kek.KekResolver; + +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PostgresBulkKeyLoaderTest { + + private static final String VAULT_TYPE = "TEST"; + private static SecureRandom secureRandom = new SecureRandom(); + private PostgresKeystoreTestUtil.TestDatabase testDatabase; + private DataSource dataSource; + private final AesGcmKeyCipher cipher = new AesGcmKeyCipher(); + + @BeforeEach + void setUp() { + testDatabase = PostgresKeystoreTestUtil.create(); + dataSource = testDatabase.getDataSource(); + } + + @AfterEach + void tearDown() throws Exception { + testDatabase.close(); + } + + @Test + void decryptsAllKeysAndMakesExactlyOneVaultCallPerTenant() throws Exception { + final byte[] dekA = randomKey(); + final byte[] dekB = randomKey(); + final int tenantAId = insertTenant("tenant-a", 1); + final int tenantBId = insertTenant("tenant-b", 1); + insertKey(tenantAId, "tenant-a", "key-a1", dekA, 1); + insertKey(tenantAId, "tenant-a", "key-a2", dekA, 1); + insertKey(tenantBId, "tenant-b", "key-b1", dekB, 1); + + final CountingFakeKekResolver resolver = + new CountingFakeKekResolver(Map.of("tenant-a", dekA, "tenant-b", dekB)); + + try (final PostgresBulkKeyLoader loader = + new PostgresBulkKeyLoader( + dataSource, Map.of(VAULT_TYPE, resolver), Duration.ofMinutes(15), 4)) { + final var results = loader.loadAll(); + + assertThat(results.getErrorCount()).isZero(); + assertThat(results.getValues()) + .extracting(DecryptedBlsKey::keyIdentifier) + .containsExactlyInAnyOrder("key-a1", "key-a2", "key-b1"); + assertThat(resolver.resolveCount).hasValue(2); // exactly one call per tenant + assertThat(loader.getLastVaultCallCount()).isEqualTo(2); + } + } + + @Test + void secondLoadWithinTtlServesDekFromCacheWithNoAdditionalVaultCalls() throws Exception { + final byte[] dek = randomKey(); + final int tenantId = insertTenant("tenant-a", 1); + insertKey(tenantId, "tenant-a", "key-a1", dek, 1); + + final CountingFakeKekResolver resolver = new CountingFakeKekResolver(Map.of("tenant-a", dek)); + + try (final PostgresBulkKeyLoader loader = + new PostgresBulkKeyLoader( + dataSource, Map.of(VAULT_TYPE, resolver), Duration.ofMinutes(15), 4)) { + loader.loadAll(); + final var secondResult = loader.loadAll(); + + assertThat(secondResult.getErrorCount()).isZero(); + assertThat(resolver.resolveCount).hasValue(1); + assertThat(loader.getLastVaultCallCount()).isZero(); + } + } + + @Test + void rowEncryptedUnderStaleDekVersionIsSkippedAndCountedAsError() throws Exception { + final byte[] dek = randomKey(); + final int tenantId = insertTenant("tenant-a", 2); // tenant's *current* version is 2 + insertKey(tenantId, "tenant-a", "stale-key", dek, 1); // but this row is still under version 1 + + final CountingFakeKekResolver resolver = new CountingFakeKekResolver(Map.of("tenant-a", dek)); + + try (final PostgresBulkKeyLoader loader = + new PostgresBulkKeyLoader( + dataSource, Map.of(VAULT_TYPE, resolver), Duration.ofMinutes(15), 4)) { + final var results = loader.loadAll(); + + assertThat(results.getValues()).isEmpty(); + assertThat(results.getErrorCount()).isEqualTo(1); + } + } + + @Test + void tenantWithNoRegisteredResolverIsSkippedAndCountedAsError() throws Exception { + final byte[] dek = randomKey(); + final int tenantId = insertTenant("tenant-a", 1); + insertKey(tenantId, "tenant-a", "key-a1", dek, 1); + + try (final PostgresBulkKeyLoader loader = + new PostgresBulkKeyLoader(dataSource, Map.of(), Duration.ofMinutes(15), 4)) { + final var results = loader.loadAll(); + + assertThat(results.getValues()).isEmpty(); + assertThat(results.getErrorCount()).isEqualTo(1); + } + } + + @Test + void oneTenantFailingKekResolutionDoesNotPreventOtherTenantsFromLoading() throws Exception { + final byte[] dekA = randomKey(); + final byte[] dekB = randomKey(); + final int tenantAId = insertTenant("tenant-a", 1); + final int tenantBId = insertTenant("tenant-b", 1); + insertKey(tenantAId, "tenant-a", "key-a1", dekA, 1); + insertKey(tenantBId, "tenant-b", "key-b1", dekB, 1); + + // resolver only knows about tenant-b's DEK - tenant-a's resolution will fail + final CountingFakeKekResolver resolver = new CountingFakeKekResolver(Map.of("tenant-b", dekB)); + + try (final PostgresBulkKeyLoader loader = + new PostgresBulkKeyLoader( + dataSource, Map.of(VAULT_TYPE, resolver), Duration.ofMinutes(15), 4)) { + final var results = loader.loadAll(); + + assertThat(results.getValues()) + .extracting(DecryptedBlsKey::keyIdentifier) + .containsExactly("key-b1"); + assertThat(results.getErrorCount()).isEqualTo(1); + } + } + + private int insertTenant(final String name, final int dekVersion) throws SQLException { + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement statement = + connection.prepareStatement( + "INSERT INTO tenants (name, vault_type, kek_key_id, encrypted_dek, dek_version)" + + " VALUES (?, ?, ?, ?, ?) RETURNING id")) { + statement.setString(1, name); + statement.setString(2, VAULT_TYPE); + statement.setString(3, "test-kek-id"); + // content is irrelevant - CountingFakeKekResolver returns a fixed DEK without reading this + statement.setBytes(4, new byte[] {0}); + statement.setInt(5, dekVersion); + try (final var resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getInt("id"); + } + } + } + + private void insertKey( + final int tenantId, + final String tenantName, + final String keyIdentifier, + final byte[] dek, + final int dekVersion) + throws SQLException, GeneralSecurityException { + final byte[] aad = AadCodec.forRow(tenantName, keyIdentifier, dekVersion); + final byte[] encryptedBlsKey = cipher.encrypt(dek, randomKey(), aad); + try (final Connection connection = dataSource.getConnection(); + final PreparedStatement statement = + connection.prepareStatement( + "INSERT INTO bls_signing_keys (tenant_id, key_identifier, encrypted_bls_key," + + " dek_version) VALUES (?, ?, ?, ?)")) { + statement.setInt(1, tenantId); + statement.setString(2, keyIdentifier); + statement.setBytes(3, encryptedBlsKey); + statement.setInt(4, dekVersion); + statement.executeUpdate(); + } + } + + private static byte[] randomKey() { + final byte[] key = new byte[32]; + secureRandom.nextBytes(key); + return key; + } + + /** A fake {@link KekResolver} returning pre-known DEKs, for testing the loader in isolation. */ + private static final class CountingFakeKekResolver implements KekResolver { + private final Map deksByTenantName; + private final AtomicInteger resolveCount = new AtomicInteger(); + + private CountingFakeKekResolver(final Map deksByTenantName) { + this.deksByTenantName = deksByTenantName; + } + + @Override + public String vaultType() { + return VAULT_TYPE; + } + + @Override + public byte[] unwrapDek(final TenantRecord tenant) { + resolveCount.incrementAndGet(); + final byte[] dek = deksByTenantName.get(tenant.name()); + if (dek == null) { + throw new KekResolutionException("No test DEK registered for tenant " + tenant.name()); + } + return dek; + } + } +} diff --git a/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AadCodecTest.java b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AadCodecTest.java new file mode 100644 index 000000000..6cf461d06 --- /dev/null +++ b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AadCodecTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class AadCodecTest { + + @Test + void isDeterministic() { + assertThat(AadCodec.forRow("tenant-a", "0xabc", 1)) + .isEqualTo(AadCodec.forRow("tenant-a", "0xabc", 1)); + assertThat(AadCodec.forTenant("tenant-a", 1)).isEqualTo(AadCodec.forTenant("tenant-a", 1)); + } + + @Test + void lengthPrefixingPreventsFieldBoundaryCollisions() { + // "A" + "BC" must not encode the same as "AB" + "C" + final byte[] first = AadCodec.forRow("A", "BC", 1); + final byte[] second = AadCodec.forRow("AB", "C", 1); + assertThat(first).isNotEqualTo(second); + } + + @Test + void differentDekVersionsProduceDifferentAad() { + assertThat(AadCodec.forRow("tenant-a", "0xabc", 1)) + .isNotEqualTo(AadCodec.forRow("tenant-a", "0xabc", 2)); + assertThat(AadCodec.forTenant("tenant-a", 1)).isNotEqualTo(AadCodec.forTenant("tenant-a", 2)); + } + + @Test + void rowAadDiffersFromTenantAad() { + assertThat(AadCodec.forRow("tenant-a", "tenant-a", 1)) + .isNotEqualTo(AadCodec.forTenant("tenant-a", 1)); + } +} diff --git a/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AesGcmKeyCipherTest.java b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AesGcmKeyCipherTest.java new file mode 100644 index 000000000..6266a7374 --- /dev/null +++ b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/AesGcmKeyCipherTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import javax.crypto.AEADBadTagException; + +import org.junit.jupiter.api.Test; + +class AesGcmKeyCipherTest { + + private static SecureRandom secureRandom = new SecureRandom(); + private final AesGcmKeyCipher cipher = new AesGcmKeyCipher(); + private final byte[] key = randomKey(); + private final byte[] aad = AadCodec.forRow("tenant-a", "0xabc", 1); + + @Test + void decryptsWhatWasEncrypted() throws GeneralSecurityException { + final byte[] plaintext = "a 32 byte long secret key......".getBytes(UTF_8); + final byte[] ciphertext = cipher.encrypt(key, plaintext, aad); + + assertThat(ciphertext).hasSize(AesGcmKeyCipher.IV_LENGTH_BYTES + plaintext.length + 16); + assertThat(cipher.decrypt(key, ciphertext, aad)).isEqualTo(plaintext); + } + + @Test + void producesDifferentCiphertextEachTimeDueToRandomIv() throws GeneralSecurityException { + final byte[] plaintext = "some plaintext".getBytes(UTF_8); + final byte[] first = cipher.encrypt(key, plaintext, aad); + final byte[] second = cipher.encrypt(key, plaintext, aad); + assertThat(first).isNotEqualTo(second); + } + + @Test + void rejectsTamperedCiphertext() throws GeneralSecurityException { + final byte[] ciphertext = cipher.encrypt(key, "plaintext".getBytes(UTF_8), aad); + ciphertext[ciphertext.length - 1] ^= 0x01; + + assertThatThrownBy(() -> cipher.decrypt(key, ciphertext, aad)) + .isInstanceOf(AEADBadTagException.class); + } + + @Test + void rejectsMismatchedAad() throws GeneralSecurityException { + final byte[] ciphertext = cipher.encrypt(key, "plaintext".getBytes(UTF_8), aad); + final byte[] wrongAad = AadCodec.forRow("tenant-b", "0xabc", 1); + + assertThatThrownBy(() -> cipher.decrypt(key, ciphertext, wrongAad)) + .isInstanceOf(AEADBadTagException.class); + } + + @Test + void rejectsWrongKey() throws GeneralSecurityException { + final byte[] ciphertext = cipher.encrypt(key, "plaintext".getBytes(UTF_8), aad); + + assertThatThrownBy(() -> cipher.decrypt(randomKey(), ciphertext, aad)) + .isInstanceOf(AEADBadTagException.class); + } + + private static byte[] randomKey() { + final byte[] key = new byte[32]; + secureRandom.nextBytes(key); + return key; + } +} diff --git a/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekCacheTest.java b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekCacheTest.java new file mode 100644 index 000000000..659cc5c86 --- /dev/null +++ b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekCacheTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +class TenantDekCacheTest { + + @Test + void resolverIsInvokedOnceThenCachedForSameTenantAndVersion() { + final TenantDekCache cache = new TenantDekCache(Duration.ofMinutes(15)); + final AtomicInteger resolveCount = new AtomicInteger(); + + final TenantDek first = cache.getOrLoad("tenant-a", 1, () -> resolve(resolveCount)); + final TenantDek second = cache.getOrLoad("tenant-a", 1, () -> resolve(resolveCount)); + + assertThat(first).isSameAs(second); + assertThat(resolveCount).hasValue(1); + } + + @Test + void differentTenantsResolveIndependently() { + final TenantDekCache cache = new TenantDekCache(Duration.ofMinutes(15)); + final AtomicInteger resolveCount = new AtomicInteger(); + + cache.getOrLoad("tenant-a", 1, () -> resolve(resolveCount)); + cache.getOrLoad("tenant-b", 1, () -> resolve(resolveCount)); + + assertThat(resolveCount).hasValue(2); + } + + @Test + void dekVersionBumpForcesReResolution() { + final TenantDekCache cache = new TenantDekCache(Duration.ofMinutes(15)); + final AtomicInteger resolveCount = new AtomicInteger(); + + cache.getOrLoad("tenant-a", 1, () -> resolve(resolveCount)); + cache.getOrLoad("tenant-a", 2, () -> resolve(resolveCount)); + + assertThat(resolveCount).hasValue(2); + } + + @Test + void closeWipesAllCachedDeks() { + final TenantDekCache cache = new TenantDekCache(Duration.ofMinutes(15)); + final byte[] keyBytes = {1, 2, 3, 4}; + cache.getOrLoad("tenant-a", 1, () -> keyBytes); + + cache.close(); + + assertThat(keyBytes).containsOnly(0); + } + + private static byte[] resolve(final AtomicInteger resolveCount) { + resolveCount.incrementAndGet(); + return new byte[] {1, 2, 3, 4}; + } +} diff --git a/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekTest.java b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekTest.java new file mode 100644 index 000000000..7e1d15114 --- /dev/null +++ b/keys-postgres/src/test/java/tech/pegasys/web3signer/keystorage/postgres/crypto/TenantDekTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.Test; + +class TenantDekTest { + + @Test + void wipeZeroesKeyBytesWhenNoActiveLease() { + final byte[] keyBytes = {1, 2, 3, 4}; + final TenantDek dek = new TenantDek(keyBytes); + + dek.markForWipeAndAttempt(); + + assertThat(keyBytes).containsOnly(0); + } + + @Test + void leaseExposesTheUnderlyingBytesUntilClosed() { + final byte[] keyBytes = {1, 2, 3, 4}; + final TenantDek dek = new TenantDek(keyBytes); + + try (final TenantDek.Lease lease = dek.acquireForRead()) { + assertThat(lease.keyBytes()).isEqualTo(keyBytes); + } + } + + @Test + void wipeIsDeferredWhileALeaseIsHeld() throws InterruptedException { + final byte[] keyBytes = {1, 2, 3, 4}; + final TenantDek dek = new TenantDek(keyBytes); + final CountDownLatch wipeAttempted = new CountDownLatch(1); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + + try (final TenantDek.Lease lease = dek.acquireForRead()) { + executor.submit( + () -> { + dek.markForWipeAndAttempt(); + wipeAttempted.countDown(); + }); + wipeAttempted.await(); + + // the wipe attempt ran, but could not acquire the write lock while the lease is held + assertThat(keyBytes).isNotEqualTo(new byte[] {0, 0, 0, 0}); + assertThat(lease.keyBytes()).isEqualTo(new byte[] {1, 2, 3, 4}); + } + + // closing the lease retries the wipe, which now succeeds + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> assertThat(keyBytes).containsOnly(0)); + + executor.shutdownNow(); + } + + @Test + void newLeasesAreRejectedOncePendingWipe() { + final TenantDek dek = new TenantDek(new byte[] {1, 2, 3, 4}); + dek.markForWipeAndAttempt(); + + assertThatThrownBy(dek::acquireForRead).isInstanceOf(IllegalStateException.class); + } +} diff --git a/keys-postgres/src/testFixtures/java/tech/pegasys/web3signer/keystorage/postgres/PostgresKeystoreTestUtil.java b/keys-postgres/src/testFixtures/java/tech/pegasys/web3signer/keystorage/postgres/PostgresKeystoreTestUtil.java new file mode 100644 index 000000000..56fdd23ae --- /dev/null +++ b/keys-postgres/src/testFixtures/java/tech/pegasys/web3signer/keystorage/postgres/PostgresKeystoreTestUtil.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.keystorage.postgres; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import javax.sql.DataSource; + +import io.zonky.test.db.postgres.embedded.EmbeddedPostgres; +import org.flywaydb.core.Flyway; + +/** Spins up a real, migrated, embedded postgres keystore database for tests. */ +public final class PostgresKeystoreTestUtil { + + public static final String USERNAME = "postgres"; + public static final String PASSWORD = "postgres"; + public static final String MIGRATIONS_LOCATION = "/migrations/keystore-postgresql/"; + + private PostgresKeystoreTestUtil() {} + + public static TestDatabase create() { + try { + final EmbeddedPostgres db = EmbeddedPostgres.start(); + final String jdbcUrl = String.format("jdbc:postgresql://localhost:%d/postgres", db.getPort()); + final Flyway flyway = + Flyway.configure() + .locations(MIGRATIONS_LOCATION) + .dataSource(db.getPostgresDatabase()) + .load(); + flyway.migrate(); + final DataSource dataSource = + PostgresConnectionFactory.createDataSource(jdbcUrl, USERNAME, PASSWORD, null); + return new TestDatabase(db, dataSource); + } catch (final IOException e) { + throw new UncheckedIOException("Unable to create embedded postgres database", e); + } + } + + public static final class TestDatabase implements Closeable { + private final EmbeddedPostgres db; + private final DataSource dataSource; + + private TestDatabase(final EmbeddedPostgres db, final DataSource dataSource) { + this.db = db; + this.dataSource = dataSource; + } + + public DataSource getDataSource() { + return dataSource; + } + + @Override + public void close() throws IOException { + db.close(); + } + } +} diff --git a/settings.gradle b/settings.gradle index 738d25d85..eddd1e201 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include 'commandline' include 'common' include 'core' include 'keystorage' +include 'keys-postgres' include 'signing' include 'slashing-protection' include 'slashing-protection:referencetests' diff --git a/signing/build.gradle b/signing/build.gradle index 66c3f0fef..565722eea 100644 --- a/signing/build.gradle +++ b/signing/build.gradle @@ -17,6 +17,7 @@ jar { dependencies { implementation project(":common") implementation project(':keystorage') + implementation project(':keys-postgres') implementation project(":bls-keystore") implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml' implementation 'com.github.arteam:simple-json-rpc-server' diff --git a/signing/src/main/java/tech/pegasys/web3signer/signing/bulkloading/BlsPostgresBulkLoader.java b/signing/src/main/java/tech/pegasys/web3signer/signing/bulkloading/BlsPostgresBulkLoader.java new file mode 100644 index 000000000..ff32a549b --- /dev/null +++ b/signing/src/main/java/tech/pegasys/web3signer/signing/bulkloading/BlsPostgresBulkLoader.java @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.signing.bulkloading; + +import tech.pegasys.teku.bls.BLSKeyPair; +import tech.pegasys.teku.bls.BLSSecretKey; +import tech.pegasys.web3signer.common.config.AwsAuthenticationMode; +import tech.pegasys.web3signer.common.config.AwsCredentials; +import tech.pegasys.web3signer.keystorage.common.MappedResults; +import tech.pegasys.web3signer.keystorage.postgres.DecryptedBlsKey; +import tech.pegasys.web3signer.keystorage.postgres.PostgresBulkKeyLoader; +import tech.pegasys.web3signer.keystorage.postgres.PostgresConnectionFactory; +import tech.pegasys.web3signer.keystorage.postgres.PostgresKeystoreVersionChecker; +import tech.pegasys.web3signer.keystorage.postgres.kek.KekResolver; +import tech.pegasys.web3signer.keystorage.postgres.kek.awskms.AwsKmsKekCredentials; +import tech.pegasys.web3signer.keystorage.postgres.kek.awskms.PostgresAwsKmsKekResolver; +import tech.pegasys.web3signer.signing.ArtifactSigner; +import tech.pegasys.web3signer.signing.BlsArtifactSigner; +import tech.pegasys.web3signer.signing.config.PostgresAwsKmsKekParameters; +import tech.pegasys.web3signer.signing.config.PostgresKeystoreParameters; +import tech.pegasys.web3signer.signing.config.metadata.SignerOrigin; + +import java.io.Closeable; +import java.net.URI; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.sql.DataSource; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tuweni.bytes.Bytes32; + +/** + * Bulk-loads BLS signing keys from the postgres keystore. Unlike the other {@code Bls*BulkLoader} + * classes, this one is long-lived by design: it must be constructed once (holding a connection pool + * and a DEK cache) and reused across startup and every {@code /reload} so the DEK cache actually + * has a chance to serve cache hits across reload cycles - see {@link PostgresBulkKeyLoader}. + */ +public class BlsPostgresBulkLoader implements Closeable { + + private static final Logger LOG = LogManager.getLogger(); + + private final DataSource dataSource; + private final PostgresAwsKmsKekResolver awsKmsKekResolver; + private final PostgresBulkKeyLoader postgresBulkKeyLoader; + + public BlsPostgresBulkLoader( + final PostgresKeystoreParameters postgresKeystoreParameters, + final PostgresAwsKmsKekParameters awsKmsKekParameters) { + this.dataSource = + PostgresConnectionFactory.createDataSource( + postgresKeystoreParameters.getDbUrl(), + postgresKeystoreParameters.getDbUsername(), + postgresKeystoreParameters.getDbPassword(), + postgresKeystoreParameters.getDbPoolConfigurationFile()); + PostgresKeystoreVersionChecker.verifyVersion(dataSource); + + this.awsKmsKekResolver = + new PostgresAwsKmsKekResolver(toAwsKmsKekCredentials(awsKmsKekParameters)); + final Map resolversByVaultType = + Map.of(awsKmsKekResolver.vaultType(), awsKmsKekResolver); + + this.postgresBulkKeyLoader = + new PostgresBulkKeyLoader( + dataSource, + resolversByVaultType, + postgresKeystoreParameters.getDekCacheTtl(), + postgresKeystoreParameters.getDecryptionParallelism()); + } + + public MappedResults load() { + final MappedResults decrypted = postgresBulkKeyLoader.loadAll(); + final Set signers = new HashSet<>(); + int mappingErrors = 0; + for (final DecryptedBlsKey key : decrypted.getValues()) { + final ArtifactSigner signer = toArtifactSigner(key); + if (signer != null) { + signers.add(signer); + } else { + mappingErrors++; + } + } + return MappedResults.newInstance(signers, decrypted.getErrorCount() + mappingErrors); + } + + /** The number of KEK vault calls made during the most recent {@link #load()} invocation. */ + public int getLastVaultCallCount() { + return postgresBulkKeyLoader.getLastVaultCallCount(); + } + + private static ArtifactSigner toArtifactSigner(final DecryptedBlsKey key) { + try { + final BLSKeyPair keyPair = + new BLSKeyPair(BLSSecretKey.fromBytes(Bytes32.wrap(key.rawSecretKeyBytes()))); + return new BlsArtifactSigner(keyPair, SignerOrigin.POSTGRES); + } catch (final Exception e) { + LOG.warn( + "Failed to construct BLS key pair for '{}', discarding: {}", + key.keyIdentifier(), + e.getClass().getSimpleName()); + return null; + } + } + + private static AwsKmsKekCredentials toAwsKmsKekCredentials( + final PostgresAwsKmsKekParameters parameters) { + return new AwsKmsKekCredentials() { + @Override + public AwsAuthenticationMode getAuthenticationMode() { + return parameters.getAuthenticationMode(); + } + + @Override + public Optional getCredentials() { + if (parameters.getAccessKeyId() == null || parameters.getSecretAccessKey() == null) { + return Optional.empty(); + } + return Optional.of( + AwsCredentials.builder() + .withAccessKeyId(parameters.getAccessKeyId()) + .withSecretAccessKey(parameters.getSecretAccessKey()) + .build()); + } + + @Override + public Optional getEndpointOverride() { + return parameters.getEndpointOverride(); + } + }; + } + + @Override + public void close() { + // postgresBulkKeyLoader also closes the datasource, since it created it via + // PostgresConnectionFactory and owns its lifecycle. + postgresBulkKeyLoader.close(); + awsKmsKekResolver.close(); + } +} diff --git a/signing/src/main/java/tech/pegasys/web3signer/signing/config/PostgresAwsKmsKekParameters.java b/signing/src/main/java/tech/pegasys/web3signer/signing/config/PostgresAwsKmsKekParameters.java new file mode 100644 index 000000000..626b9522c --- /dev/null +++ b/signing/src/main/java/tech/pegasys/web3signer/signing/config/PostgresAwsKmsKekParameters.java @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.signing.config; + +import tech.pegasys.web3signer.common.config.AwsAuthenticationMode; + +import java.net.URI; +import java.util.Optional; + +/** + * Credentials used to call AWS KMS to unwrap a tenant's DEK when bulk-loading keys from the + * postgres keystore. Deliberately separate from {@link AwsVaultParameters} (used for the unrelated + * AWS Secrets Manager bulk-scan feature) - "unwrap N specific keys" and "list an entire vault" are + * different privilege scopes that may reasonably use different identities. + */ +public interface PostgresAwsKmsKekParameters { + AwsAuthenticationMode getAuthenticationMode(); + + String getAccessKeyId(); + + String getSecretAccessKey(); + + Optional getEndpointOverride(); +} diff --git a/signing/src/main/java/tech/pegasys/web3signer/signing/config/PostgresKeystoreParameters.java b/signing/src/main/java/tech/pegasys/web3signer/signing/config/PostgresKeystoreParameters.java new file mode 100644 index 000000000..7bce29684 --- /dev/null +++ b/signing/src/main/java/tech/pegasys/web3signer/signing/config/PostgresKeystoreParameters.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Consensys Software Inc. + * + * 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 tech.pegasys.web3signer.signing.config; + +import java.nio.file.Path; +import java.time.Duration; + +public interface PostgresKeystoreParameters { + boolean isEnabled(); + + String getDbUrl(); + + String getDbUsername(); + + String getDbPassword(); + + Path getDbPoolConfigurationFile(); + + default Duration getDekCacheTtl() { + return Duration.ofMinutes(15); + } + + default int getDecryptionParallelism() { + return 8; + } + + default long getDbHealthCheckTimeoutMilliseconds() { + return 3000; + } +} diff --git a/signing/src/main/java/tech/pegasys/web3signer/signing/config/metadata/SignerOrigin.java b/signing/src/main/java/tech/pegasys/web3signer/signing/config/metadata/SignerOrigin.java index 656d7d7e5..c1e90d77f 100644 --- a/signing/src/main/java/tech/pegasys/web3signer/signing/config/metadata/SignerOrigin.java +++ b/signing/src/main/java/tech/pegasys/web3signer/signing/config/metadata/SignerOrigin.java @@ -18,5 +18,6 @@ public enum SignerOrigin { AWS, GCP, FILE_KEYSTORE, - FILE_RAW + FILE_RAW, + POSTGRES }