Skip to content
4 changes: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)\\.)")
}
}

Expand Down Expand Up @@ -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" }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "<AUTHENTICATION_MODE>")
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 = "<ACCESS_KEY_ID>")
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 = "<SECRET_ACCESS_KEY>")
private String secretAccessKey;

@Option(
names = POSTGRES_KEYSTORE_AWS_KMS_ENDPOINT_OVERRIDE_OPTION,
description = "Override the AWS KMS endpoint.",
paramLabel = "<URI>")
private Optional<URI> endpointOverride = Optional.empty();

@Override
public AwsAuthenticationMode getAuthenticationMode() {
return authenticationMode;
}

@Override
public String getAccessKeyId() {
return accessKeyId;
}

@Override
public String getSecretAccessKey() {
return secretAccessKey;
}

@Override
public Optional<URI> getEndpointOverride() {
return endpointOverride;
}
}
Original file line number Diff line number Diff line change
@@ -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 = "<BOOL>",
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 = "<jdbc url>")
private String dbUrl;

@Option(
names = POSTGRES_KEYSTORE_DB_USERNAME_OPTION,
description = "The username to use when connecting to the postgres keystore database",
paramLabel = "<jdbc user>")
private String dbUsername;

@Option(
names = POSTGRES_KEYSTORE_DB_PASSWORD_OPTION,
description = "The password to use when connecting to the postgres keystore database",
paramLabel = "<jdbc password>")
private String dbPassword;

@Option(
names = POSTGRES_KEYSTORE_DB_POOL_CONFIG_FILE_OPTION,
description = "Optional configuration file for Hikari database connection pool.",
paramLabel = "<hikari configuration properties file>")
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 = "<MINUTES>")
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 = "<INT>",
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 = "<timeout in milliseconds>")
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -170,6 +172,8 @@ private static class NetworkCliCompletionCandidates extends ArrayList<String> {
@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;
Expand All @@ -189,6 +193,8 @@ public Runner createRunner() {
keystoreParameters,
awsSecretsManagerParameters,
gcpSecretManagerParameters,
postgresKeystoreParameters,
postgresAwsKmsKekParameters,
eth2Spec,
keyManagerApiParameters,
signingExtEnabled,
Expand Down Expand Up @@ -273,9 +279,45 @@ protected void validateArgs() {
validateKeystoreParameters(keystoreParameters);
validateAwsSecretsManageParameters();
validateGcpSecretManagerParameters();
validatePostgresKeystoreParameters();
commitBoostApiParameters.validateParameters();
}

private void validatePostgresKeystoreParameters() {
if (postgresKeystoreParameters.isEnabled()) {
final List<String> 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<String> missingPostgresKeystoreFields() {
final List<String> 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<String> specifiedAuthModeMissingFields =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading