diff --git a/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 5e2ad3278e..682ef3b0ad 100644 --- a/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/besu/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -71,6 +71,8 @@ import org.hyperledger.besu.ethereum.api.jsonrpc.JsonRpcConfiguration; import org.hyperledger.besu.ethereum.api.jsonrpc.RpcApi; import org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis; import org.hyperledger.besu.ethereum.api.jsonrpc.websocket.WebSocketConfiguration; +import org.hyperledger.besu.ethereum.api.tls.FileBasedPasswordProvider; +import org.hyperledger.besu.ethereum.api.tls.TlsConfiguration; import org.hyperledger.besu.ethereum.core.Address; import org.hyperledger.besu.ethereum.core.Hash; import org.hyperledger.besu.ethereum.core.MiningParameters; @@ -447,6 +449,32 @@ public class BesuCommand implements DefaultCommandValues, Runnable { "Require authentication for the JSON-RPC HTTP service (default: ${DEFAULT-VALUE})") private final Boolean isRpcHttpAuthenticationEnabled = false; + @Option( + names = {"--rpc-http-tls-enabled"}, + description = "Enable TLS for the JSON-RPC HTTP service (default: ${DEFAULT-VALUE})") + private final Boolean isRpcHttpTlsEnabled = false; + + @Option( + names = {"--rpc-http-tls-keystore-file"}, + paramLabel = MANDATORY_FILE_FORMAT_HELP, + description = + "Keystore (PKCS#12) containing key/certificate for the JSON-RPC HTTP service. Required if TLS is enabled.") + private final Path rpcHttpTlsKeyStoreFile = null; + + @Option( + names = {"--rpc-http-tls-keystore-password-file"}, + paramLabel = MANDATORY_FILE_FORMAT_HELP, + description = + "File containing password to unlock keystore for the JSON-RPC HTTP service. Required if TLS is enabled.") + private final Path rpcHttpTlsKeyStorePasswordFile = null; + + @Option( + names = {"--rpc-http-tls-known-clients-file"}, + paramLabel = MANDATORY_FILE_FORMAT_HELP, + description = + "Require clients to present known or CA-signed certificates. File must contain common name and fingerprint if certificate is not CA-signed") + private final Path rpcHttpTlsKnownClientsFile = null; + @Option( names = {"--rpc-ws-enabled"}, description = "Set to start the JSON-RPC WebSocket service (default: ${DEFAULT-VALUE})") @@ -1153,6 +1181,15 @@ public class BesuCommand implements DefaultCommandValues, Runnable { } private JsonRpcConfiguration jsonRpcConfiguration() { + CommandLineUtils.checkOptionDependencies( + logger, + commandLine, + "--rpc-http-tls-enabled", + !isRpcHttpTlsEnabled, + asList( + "--rpc-http-tls-keystore-file", + "--rpc-http-tls-keystore-password-file", + "--rpc-http-tls-known-clients-file")); CommandLineUtils.checkOptionDependencies( logger, @@ -1167,7 +1204,11 @@ public class BesuCommand implements DefaultCommandValues, Runnable { "--rpc-http-port", "--rpc-http-authentication-enabled", "--rpc-http-authentication-credentials-file", - "--rpc-http-authentication-public-key-file")); + "--rpc-http-authentication-public-key-file", + "--rpc-http-tls-enabled", + "--rpc-http-tls-keystore-file", + "--rpc-http-tls-keystore-password-file", + "--rpc-http-tls-known-clients-file")); if (isRpcHttpAuthenticationEnabled && rpcHttpAuthenticationCredentialsFile() == null @@ -1187,9 +1228,31 @@ public class BesuCommand implements DefaultCommandValues, Runnable { jsonRpcConfiguration.setAuthenticationEnabled(isRpcHttpAuthenticationEnabled); jsonRpcConfiguration.setAuthenticationCredentialsFile(rpcHttpAuthenticationCredentialsFile()); jsonRpcConfiguration.setAuthenticationPublicKeyFile(rpcHttpAuthenticationPublicKeyFile()); + jsonRpcConfiguration.setTlsConfiguration(rpcHttpTlsConfiguration()); return jsonRpcConfiguration; } + private TlsConfiguration rpcHttpTlsConfiguration() { + if (isRpcHttpEnabled && isRpcHttpTlsEnabled) { + return new TlsConfiguration( + Optional.ofNullable(rpcHttpTlsKeyStoreFile) + .orElseThrow( + () -> + new ParameterException( + commandLine, + "Keystore file is required when TLS is enabled for JSON-RPC HTTP endpoint")), + new FileBasedPasswordProvider( + Optional.ofNullable(rpcHttpTlsKeyStorePasswordFile) + .orElseThrow( + () -> + new ParameterException( + commandLine, + "File containing password to unlock keystore is required when TLS is enabled for JSON-RPC HTTP endpoint"))), + rpcHttpTlsKnownClientsFile); + } + return null; + } + private WebSocketConfiguration webSocketConfiguration() { CommandLineUtils.checkOptionDependencies( diff --git a/besu/src/test/resources/everything_config.toml b/besu/src/test/resources/everything_config.toml index 4c69462d08..fa2d077c0f 100644 --- a/besu/src/test/resources/everything_config.toml +++ b/besu/src/test/resources/everything_config.toml @@ -51,6 +51,10 @@ rpc-http-cors-origins=["none"] rpc-http-authentication-enabled=false rpc-http-authentication-credentials-file="none" rpc-http-authentication-jwt-public-key-file="none" +rpc-http-tls-enabled=false +rpc-http-tls-keystore-file="none.pfx" +rpc-http-tls-keystore-password-file="none.passwd" +rpc-http-tls-known-clients-file="rpc_tls_clients.txt" # GRAPHQL HTTP graphql-http-enabled=false diff --git a/ethereum/api/build.gradle b/ethereum/api/build.gradle index fa9198bdae..2681538bd2 100644 --- a/ethereum/api/build.gradle +++ b/ethereum/api/build.gradle @@ -49,6 +49,7 @@ dependencies { implementation 'io.vertx:vertx-unit' implementation 'io.vertx:vertx-web' implementation 'org.apache.tuweni:tuweni-bytes' + implementation 'org.apache.tuweni:tuweni-net' implementation 'org.apache.tuweni:tuweni-toml' implementation 'org.apache.tuweni:tuweni-units' implementation 'org.bouncycastle:bcprov-jdk15on' diff --git a/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfiguration.java b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfiguration.java index 4b1fd0c1fd..ce154031e9 100644 --- a/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfiguration.java +++ b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfiguration.java @@ -14,6 +14,8 @@ */ package org.hyperledger.besu.ethereum.api.jsonrpc; +import org.hyperledger.besu.ethereum.api.tls.TlsConfiguration; + import java.io.File; import java.util.ArrayList; import java.util.Arrays; @@ -21,6 +23,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Optional; import com.google.common.base.MoreObjects; @@ -37,6 +40,7 @@ public class JsonRpcConfiguration { private boolean authenticationEnabled = false; private String authenticationCredentialsFile; private File authenticationPublicKeyFile; + private TlsConfiguration tlsConfiguration; public static JsonRpcConfiguration createDefault() { final JsonRpcConfiguration config = new JsonRpcConfiguration(); @@ -128,6 +132,14 @@ public class JsonRpcConfiguration { this.authenticationPublicKeyFile = authenticationPublicKeyFile; } + public Optional getTlsConfiguration() { + return Optional.ofNullable(tlsConfiguration); + } + + public void setTlsConfiguration(final TlsConfiguration tlsConfiguration) { + this.tlsConfiguration = tlsConfiguration; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpService.java b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpService.java index 5e8ade929d..f650d2a69a 100755 --- a/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpService.java +++ b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpService.java @@ -41,6 +41,7 @@ import org.hyperledger.besu.nat.upnp.UpnpNatManager; import org.hyperledger.besu.plugin.services.MetricsSystem; import org.hyperledger.besu.plugin.services.metrics.LabelledMetric; import org.hyperledger.besu.plugin.services.metrics.OperationTimer; +import org.hyperledger.besu.util.ExceptionUtils; import org.hyperledger.besu.util.NetworkUtility; import java.net.InetSocketAddress; @@ -60,6 +61,8 @@ import io.vertx.core.CompositeFuture; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; +import io.vertx.core.VertxException; +import io.vertx.core.http.ClientAuth; import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServer; import io.vertx.core.http.HttpServerOptions; @@ -68,6 +71,7 @@ import io.vertx.core.json.DecodeException; import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import io.vertx.core.net.PfxOptions; import io.vertx.ext.auth.User; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; @@ -75,6 +79,7 @@ import io.vertx.ext.web.handler.BodyHandler; import io.vertx.ext.web.handler.CorsHandler; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.tuweni.net.tls.VertxTrustOptions; public class JsonRpcHttpService { @@ -168,14 +173,53 @@ public class JsonRpcHttpService { public CompletableFuture start() { LOG.info("Starting JsonRPC service on {}:{}", config.getHost(), config.getPort()); - // Create the HTTP server and a router object. - httpServer = - vertx.createHttpServer( - new HttpServerOptions() - .setHost(config.getHost()) - .setPort(config.getPort()) - .setHandle100ContinueAutomatically(true)); + final CompletableFuture resultFuture = new CompletableFuture<>(); + try { + // Create the HTTP server and a router object. + httpServer = vertx.createHttpServer(getHttpServerOptions()); + httpServer + .requestHandler(buildRouter()) + .listen( + res -> { + if (!res.failed()) { + resultFuture.complete(null); + config.setPort(httpServer.actualPort()); + LOG.info( + "JsonRPC service started and listening on {}:{}{}", + config.getHost(), + config.getPort(), + tlsLogMessage()); + + natService.ifNatEnvironment( + NatMethod.UPNP, + natManager -> { + ((UpnpNatManager) natManager) + .requestPortForward( + config.getPort(), NetworkProtocol.TCP, NatServiceType.JSON_RPC); + }); + + return; + } + + httpServer = null; + resultFuture.completeExceptionally(getFailureException(res.cause())); + }); + } catch (final JsonRpcServiceException tlsException) { + httpServer = null; + resultFuture.completeExceptionally(tlsException); + } catch (final VertxException listenException) { + httpServer = null; + resultFuture.completeExceptionally( + new JsonRpcServiceException( + String.format( + "Ethereum JSON RPC listener failed to start: %s", + ExceptionUtils.rootCause(listenException).getMessage()))); + } + + return resultFuture; + } + private Router buildRouter() { // Handle json rpc requests final Router router = Router.router(vertx); @@ -222,43 +266,62 @@ public class JsonRpcHttpService { .produces(APPLICATION_JSON) .handler(AuthenticationService::handleDisabledLogin); } + return router; + } - final CompletableFuture resultFuture = new CompletableFuture<>(); - httpServer - .requestHandler(router) - .listen( - res -> { - if (!res.failed()) { - resultFuture.complete(null); - final int actualPort = httpServer.actualPort(); - LOG.info( - "JsonRPC service started and listening on {}:{}", config.getHost(), actualPort); - config.setPort(actualPort); - - natService.ifNatEnvironment( - NatMethod.UPNP, - natManager -> { - ((UpnpNatManager) natManager) - .requestPortForward( - config.getPort(), NetworkProtocol.TCP, NatServiceType.JSON_RPC); - }); + private HttpServerOptions getHttpServerOptions() { + final HttpServerOptions httpServerOptions = + new HttpServerOptions() + .setHost(config.getHost()) + .setPort(config.getPort()) + .setHandle100ContinueAutomatically(true); - return; - } - httpServer = null; - final Throwable cause = res.cause(); - if (cause instanceof SocketException) { - resultFuture.completeExceptionally( - new JsonRpcServiceException( - String.format( - "Failed to bind Ethereum JSON RPC listener to %s:%s: %s", - config.getHost(), config.getPort(), cause.getMessage()))); - return; + return applyTlsConfig(httpServerOptions); + } + + private HttpServerOptions applyTlsConfig(final HttpServerOptions httpServerOptions) { + config + .getTlsConfiguration() + .ifPresent( + tlsConfiguration -> { + try { + httpServerOptions + .setSsl(true) + .setPfxKeyCertOptions( + new PfxOptions() + .setPath(tlsConfiguration.getKeyStorePath().toString()) + .setPassword(tlsConfiguration.getKeyStorePassword())); + + tlsConfiguration + .getKnownClientsFile() + .ifPresent( + knownClientsFile -> + httpServerOptions + .setClientAuth(ClientAuth.REQUIRED) + .setTrustOptions( + VertxTrustOptions.whitelistClients(knownClientsFile))); + } catch (final RuntimeException re) { + throw new JsonRpcServiceException( + String.format( + "TLS options failed to initialise for Ethereum JSON RPC listener: %s", + re.getMessage())); } - resultFuture.completeExceptionally(cause); }); + return httpServerOptions; + } - return resultFuture; + private String tlsLogMessage() { + return config.getTlsConfiguration().isPresent() ? " with TLS enabled." : ""; + } + + private Throwable getFailureException(final Throwable listenFailure) { + if (listenFailure instanceof SocketException) { + return new JsonRpcServiceException( + String.format( + "Failed to bind Ethereum JSON RPC listener to %s:%s: %s", + config.getHost(), config.getPort(), listenFailure.getMessage())); + } + return listenFailure; } private Handler checkWhitelistHostHeader() { @@ -329,7 +392,11 @@ public class JsonRpcHttpService { if (httpServer == null) { return ""; } - return NetworkUtility.urlForSocketAddress("http", socketAddress()); + return NetworkUtility.urlForSocketAddress(getScheme(), socketAddress()); + } + + private String getScheme() { + return config.getTlsConfiguration().isPresent() ? "https" : "http"; } private void handleJsonRPCRequest(final RoutingContext routingContext) { diff --git a/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProvider.java b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProvider.java new file mode 100644 index 0000000000..9dd9004f5b --- /dev/null +++ b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.ethereum.api.tls; + +import static java.util.Objects.requireNonNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Supplier; +import java.util.stream.Stream; + +public class FileBasedPasswordProvider implements Supplier { + private final Path passwordFile; + + public FileBasedPasswordProvider(final Path passwordFile) { + requireNonNull(passwordFile, "Password file path cannot be null"); + this.passwordFile = passwordFile; + } + + @Override + public String get() { + try (final Stream fileStream = Files.lines(passwordFile)) { + return fileStream + .findFirst() + .orElseThrow( + () -> + new TlsConfigurationException( + String.format( + "Unable to read keystore password from %s", passwordFile.toString()))); + } catch (final IOException e) { + throw new TlsConfigurationException( + String.format("Unable to read keystore password file %s", passwordFile.toString()), e); + } + } +} diff --git a/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfiguration.java b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfiguration.java new file mode 100644 index 0000000000..c7262b7ebc --- /dev/null +++ b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.hyperledger.besu.ethereum.api.tls; + +import static java.util.Objects.requireNonNull; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Supplier; + +public class TlsConfiguration { + private final Path keyStorePath; + private final Supplier keyStorePasswordSupplier; + private final Path knownClientsFile; + + public TlsConfiguration( + final Path keyStorePath, + final Supplier keyStorePasswordSupplier, + final Path knownClientsFile) { + requireNonNull(keyStorePath, "Key Store Path must not be null"); + requireNonNull(keyStorePasswordSupplier, "Key Store password supplier must not be null"); + this.keyStorePath = keyStorePath; + this.keyStorePasswordSupplier = keyStorePasswordSupplier; + this.knownClientsFile = knownClientsFile; + } + + public Path getKeyStorePath() { + return keyStorePath; + } + + public String getKeyStorePassword() { + return keyStorePasswordSupplier.get(); + } + + public Optional getKnownClientsFile() { + return Optional.ofNullable(knownClientsFile); + } +} diff --git a/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfigurationException.java b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfigurationException.java new file mode 100644 index 0000000000..05cf3b04c6 --- /dev/null +++ b/ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfigurationException.java @@ -0,0 +1,26 @@ +/* + * Copyright 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.hyperledger.besu.ethereum.api.tls; + +public class TlsConfigurationException extends RuntimeException { + public TlsConfigurationException(final String message) { + super(message); + } + + public TlsConfigurationException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfigurationTest.java b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfigurationTest.java index 8355d24724..37c3743d64 100644 --- a/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfigurationTest.java +++ b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcConfigurationTest.java @@ -16,6 +16,8 @@ package org.hyperledger.besu.ethereum.api.jsonrpc; import static org.assertj.core.api.Assertions.assertThat; +import java.util.Optional; + import com.google.common.collect.Lists; import org.junit.Test; @@ -66,4 +68,10 @@ public class JsonRpcConfigurationTest { configuration.setRpcApis(Lists.newArrayList(RpcApis.DEBUG)); assertThat(configuration.getRpcApis()).containsExactly(RpcApis.DEBUG); } + + @Test + public void tlsConfigurationDefaultShouldBeEmpty() { + final JsonRpcConfiguration configuration = JsonRpcConfiguration.createDefault(); + assertThat(configuration.getTlsConfiguration()).isEqualTo(Optional.empty()); + } } diff --git a/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsMisconfigurationTest.java b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsMisconfigurationTest.java new file mode 100644 index 0000000000..18d2f9fa0e --- /dev/null +++ b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsMisconfigurationTest.java @@ -0,0 +1,260 @@ +/* + * Copyright 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.ethereum.api.jsonrpc; + +import static com.google.common.io.Resources.getResource; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.ETH; +import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.NET; +import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.WEB3; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import org.hyperledger.besu.config.StubGenesisConfigOptions; +import org.hyperledger.besu.ethereum.api.jsonrpc.health.HealthService; +import org.hyperledger.besu.ethereum.api.jsonrpc.internal.filter.FilterManager; +import org.hyperledger.besu.ethereum.api.jsonrpc.internal.methods.JsonRpcMethod; +import org.hyperledger.besu.ethereum.api.jsonrpc.methods.JsonRpcMethodsFactory; +import org.hyperledger.besu.ethereum.api.jsonrpc.websocket.WebSocketConfiguration; +import org.hyperledger.besu.ethereum.api.query.BlockchainQueries; +import org.hyperledger.besu.ethereum.api.tls.FileBasedPasswordProvider; +import org.hyperledger.besu.ethereum.api.tls.TlsConfiguration; +import org.hyperledger.besu.ethereum.blockcreation.EthHashMiningCoordinator; +import org.hyperledger.besu.ethereum.core.PrivacyParameters; +import org.hyperledger.besu.ethereum.core.Synchronizer; +import org.hyperledger.besu.ethereum.eth.EthProtocol; +import org.hyperledger.besu.ethereum.eth.transactions.TransactionPool; +import org.hyperledger.besu.ethereum.mainnet.MainnetProtocolSchedule; +import org.hyperledger.besu.ethereum.p2p.network.P2PNetwork; +import org.hyperledger.besu.ethereum.p2p.rlpx.wire.Capability; +import org.hyperledger.besu.ethereum.permissioning.AccountLocalConfigPermissioningController; +import org.hyperledger.besu.ethereum.permissioning.NodeLocalConfigPermissioningController; +import org.hyperledger.besu.metrics.noop.NoOpMetricsSystem; +import org.hyperledger.besu.metrics.prometheus.MetricsConfiguration; +import org.hyperledger.besu.nat.NatService; + +import java.io.File; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletionException; + +import io.vertx.core.Vertx; +import org.assertj.core.api.Assertions; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class JsonRpcHttpServiceTlsMisconfigurationTest { + @ClassRule public static final TemporaryFolder folder = new TemporaryFolder(); + + protected static final Vertx vertx = Vertx.vertx(); + + private static final String CLIENT_VERSION = "TestClientVersion/0.1.0"; + private static final BigInteger CHAIN_ID = BigInteger.valueOf(123); + private static final Collection JSON_RPC_APIS = List.of(ETH, NET, WEB3); + private static final String KEYSTORE_RESOURCE = "JsonRpcHttpService/rpc_keystore.pfx"; + private static final String KNOWN_CLIENTS_RESOURCE = "JsonRpcHttpService/rpc_known_clients.txt"; + private static final NatService natService = new NatService(Optional.empty()); + + private Map rpcMethods; + private JsonRpcHttpService service; + + @Before + public void beforeEach() { + final P2PNetwork peerDiscoveryMock = mock(P2PNetwork.class); + final BlockchainQueries blockchainQueries = mock(BlockchainQueries.class); + final Synchronizer synchronizer = mock(Synchronizer.class); + + final Set supportedCapabilities = new HashSet<>(); + supportedCapabilities.add(EthProtocol.ETH62); + supportedCapabilities.add(EthProtocol.ETH63); + + rpcMethods = + spy( + new JsonRpcMethodsFactory() + .methods( + CLIENT_VERSION, + CHAIN_ID, + new StubGenesisConfigOptions(), + peerDiscoveryMock, + blockchainQueries, + synchronizer, + MainnetProtocolSchedule.fromConfig( + new StubGenesisConfigOptions().constantinopleBlock(0).chainId(CHAIN_ID)), + mock(FilterManager.class), + mock(TransactionPool.class), + mock(EthHashMiningCoordinator.class), + new NoOpMetricsSystem(), + supportedCapabilities, + Optional.of(mock(AccountLocalConfigPermissioningController.class)), + Optional.of(mock(NodeLocalConfigPermissioningController.class)), + JSON_RPC_APIS, + mock(PrivacyParameters.class), + mock(JsonRpcConfiguration.class), + mock(WebSocketConfiguration.class), + mock(MetricsConfiguration.class), + natService, + Collections.emptyMap())); + } + + @After + public void shutdownServer() { + Optional.ofNullable(service).ifPresent(s -> service.stop().join()); + } + + @Test + public void exceptionRaisedWhenNonExistentKeystoreFileIsSpecified() throws IOException { + service = + createJsonRpcHttpService( + rpcMethods, createJsonRpcConfig(invalidKeystorePathTlsConfiguration())); + assertThatExceptionOfType(CompletionException.class) + .isThrownBy( + () -> { + service.start().join(); + Assertions.fail("service.start should have failed"); + }) + .withCauseInstanceOf(JsonRpcServiceException.class); + } + + @Test + public void exceptionRaisedWhenIncorrectKeystorePasswordIsSpecified() throws IOException { + service = + createJsonRpcHttpService( + rpcMethods, createJsonRpcConfig(invalidPasswordTlsConfiguration())); + assertThatExceptionOfType(CompletionException.class) + .isThrownBy( + () -> { + service.start().join(); + Assertions.fail("service.start should have failed"); + }) + .withCauseInstanceOf(JsonRpcServiceException.class) + .withMessageContaining("failed to decrypt safe contents entry"); + } + + @Test + public void exceptionRaisedWhenIncorrectKeystorePasswordFileIsSpecified() throws IOException { + service = + createJsonRpcHttpService( + rpcMethods, createJsonRpcConfig(invalidPasswordFileTlsConfiguration())); + assertThatExceptionOfType(CompletionException.class) + .isThrownBy( + () -> { + service.start().join(); + Assertions.fail("service.start should have failed"); + }) + .withCauseInstanceOf(JsonRpcServiceException.class) + .withMessageContaining("Unable to read keystore password file"); + } + + @Test + public void exceptionRaisedWhenInvalidKeystoreFileIsSpecified() throws IOException { + service = + createJsonRpcHttpService( + rpcMethods, createJsonRpcConfig(invalidKeystoreFileTlsConfiguration())); + assertThatExceptionOfType(CompletionException.class) + .isThrownBy( + () -> { + service.start().join(); + Assertions.fail("service.start should have failed"); + }) + .withCauseInstanceOf(JsonRpcServiceException.class) + .withMessageContaining("Short read of DER length"); + } + + @Test + public void exceptionRaisedWhenInvalidKnownClientsFileIsSpecified() throws IOException { + service = + createJsonRpcHttpService( + rpcMethods, createJsonRpcConfig(invalidKnownClientsTlsConfiguration())); + assertThatExceptionOfType(CompletionException.class) + .isThrownBy( + () -> { + service.start().join(); + Assertions.fail("service.start should have failed"); + }) + .withCauseInstanceOf(JsonRpcServiceException.class) + .withMessageContaining("Invalid fingerprint in"); + } + + private TlsConfiguration invalidKeystoreFileTlsConfiguration() throws IOException { + final File tempFile = folder.newFile(); + return new TlsConfiguration(tempFile.toPath(), () -> "invalid_password", getKnownClientsFile()); + } + + private TlsConfiguration invalidKeystorePathTlsConfiguration() { + return new TlsConfiguration( + Path.of("/tmp/invalidkeystore.pfx"), () -> "invalid_password", getKnownClientsFile()); + } + + private TlsConfiguration invalidPasswordTlsConfiguration() { + return new TlsConfiguration(getKeyStorePath(), () -> "invalid_password", getKnownClientsFile()); + } + + private TlsConfiguration invalidPasswordFileTlsConfiguration() { + return new TlsConfiguration( + getKeyStorePath(), + new FileBasedPasswordProvider(Path.of("/tmp/invalid_password_file.txt")), + getKnownClientsFile()); + } + + private TlsConfiguration invalidKnownClientsTlsConfiguration() throws IOException { + final Path tempKnownClientsFile = folder.newFile().toPath(); + Files.write(tempKnownClientsFile, List.of("cn invalid_sha256")); + return new TlsConfiguration(getKeyStorePath(), () -> "changeit", tempKnownClientsFile); + } + + private JsonRpcHttpService createJsonRpcHttpService( + final Map rpcMethods, final JsonRpcConfiguration jsonRpcConfig) + throws IOException { + return new JsonRpcHttpService( + vertx, + folder.newFolder().toPath(), + jsonRpcConfig, + new NoOpMetricsSystem(), + natService, + rpcMethods, + HealthService.ALWAYS_HEALTHY, + HealthService.ALWAYS_HEALTHY); + } + + private JsonRpcConfiguration createJsonRpcConfig( + final TlsConfiguration tlsConfigurationSupplier) { + final JsonRpcConfiguration config = JsonRpcConfiguration.createDefault(); + config.setPort(0); + config.setHostsWhitelist(Collections.singletonList("*")); + config.setTlsConfiguration(tlsConfigurationSupplier); + return config; + } + + private static Path getKeyStorePath() { + return Paths.get(getResource(KEYSTORE_RESOURCE).getPath()); + } + + private static Path getKnownClientsFile() { + return Paths.get(getResource(KNOWN_CLIENTS_RESOURCE).getPath()); + } +} diff --git a/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsTest.java b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsTest.java new file mode 100644 index 0000000000..db47b9d912 --- /dev/null +++ b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsTest.java @@ -0,0 +1,274 @@ +/* + * Copyright 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.ethereum.api.jsonrpc; + +import static com.google.common.io.Resources.getResource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; +import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.ETH; +import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.NET; +import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.WEB3; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +import org.hyperledger.besu.config.StubGenesisConfigOptions; +import org.hyperledger.besu.ethereum.api.jsonrpc.health.HealthService; +import org.hyperledger.besu.ethereum.api.jsonrpc.internal.filter.FilterManager; +import org.hyperledger.besu.ethereum.api.jsonrpc.internal.methods.JsonRpcMethod; +import org.hyperledger.besu.ethereum.api.jsonrpc.methods.JsonRpcMethodsFactory; +import org.hyperledger.besu.ethereum.api.jsonrpc.websocket.WebSocketConfiguration; +import org.hyperledger.besu.ethereum.api.query.BlockchainQueries; +import org.hyperledger.besu.ethereum.api.tls.FileBasedPasswordProvider; +import org.hyperledger.besu.ethereum.api.tls.TlsConfiguration; +import org.hyperledger.besu.ethereum.blockcreation.EthHashMiningCoordinator; +import org.hyperledger.besu.ethereum.core.PrivacyParameters; +import org.hyperledger.besu.ethereum.core.Synchronizer; +import org.hyperledger.besu.ethereum.eth.EthProtocol; +import org.hyperledger.besu.ethereum.eth.transactions.TransactionPool; +import org.hyperledger.besu.ethereum.mainnet.MainnetProtocolSchedule; +import org.hyperledger.besu.ethereum.p2p.network.P2PNetwork; +import org.hyperledger.besu.ethereum.p2p.rlpx.wire.Capability; +import org.hyperledger.besu.ethereum.permissioning.AccountLocalConfigPermissioningController; +import org.hyperledger.besu.ethereum.permissioning.NodeLocalConfigPermissioningController; +import org.hyperledger.besu.metrics.noop.NoOpMetricsSystem; +import org.hyperledger.besu.metrics.prometheus.MetricsConfiguration; +import org.hyperledger.besu.nat.NatService; + +import java.math.BigInteger; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import io.vertx.core.Vertx; +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.assertj.core.api.Assertions; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class JsonRpcHttpServiceTlsTest { + @ClassRule public static final TemporaryFolder folder = new TemporaryFolder(); + + protected static final Vertx vertx = Vertx.vertx(); + + private static final String JSON_HEADER = "application/json; charset=utf-8"; + private static final String CLIENT_VERSION = "TestClientVersion/0.1.0"; + private static final BigInteger CHAIN_ID = BigInteger.valueOf(123); + private static final Collection JSON_RPC_APIS = List.of(ETH, NET, WEB3); + private static final NatService natService = new NatService(Optional.empty()); + private static final String ROOT_RESOURCE = "JsonRpcHttpService/"; + private static final Path KEYSTORE_PATH = + Paths.get(getResource(ROOT_RESOURCE + "rpc_keystore.pfx").getPath()); + private static final Path KEYSTORE_PASSWORD_FILE = + Paths.get(getResource(ROOT_RESOURCE + "rpc_keystore.password").getPath()); + private static final Path KNOWN_CLIENTS_FILE = + Paths.get(getResource(ROOT_RESOURCE + "rpc_known_clients.txt").getPath()); + private static final Path CLIENT_CERT_KEYSTORE_PATH = + Paths.get(getResource(ROOT_RESOURCE + "rpc_client_keystore.pfx").getPath()); + private static final Path X_CLIENT_CERT_KEYSTORE_PATH = + Paths.get(getResource(ROOT_RESOURCE + "rpc_client_2.pfx").getPath()); + + private JsonRpcHttpService service; + private String baseUrl; + private Map rpcMethods; + private final JsonRpcTestHelper testHelper = new JsonRpcTestHelper(); + + @Before + public void initServer() throws Exception { + final P2PNetwork peerDiscoveryMock = mock(P2PNetwork.class); + final BlockchainQueries blockchainQueries = mock(BlockchainQueries.class); + final Synchronizer synchronizer = mock(Synchronizer.class); + + final Set supportedCapabilities = new HashSet<>(); + supportedCapabilities.add(EthProtocol.ETH62); + supportedCapabilities.add(EthProtocol.ETH63); + + rpcMethods = + spy( + new JsonRpcMethodsFactory() + .methods( + CLIENT_VERSION, + CHAIN_ID, + new StubGenesisConfigOptions(), + peerDiscoveryMock, + blockchainQueries, + synchronizer, + MainnetProtocolSchedule.fromConfig( + new StubGenesisConfigOptions().constantinopleBlock(0).chainId(CHAIN_ID)), + mock(FilterManager.class), + mock(TransactionPool.class), + mock(EthHashMiningCoordinator.class), + new NoOpMetricsSystem(), + supportedCapabilities, + Optional.of(mock(AccountLocalConfigPermissioningController.class)), + Optional.of(mock(NodeLocalConfigPermissioningController.class)), + JSON_RPC_APIS, + mock(PrivacyParameters.class), + mock(JsonRpcConfiguration.class), + mock(WebSocketConfiguration.class), + mock(MetricsConfiguration.class), + natService, + Collections.emptyMap())); + service = createJsonRpcHttpService(createJsonRpcConfig()); + service.start().join(); + baseUrl = service.url(); + } + + private JsonRpcHttpService createJsonRpcHttpService(final JsonRpcConfiguration jsonRpcConfig) + throws Exception { + return new JsonRpcHttpService( + vertx, + folder.newFolder().toPath(), + jsonRpcConfig, + new NoOpMetricsSystem(), + natService, + rpcMethods, + HealthService.ALWAYS_HEALTHY, + HealthService.ALWAYS_HEALTHY); + } + + private JsonRpcConfiguration createJsonRpcConfig() { + final JsonRpcConfiguration config = JsonRpcConfiguration.createDefault(); + config.setPort(0); + config.setHostsWhitelist(Collections.singletonList("*")); + config.setTlsConfiguration(getRpcHttpTlsConfiguration()); + return config; + } + + private TlsConfiguration getRpcHttpTlsConfiguration() { + return new TlsConfiguration( + KEYSTORE_PATH, new FileBasedPasswordProvider(KEYSTORE_PASSWORD_FILE), KNOWN_CLIENTS_FILE); + } + + @After + public void shutdownServer() { + service.stop().join(); + } + + @Test + public void connectionFailsWhenTlsClientAuthIsNotProvided() { + final String id = "123"; + final String json = + "{\"jsonrpc\":\"2.0\",\"id\":" + Json.encode(id) + ",\"method\":\"net_version\"}"; + + final OkHttpClient httpClient = getTlsHttpClientWithoutClientAuthentication(); + assertThatIOException() + .isThrownBy( + () -> { + try (final Response response = httpClient.newCall(buildPostRequest(json)).execute()) { + Assertions.fail("Call should have failed. Got: " + response); + } catch (final Exception e) { + e.printStackTrace(); + throw e; + } + }); + } + + @Test + public void connectionFailsWhenClientIsNotWhitelisted() { + final String id = "123"; + final String json = + "{\"jsonrpc\":\"2.0\",\"id\":" + Json.encode(id) + ",\"method\":\"net_version\"}"; + + final OkHttpClient httpClient = getTlsHttpClientNotTrustedWithServer(); + assertThatIOException() + .isThrownBy( + () -> { + try (final Response response = httpClient.newCall(buildPostRequest(json)).execute()) { + Assertions.fail("Call should have failed. Got: " + response); + } catch (final Exception e) { + e.printStackTrace(); + throw e; + } + }); + } + + @Test + public void netVersionSuccessfulOnTls() throws Exception { + final String id = "123"; + final String json = + "{\"jsonrpc\":\"2.0\",\"id\":" + Json.encode(id) + ",\"method\":\"net_version\"}"; + + final OkHttpClient httpClient = getTlsHttpClient(); + try (final Response response = httpClient.newCall(buildPostRequest(json)).execute()) { + + assertThat(response.code()).isEqualTo(200); + // Check general format of result + final ResponseBody body = response.body(); + assertThat(body).isNotNull(); + final JsonObject jsonObject = new JsonObject(body.string()); + testHelper.assertValidJsonRpcResult(jsonObject, id); + // Check result + final String result = jsonObject.getString("result"); + assertThat(result).isEqualTo(String.valueOf(CHAIN_ID)); + } catch (final Exception e) { + e.printStackTrace(); + throw e; + } + } + + private OkHttpClient getTlsHttpClient() { + final TlsConfiguration serverTrustConfiguration = + new TlsConfiguration( + KEYSTORE_PATH, new FileBasedPasswordProvider(KEYSTORE_PASSWORD_FILE), null); + final TlsConfiguration clientCertConfiguration = + new TlsConfiguration( + CLIENT_CERT_KEYSTORE_PATH, new FileBasedPasswordProvider(KEYSTORE_PASSWORD_FILE), null); + return TlsHttpClient.fromServerTrustAndClientCertConfiguration( + serverTrustConfiguration, clientCertConfiguration) + .getHttpClient(); + } + + private OkHttpClient getTlsHttpClientNotTrustedWithServer() { + final TlsConfiguration serverTrustConfiguration = + new TlsConfiguration( + KEYSTORE_PATH, new FileBasedPasswordProvider(KEYSTORE_PASSWORD_FILE), null); + final TlsConfiguration clientCertConfiguration = + new TlsConfiguration( + X_CLIENT_CERT_KEYSTORE_PATH, + new FileBasedPasswordProvider(KEYSTORE_PASSWORD_FILE), + null); + return TlsHttpClient.fromServerTrustAndClientCertConfiguration( + serverTrustConfiguration, clientCertConfiguration) + .getHttpClient(); + } + + private OkHttpClient getTlsHttpClientWithoutClientAuthentication() { + final TlsConfiguration serverTrustConfiguration = + new TlsConfiguration( + KEYSTORE_PATH, new FileBasedPasswordProvider(KEYSTORE_PASSWORD_FILE), null); + return TlsHttpClient.fromServerTrustConfiguration(serverTrustConfiguration).getHttpClient(); + } + + private Request buildPostRequest(final String json) { + final RequestBody body = RequestBody.create(json, MediaType.parse(JSON_HEADER)); + return new Request.Builder().post(body).url(baseUrl).build(); + } +} diff --git a/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/TlsHttpClient.java b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/TlsHttpClient.java new file mode 100644 index 0000000000..fddd9eba82 --- /dev/null +++ b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/TlsHttpClient.java @@ -0,0 +1,144 @@ +/* + * Copyright 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.ethereum.api.jsonrpc; + +import static org.hyperledger.besu.crypto.SecureRandomProvider.createSecureRandom; + +import org.hyperledger.besu.ethereum.api.tls.TlsConfiguration; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.OkHttpClient; + +public class TlsHttpClient { + private final TlsConfiguration serverTrustConfiguration; + private final TlsConfiguration clientCertConfiguration; + private TrustManagerFactory trustManagerFactory; + private KeyManagerFactory keyManagerFactory; + private OkHttpClient client; + + private TlsHttpClient( + final TlsConfiguration serverTrustConfiguration, + final TlsConfiguration clientCertConfiguration) { + this.serverTrustConfiguration = serverTrustConfiguration; + this.clientCertConfiguration = clientCertConfiguration; + } + + public static TlsHttpClient fromServerTrustConfiguration( + final TlsConfiguration serverTrustConfiguration) { + final TlsHttpClient tlsHttpClient = new TlsHttpClient(serverTrustConfiguration, null); + tlsHttpClient.initHttpClient(); + return tlsHttpClient; + } + + public static TlsHttpClient fromServerTrustAndClientCertConfiguration( + final TlsConfiguration serverTrustConfiguration, + final TlsConfiguration clientCertConfiguration) { + final TlsHttpClient tlsHttpClient = + new TlsHttpClient(serverTrustConfiguration, clientCertConfiguration); + tlsHttpClient.initHttpClient(); + return tlsHttpClient; + } + + public OkHttpClient getHttpClient() { + return client; + } + + private void initHttpClient() { + initTrustManagerFactory(); + initKeyManagerFactory(); + try { + client = + new OkHttpClient.Builder() + .sslSocketFactory( + getCustomSslContext().getSocketFactory(), + (X509TrustManager) + trustManagerFactory.getTrustManagers()[0]) // we only have one trust manager + .build(); + } catch (final GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + + private void initTrustManagerFactory() { + try { + final KeyStore trustStore = + loadP12KeyStore( + serverTrustConfiguration.getKeyStorePath(), + serverTrustConfiguration.getKeyStorePassword().toCharArray()); + final TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + this.trustManagerFactory = trustManagerFactory; + } catch (final GeneralSecurityException gse) { + throw new RuntimeException("Unable to load trust manager factory", gse); + } + } + + private void initKeyManagerFactory() { + if (!isClientAuthRequired()) { + return; + } + + try { + final char[] keyStorePassword = clientCertConfiguration.getKeyStorePassword().toCharArray(); + final KeyStore keyStore = + loadP12KeyStore(clientCertConfiguration.getKeyStorePath(), keyStorePassword); + final KeyManagerFactory keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, keyStorePassword); + this.keyManagerFactory = keyManagerFactory; + } catch (final GeneralSecurityException gse) { + throw new RuntimeException("Unable to load key manager factory", gse); + } + } + + private boolean isClientAuthRequired() { + return clientCertConfiguration != null; + } + + private KeyStore loadP12KeyStore(final Path keyStore, final char[] password) + throws KeyStoreException, NoSuchAlgorithmException, CertificateException { + final KeyStore store = KeyStore.getInstance("pkcs12"); + try (final InputStream keystoreStream = Files.newInputStream(keyStore)) { + store.load(keystoreStream, password); + } catch (final IOException e) { + throw new RuntimeException("Unable to load keystore.", e); + } + return store; + } + + private SSLContext getCustomSslContext() throws GeneralSecurityException { + final KeyManager[] km = isClientAuthRequired() ? keyManagerFactory.getKeyManagers() : null; + final TrustManager[] tm = trustManagerFactory.getTrustManagers(); + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(km, tm, createSecureRandom()); + return sslContext; + } +} diff --git a/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProviderTest.java b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProviderTest.java new file mode 100644 index 0000000000..2034f63147 --- /dev/null +++ b/ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProviderTest.java @@ -0,0 +1,64 @@ +/* + * + * Copyright 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. + * + * SPDX-License-Identifier: Apache-2.0 + * + */ +package org.hyperledger.besu.ethereum.api.tls; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class FileBasedPasswordProviderTest { + + @Rule public final TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void passwordCanBeReadFromFile() throws IOException { + final Path passwordFile = folder.newFile().toPath(); + Files.write(passwordFile, List.of("line1", "line2")); + + final String password = new FileBasedPasswordProvider(passwordFile).get(); + Assertions.assertThat(password).isEqualTo("line1"); + } + + @Test + public void exceptionRaisedFromReadingEmptyFile() throws IOException { + final Path passwordFile = folder.newFile().toPath(); + Files.write(passwordFile, new byte[0]); + + Assertions.assertThatExceptionOfType(TlsConfigurationException.class) + .isThrownBy( + () -> { + new FileBasedPasswordProvider(passwordFile).get(); + }) + .withMessageContaining("Unable to read keystore password from"); + } + + @Test + public void exceptionRaisedFromReadingNonExistingFile() throws IOException { + Assertions.assertThatExceptionOfType(TlsConfigurationException.class) + .isThrownBy( + () -> { + new FileBasedPasswordProvider(Path.of("/tmp/invalid_file.txt")).get(); + }) + .withMessageContaining("Unable to read keystore password file"); + } +} diff --git a/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_2.pfx b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_2.pfx new file mode 100644 index 0000000000..def1c92659 Binary files /dev/null and b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_2.pfx differ diff --git a/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_keystore.pfx b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_keystore.pfx new file mode 100644 index 0000000000..04ebd3ce70 Binary files /dev/null and b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_keystore.pfx differ diff --git a/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.password b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.password new file mode 100644 index 0000000000..5bbaf87581 --- /dev/null +++ b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.password @@ -0,0 +1 @@ +changeit \ No newline at end of file diff --git a/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.pfx b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.pfx new file mode 100644 index 0000000000..de121eebe2 Binary files /dev/null and b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.pfx differ diff --git a/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.readme b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.readme new file mode 100644 index 0000000000..38c713903e --- /dev/null +++ b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.readme @@ -0,0 +1,16 @@ +The test key stores are created using following commands: + +keytool -genkeypair -keystore rpc_keystore.pfx -storetype PKCS12 -storepass changeit -alias testrpcserver \ +-keyalg RSA -keysize 2048 -validity 109500 -dname "CN=localhost, OU=PegaSys, O=ConsenSys, L=Brisbane, ST=QLD, C=AU" \ +-ext san=dns:localhost,ip:127.0.0.1 + +keytool -genkeypair -keystore rpc_client_keystore.pfx -storetype PKCS12 -storepass changeit -alias testrpcclient \ +-keyalg RSA -keysize 2048 -validity 109500 -dname "CN=localhost, OU=PegaSys, O=ConsenSys, L=Brisbane, ST=QLD, C=AU" \ +-ext san=dns:localhost,ip:127.0.0.1 + +keytool -genkeypair -keystore rpc_client_2.pfx -storetype PKCS12 -storepass changeit -alias testrpcclient2 \ +-keyalg RSA -keysize 2048 -validity 109500 -dname "CN=localhost, OU=PegaSys, O=ConsenSys, L=Brisbane, ST=QLD, C=AU" \ +-ext san=dns:localhost,ip:127.0.0.1 + +The fingerprint is obtained using following command: +keytool -list -keystore rpc_client_keystore.pfx \ No newline at end of file diff --git a/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_known_clients.txt b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_known_clients.txt new file mode 100644 index 0000000000..22874cb6db --- /dev/null +++ b/ethereum/api/src/test/resources/JsonRpcHttpService/rpc_known_clients.txt @@ -0,0 +1,3 @@ +#client certificates fingerprint obtained from rpc_client_keystore.pfx +localhost DF:65:B8:02:08:5E:91:82:0F:91:F5:1C:96:56:92:C4:1A:F6:C6:27:FD:6C:FC:31:F2:BB:90:17:22:59:5B:50 +127.0.0.1 DF:65:B8:02:08:5E:91:82:0F:91:F5:1C:96:56:92:C4:1A:F6:C6:27:FD:6C:FC:31:F2:BB:90:17:22:59:5B:50 \ No newline at end of file diff --git a/gradle/versions.gradle b/gradle/versions.gradle index 77a0b68fd3..414bb78c5d 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -70,6 +70,7 @@ dependencyManagement { dependency 'org.apache.tuweni:tuweni-io:0.10.0' dependency 'org.apache.tuweni:tuweni-toml:0.10.0' dependency 'org.apache.tuweni:tuweni-units:0.10.0' + dependency 'org.apache.tuweni:tuweni-net:0.10.0' dependency 'org.assertj:assertj-core:3.14.0'