From cb56b3c7455f70394a1a7a4c41125c798e0312c3 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Mon, 13 Jan 2020 21:11:47 +1000 Subject: [PATCH] [BESU-77] - Enable TLS for JSON-RPC HTTP Service (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose following new command line parameters to enable TLS on Ethereum JSON-RPC HTTP interface to allow clients like Ethsigner to connect via TLS --rpc-http-tls-enabled=true (Optional - Only required if --rpc-http-enabled is set to true) Set to ‘true’ to enable TLS. false by default. --rpc-http-tls-keystore-file="/path/to/cert.pfx" (Must be specified if TLS is enabled) Path to PKCS12 format key store which contains server's certificate and it's private key --rpc-http-tls-keystore-password-file="/path/to/cert.passwd" (Must be specified if TLS is enabled) Path to the text file containing password for unlocking key store. --rpc-http-tls-known-clients-file="/path/to/rpc_tls_clients.txt" (Optional) Path to a plain text file containing space separated client’s certificate’s common name and its sha-256 fingerprints when they are not signed by a known CA. The presence of this file (even empty) will enable TLS client authentication i.e. the client will present its certificate to server on TLS handshake and server will establish that the client’s certificate is either signed by a proper/known CA otherwise server trusts client's certificate by reading it's sha-256 fingerprint from known clients file specified above. The format of the file is (as an example): 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 Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/cli/BesuCommand.java | 65 ++++- .../src/test/resources/everything_config.toml | 4 + ethereum/api/build.gradle | 1 + .../api/jsonrpc/JsonRpcConfiguration.java | 12 + .../api/jsonrpc/JsonRpcHttpService.java | 147 +++++++--- .../api/tls/FileBasedPasswordProvider.java | 48 +++ .../ethereum/api/tls/TlsConfiguration.java | 51 ++++ .../api/tls/TlsConfigurationException.java | 26 ++ .../api/jsonrpc/JsonRpcConfigurationTest.java | 8 + ...RpcHttpServiceTlsMisconfigurationTest.java | 260 +++++++++++++++++ .../jsonrpc/JsonRpcHttpServiceTlsTest.java | 274 ++++++++++++++++++ .../ethereum/api/jsonrpc/TlsHttpClient.java | 144 +++++++++ .../tls/FileBasedPasswordProviderTest.java | 64 ++++ .../JsonRpcHttpService/rpc_client_2.pfx | Bin 0 -> 2639 bytes .../rpc_client_keystore.pfx | Bin 0 -> 2637 bytes .../JsonRpcHttpService/rpc_keystore.password | 1 + .../JsonRpcHttpService/rpc_keystore.pfx | Bin 0 -> 2637 bytes .../JsonRpcHttpService/rpc_keystore.readme | 16 + .../JsonRpcHttpService/rpc_known_clients.txt | 3 + gradle/versions.gradle | 1 + 20 files changed, 1084 insertions(+), 41 deletions(-) create mode 100644 ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProvider.java create mode 100644 ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfiguration.java create mode 100644 ethereum/api/src/main/java/org/hyperledger/besu/ethereum/api/tls/TlsConfigurationException.java create mode 100644 ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsMisconfigurationTest.java create mode 100644 ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/JsonRpcHttpServiceTlsTest.java create mode 100644 ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/jsonrpc/TlsHttpClient.java create mode 100644 ethereum/api/src/test/java/org/hyperledger/besu/ethereum/api/tls/FileBasedPasswordProviderTest.java create mode 100644 ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_2.pfx create mode 100644 ethereum/api/src/test/resources/JsonRpcHttpService/rpc_client_keystore.pfx create mode 100644 ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.password create mode 100644 ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.pfx create mode 100644 ethereum/api/src/test/resources/JsonRpcHttpService/rpc_keystore.readme create mode 100644 ethereum/api/src/test/resources/JsonRpcHttpService/rpc_known_clients.txt 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 0000000000000000000000000000000000000000..def1c92659abb5d56a6481dd9f22e71725db196f GIT binary patch literal 2639 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..04ebd3ce70f2a04d0d2237adebaea00c06609b42 GIT binary patch literal 2637 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