From 2db210a898dcfcdd2fdc25207687c94f6e644b70 Mon Sep 17 00:00:00 2001 From: Dmitriy Tverdiakov <11927660+injectives@users.noreply.github.com> Date: Wed, 6 Mar 2024 17:05:23 +0000 Subject: [PATCH] Introduce mTLS support (#1543) * Introduce mTLS support Please note that this feature is in preview. * Make hasUpdate unnecessary * Fix inspection errors --- benchkit-backend/pom.xml | 2 +- bundle/pom.xml | 2 +- driver/pom.xml | 2 +- .../org/neo4j/driver/ClientCertificate.java | 29 ++ .../driver/ClientCertificateManager.java | 48 ++ .../driver/ClientCertificateManagers.java | 41 ++ .../org/neo4j/driver/ClientCertificates.java | 54 ++ .../java/org/neo4j/driver/GraphDatabase.java | 244 ++++++++- .../RotatingClientCertificateManager.java | 33 ++ .../neo4j/driver/internal/DriverFactory.java | 13 +- .../internal/InternalClientCertificate.java | 23 + ...ernalRotatingClientCertificateManager.java | 57 +++ .../ValidatingClientCertificateManager.java | 43 ++ .../async/connection/ChannelConnector.java | 6 +- .../connection/ChannelConnectorImpl.java | 66 ++- .../connection/DeferredChannelFuture.java | 296 +++++++++++ .../connection/NettyChannelInitializer.java | 6 +- .../internal/async/pool/NettyChannelPool.java | 33 +- .../neo4j/driver/internal/pki/DerUtils.java | 204 ++++++++ .../neo4j/driver/internal/pki/PemFormats.java | 463 ++++++++++++++++++ .../neo4j/driver/internal/pki/PemParser.java | 150 ++++++ .../internal/security/SSLContextManager.java | 154 ++++++ .../internal/security/SecurityPlan.java | 12 +- .../internal/security/SecurityPlanImpl.java | 103 +++- .../internal/security/SecurityPlans.java | 38 +- .../driver/ClientCertificateManagersTest.java | 34 ++ .../integration/ChannelConnectorImplIT.java | 60 ++- .../integration/ConnectionHandlingIT.java | 1 + .../driver/integration/ConnectionPoolIT.java | 2 +- .../org/neo4j/driver/integration/ErrorIT.java | 4 +- .../driver/integration/ServerKilledIT.java | 2 +- .../driver/integration/SessionBoltV3IT.java | 8 +- .../neo4j/driver/integration/SessionIT.java | 10 +- .../driver/integration/SharedEventLoopIT.java | 1 + .../driver/integration/TransactionIT.java | 2 +- .../integration/UnmanagedTransactionIT.java | 2 +- .../internal/CustomSecurityPlanTest.java | 1 + .../driver/internal/DriverFactoryTest.java | 4 +- ...RotatingClientCertificateManagerTests.java | 76 +++ .../NettyChannelInitializerTest.java | 31 +- .../security/SSLContextManagerTests.java | 214 ++++++++ .../internal/security/SecurityPlans.java | 31 +- .../util/io/ChannelTrackingConnector.java | 13 +- .../neo4j/driver/ClientCertificatesTests.java | 62 +++ examples/pom.xml | 2 +- pom.xml | 2 +- testkit-backend/pom.xml | 2 +- .../org/testkit/backend/TestkitState.java | 18 + .../messages/requests/ClientCertificate.java | 38 ++ .../ClientCertificateProviderClose.java | 45 ++ .../ClientCertificateProviderCompleted.java | 39 ++ .../messages/requests/GetFeatures.java | 3 +- .../NewClientCertificateProvider.java | 94 ++++ .../backend/messages/requests/NewDriver.java | 22 +- .../messages/requests/TestkitRequest.java | 5 +- .../responses/ClientCertificateProvider.java | 37 ++ .../ClientCertificateProviderRequest.java | 43 ++ testkit-tests/pom.xml | 2 +- 58 files changed, 2865 insertions(+), 167 deletions(-) create mode 100644 driver/src/main/java/org/neo4j/driver/ClientCertificate.java create mode 100644 driver/src/main/java/org/neo4j/driver/ClientCertificateManager.java create mode 100644 driver/src/main/java/org/neo4j/driver/ClientCertificateManagers.java create mode 100644 driver/src/main/java/org/neo4j/driver/ClientCertificates.java create mode 100644 driver/src/main/java/org/neo4j/driver/RotatingClientCertificateManager.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/InternalClientCertificate.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManager.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/ValidatingClientCertificateManager.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/async/connection/DeferredChannelFuture.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/pki/DerUtils.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/pki/PemFormats.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/pki/PemParser.java create mode 100644 driver/src/main/java/org/neo4j/driver/internal/security/SSLContextManager.java create mode 100644 driver/src/test/java/org/neo4j/driver/ClientCertificateManagersTest.java create mode 100644 driver/src/test/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManagerTests.java create mode 100644 driver/src/test/java/org/neo4j/driver/internal/security/SSLContextManagerTests.java create mode 100644 driver/src/test/java/org/neo4j/driver/org/neo4j/driver/ClientCertificatesTests.java create mode 100644 testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificate.java create mode 100644 testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderClose.java create mode 100644 testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderCompleted.java create mode 100644 testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewClientCertificateProvider.java create mode 100644 testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProvider.java create mode 100644 testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProviderRequest.java diff --git a/benchkit-backend/pom.xml b/benchkit-backend/pom.xml index 442e88a26d..445a078b33 100644 --- a/benchkit-backend/pom.xml +++ b/benchkit-backend/pom.xml @@ -7,7 +7,7 @@ neo4j-java-driver-parent org.neo4j.driver - 5.18-SNAPSHOT + 5.19-SNAPSHOT benchkit-backend diff --git a/bundle/pom.xml b/bundle/pom.xml index 2d108187c9..b3881b283d 100644 --- a/bundle/pom.xml +++ b/bundle/pom.xml @@ -6,7 +6,7 @@ org.neo4j.driver neo4j-java-driver-parent - 5.18-SNAPSHOT + 5.19-SNAPSHOT .. diff --git a/driver/pom.xml b/driver/pom.xml index 6cee687590..37433c31b2 100644 --- a/driver/pom.xml +++ b/driver/pom.xml @@ -6,7 +6,7 @@ org.neo4j.driver neo4j-java-driver-parent - 5.18-SNAPSHOT + 5.19-SNAPSHOT neo4j-java-driver diff --git a/driver/src/main/java/org/neo4j/driver/ClientCertificate.java b/driver/src/main/java/org/neo4j/driver/ClientCertificate.java new file mode 100644 index 0000000000..e8f65882d0 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/ClientCertificate.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import org.neo4j.driver.internal.InternalClientCertificate; +import org.neo4j.driver.util.Preview; + +/** + * An opaque container for client certificate used for mTLS. + *

+ * Use {@link ClientCertificates} to create new instances. + * @since 5.19 + */ +@Preview(name = "mTLS") +public sealed interface ClientCertificate permits InternalClientCertificate {} diff --git a/driver/src/main/java/org/neo4j/driver/ClientCertificateManager.java b/driver/src/main/java/org/neo4j/driver/ClientCertificateManager.java new file mode 100644 index 0000000000..772b71400e --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/ClientCertificateManager.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import java.util.concurrent.CompletionStage; +import org.neo4j.driver.util.Preview; + +/** + * A manager of {@link ClientCertificate} instances used by the driver for mTLS. + *

+ * The driver uses the {@link ClientCertificate} supplied by the manager for setting up new connections. Therefore, + * a change of the certificate affects subsequent new connections only. + *

+ * The manager must never return {@literal null}. Exceptions must be emitted via the {@link CompletionStage} only. + *

+ * All implementations of this interface must be thread-safe and non-blocking for caller threads. For instance, IO + * operations must not done on the calling thread. + * @since 5.19 + */ +@Preview(name = "mTLS") +public interface ClientCertificateManager { + /** + * Returns a {@link CompletionStage} of a new {@link ClientCertificate}. + *

+ * The first {@link CompletionStage} supplied to the driver must not complete with {@literal null} to ensure the + * driver has the initial {@link ClientCertificate}. + *

+ * Afterwards, the {@link CompletionStage} may complete with {@literal null} to indicate no update. If the + * {@link CompletionStage} completes with {@link ClientCertificate}, the driver loads the supplied + * {@link ClientCertificate}. + * @return the certificate stage, must not be {@literal null} + */ + CompletionStage getClientCertificate(); +} diff --git a/driver/src/main/java/org/neo4j/driver/ClientCertificateManagers.java b/driver/src/main/java/org/neo4j/driver/ClientCertificateManagers.java new file mode 100644 index 0000000000..b475092728 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/ClientCertificateManagers.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import org.neo4j.driver.internal.InternalRotatingClientCertificateManager; +import org.neo4j.driver.util.Preview; + +/** + * Implementations of {@link ClientCertificateManager}. + * + * @since 5.19 + */ +@Preview(name = "mTLS") +public final class ClientCertificateManagers { + private ClientCertificateManagers() {} + + /** + * Returns a {@link RotatingClientCertificateManager} that supports rotating its {@link ClientCertificate} using the + * {@link RotatingClientCertificateManager#rotate(ClientCertificate)} method. + * + * @param clientCertificate an initial certificate, must not be {@literal null} + * @return a new manager + */ + public static RotatingClientCertificateManager rotating(ClientCertificate clientCertificate) { + return new InternalRotatingClientCertificateManager(clientCertificate); + } +} diff --git a/driver/src/main/java/org/neo4j/driver/ClientCertificates.java b/driver/src/main/java/org/neo4j/driver/ClientCertificates.java new file mode 100644 index 0000000000..264be1ae4d --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/ClientCertificates.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import java.io.File; +import java.util.Objects; +import org.neo4j.driver.internal.InternalClientCertificate; +import org.neo4j.driver.util.Preview; + +/** + * Creates new instances of {@link ClientCertificate}. + * @since 5.19 + */ +@Preview(name = "mTLS") +public final class ClientCertificates { + private ClientCertificates() {} + + /** + * Creates a new instance of {@link ClientCertificate} with certificate {@link File} and private key {@link File}. + * @param certificate the certificate file, must not be {@literal null} + * @param privateKey the key file, must not be {@literal null} + * @return the client certificate + */ + public static ClientCertificate of(File certificate, File privateKey) { + return of(certificate, privateKey, null); + } + + /** + * Creates a new instance of {@link ClientCertificate} with certificate {@link File}, private key {@link File} and key password. + * @param certificate the certificate file, must not be {@literal null} + * @param privateKey the key file, must not be {@literal null} + * @param password the key password + * @return the client certificate + */ + public static ClientCertificate of(File certificate, File privateKey, String password) { + Objects.requireNonNull(certificate); + Objects.requireNonNull(privateKey); + return new InternalClientCertificate(certificate, privateKey, password); + } +} diff --git a/driver/src/main/java/org/neo4j/driver/GraphDatabase.java b/driver/src/main/java/org/neo4j/driver/GraphDatabase.java index 4f068f2ac4..d2c5263118 100644 --- a/driver/src/main/java/org/neo4j/driver/GraphDatabase.java +++ b/driver/src/main/java/org/neo4j/driver/GraphDatabase.java @@ -20,11 +20,14 @@ import java.net.URI; import org.neo4j.driver.internal.DriverFactory; +import org.neo4j.driver.internal.ValidatingClientCertificateManager; import org.neo4j.driver.internal.security.StaticAuthTokenManager; import org.neo4j.driver.internal.security.ValidatingAuthTokenManager; +import org.neo4j.driver.util.Preview; /** * Creates {@link Driver drivers}, optionally letting you {@link #driver(URI, Config)} to configure them. + * * @see Driver * @since 1.0 */ @@ -54,7 +57,7 @@ public static Driver driver(URI uri) { /** * Return a driver for a Neo4j instance with custom configuration. * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param config user defined configuration * @return a new driver to the database instance specified by the URL */ @@ -65,7 +68,7 @@ public static Driver driver(URI uri, Config config) { /** * Return a driver for a Neo4j instance with custom configuration. * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param config user defined configuration * @return a new driver to the database instance specified by the URL */ @@ -76,7 +79,7 @@ public static Driver driver(String uri, Config config) { /** * Return a driver for a Neo4j instance with the default configuration settings * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param authToken authentication to use, see {@link AuthTokens} * @return a new driver to the database instance specified by the URL */ @@ -87,7 +90,7 @@ public static Driver driver(String uri, AuthToken authToken) { /** * Return a driver for a Neo4j instance with the default configuration settings * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param authToken authentication to use, see {@link AuthTokens} * @return a new driver to the database instance specified by the URL */ @@ -98,9 +101,9 @@ public static Driver driver(URI uri, AuthToken authToken) { /** * Return a driver for a Neo4j instance with custom configuration. * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param authToken authentication to use, see {@link AuthTokens} - * @param config user defined configuration + * @param config user defined configuration * @return a new driver to the database instance specified by the URL */ public static Driver driver(String uri, AuthToken authToken, Config config) { @@ -119,18 +122,18 @@ public static Driver driver(URI uri, AuthToken authToken, Config config) { if (authToken == null) { authToken = AuthTokens.none(); } - return driver(uri, authToken, config, new DriverFactory()); + return driver(uri, authToken, null, config, new DriverFactory()); } /** * Returns a driver for a Neo4j instance with the default configuration settings and the provided * {@link AuthTokenManager}. * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param authTokenManager manager to use * @return a new driver to the database instance specified by the URL - * @since 5.8 * @see AuthTokenManager + * @since 5.8 */ public static Driver driver(URI uri, AuthTokenManager authTokenManager) { return driver(uri, authTokenManager, Config.defaultConfig()); @@ -140,11 +143,11 @@ public static Driver driver(URI uri, AuthTokenManager authTokenManager) { * Returns a driver for a Neo4j instance with the default configuration settings and the provided * {@link AuthTokenManager}. * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param authTokenManager manager to use * @return a new driver to the database instance specified by the URL - * @since 5.8 * @see AuthTokenManager + * @since 5.8 */ public static Driver driver(String uri, AuthTokenManager authTokenManager) { return driver(URI.create(uri), authTokenManager); @@ -153,42 +156,239 @@ public static Driver driver(String uri, AuthTokenManager authTokenManager) { /** * Returns a driver for a Neo4j instance with the provided {@link AuthTokenManager} and custom configuration. * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param authTokenManager manager to use - * @param config user defined configuration + * @param config user defined configuration * @return a new driver to the database instance specified by the URL - * @since 5.8 * @see AuthTokenManager + * @since 5.8 */ public static Driver driver(URI uri, AuthTokenManager authTokenManager, Config config) { - return driver(uri, authTokenManager, config, new DriverFactory()); + return driver(uri, authTokenManager, null, config, new DriverFactory()); } /** * Returns a driver for a Neo4j instance with the provided {@link AuthTokenManager} and custom configuration. * - * @param uri the URL to a Neo4j instance + * @param uri the URL to a Neo4j instance * @param authTokenManager manager to use - * @param config user defined configuration + * @param config user defined configuration * @return a new driver to the database instance specified by the URL - * @since 5.8 * @see AuthTokenManager + * @since 5.8 */ public static Driver driver(String uri, AuthTokenManager authTokenManager, Config config) { return driver(URI.create(uri), authTokenManager, config); } - private static Driver driver(URI uri, AuthToken authToken, Config config, DriverFactory driverFactory) { + /** + * Returns a driver for a Neo4j instance with the provided {@link ClientCertificateManager}. + * @param uri the URL to a Neo4j instance + * @param clientCertificateManager the client certificate manager + * @return a new driver to the database instance specified by the URL + * @since 5.19 + * @see ClientCertificateManager + */ + @Preview(name = "mTLS") + public static Driver driver(String uri, ClientCertificateManager clientCertificateManager) { + return driver(URI.create(uri), clientCertificateManager); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link ClientCertificateManager} and driver + * {@link Config}. + * @param uri the URL to a Neo4j instance + * @param clientCertificateManager the client certificate manager + * @param config the driver config + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver(String uri, ClientCertificateManager clientCertificateManager, Config config) { + return driver(URI.create(uri), clientCertificateManager, config); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthToken} and {@link ClientCertificateManager}. + * @param uri the URL to a Neo4j instance + * @param authToken the auth token + * @param clientCertificateManager the client certificate manager + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver(String uri, AuthToken authToken, ClientCertificateManager clientCertificateManager) { + return driver(URI.create(uri), authToken, clientCertificateManager); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthToken}, {@link ClientCertificateManager} and + * driver {@link Config}. + * @param uri the URL to a Neo4j instance + * @param authToken the auth token + * @param clientCertificateManager the client certificate manager + * @param config the driver config + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver( + String uri, AuthToken authToken, ClientCertificateManager clientCertificateManager, Config config) { + return driver(URI.create(uri), authToken, clientCertificateManager, config); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthTokenManager} and + * {@link ClientCertificateManager}. + * @param uri the URL to a Neo4j instance + * @param authTokenManager the auth token manager + * @param clientCertificateManager the client certificate manager + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver( + String uri, AuthTokenManager authTokenManager, ClientCertificateManager clientCertificateManager) { + return driver(URI.create(uri), authTokenManager, clientCertificateManager); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthTokenManager}, + * {@link ClientCertificateManager} and driver {@link Config}. + * @param uri the URL to a Neo4j instance + * @param authTokenManager the auth token manager + * @param clientCertificateManager the client certificate manager + * @param config the driver config + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver( + String uri, + AuthTokenManager authTokenManager, + ClientCertificateManager clientCertificateManager, + Config config) { + return driver(URI.create(uri), authTokenManager, clientCertificateManager, config); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link ClientCertificateManager}. + * @param uri the URL to a Neo4j instance + * @param clientCertificateManager the client certificate manager + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver(URI uri, ClientCertificateManager clientCertificateManager) { + return driver(uri, clientCertificateManager, Config.defaultConfig()); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link ClientCertificateManager} and driver + * {@link Config}. + * @param uri the URL to a Neo4j instance + * @param clientCertificateManager the client certificate manager + * @param config the driver config + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver(URI uri, ClientCertificateManager clientCertificateManager, Config config) { + return driver(uri, AuthTokens.none(), clientCertificateManager, config); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthToken} and {@link ClientCertificateManager}. + * @param uri the URL to a Neo4j instance + * @param authToken the auth token + * @param clientCertificateManager the client certificate manager + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver(URI uri, AuthToken authToken, ClientCertificateManager clientCertificateManager) { + return driver(uri, authToken, clientCertificateManager, Config.defaultConfig()); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthToken}, {@link ClientCertificateManager} and + * driver {@link Config}. + * @param uri the URL to a Neo4j instance + * @param authToken the auth token + * @param clientCertificateManager the client certificate manager + * @param config the driver config + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver( + URI uri, AuthToken authToken, ClientCertificateManager clientCertificateManager, Config config) { + return driver(uri, authToken, clientCertificateManager, config, new DriverFactory()); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthTokenManager} and + * {@link ClientCertificateManager}. + * @param uri the URL to a Neo4j instance + * @param authTokenManager the auth token manager + * @param clientCertificateManager the client certificate manager + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver( + URI uri, AuthTokenManager authTokenManager, ClientCertificateManager clientCertificateManager) { + return driver(uri, authTokenManager, clientCertificateManager, Config.defaultConfig()); + } + + /** + * Returns a driver for a Neo4j instance with the provided {@link AuthTokenManager}, + * {@link ClientCertificateManager} and driver {@link Config}. + * @param uri the URL to a Neo4j instance + * @param authTokenManager the auth token manager + * @param clientCertificateManager the client certificate manager + * @param config the driver config + * @return a new driver to the database instance specified by the URL + * @since 5.19 + */ + @Preview(name = "mTLS") + public static Driver driver( + URI uri, + AuthTokenManager authTokenManager, + ClientCertificateManager clientCertificateManager, + Config config) { + return driver(uri, authTokenManager, clientCertificateManager, config, new DriverFactory()); + } + + private static Driver driver( + URI uri, + AuthToken authToken, + ClientCertificateManager clientCertificateManager, + Config config, + DriverFactory driverFactory) { + if (clientCertificateManager != null) { + clientCertificateManager = new ValidatingClientCertificateManager(clientCertificateManager); + } config = getOrDefault(config); - return driverFactory.newInstance(uri, new StaticAuthTokenManager(authToken), config); + return driverFactory.newInstance(uri, new StaticAuthTokenManager(authToken), clientCertificateManager, config); } private static Driver driver( - URI uri, AuthTokenManager authTokenManager, Config config, DriverFactory driverFactory) { + URI uri, + AuthTokenManager authTokenManager, + ClientCertificateManager clientCertificateManager, + Config config, + DriverFactory driverFactory) { requireNonNull(authTokenManager, "authTokenManager must not be null"); + if (clientCertificateManager != null) { + clientCertificateManager = new ValidatingClientCertificateManager(clientCertificateManager); + } config = getOrDefault(config); return driverFactory.newInstance( - uri, new ValidatingAuthTokenManager(authTokenManager, config.logging()), config); + uri, + new ValidatingAuthTokenManager(authTokenManager, config.logging()), + clientCertificateManager, + config); } private static Config getOrDefault(Config config) { diff --git a/driver/src/main/java/org/neo4j/driver/RotatingClientCertificateManager.java b/driver/src/main/java/org/neo4j/driver/RotatingClientCertificateManager.java new file mode 100644 index 0000000000..d52759bab7 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/RotatingClientCertificateManager.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import org.neo4j.driver.util.Preview; + +/** + * A {@link ClientCertificateManager} that supports rotating its {@link ClientCertificate}. + * @since 5.19 + */ +@Preview(name = "mTLS") +public sealed interface RotatingClientCertificateManager extends ClientCertificateManager + permits org.neo4j.driver.internal.InternalRotatingClientCertificateManager { + /** + * Rotates the current {@link ClientCertificate}. + * @param clientCertificate the new certificate, must not be {@literal null} + */ + void rotate(ClientCertificate clientCertificate); +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java b/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java index 9167abffe7..273d9f97d5 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java +++ b/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java @@ -29,6 +29,7 @@ import java.time.Clock; import java.util.function.Supplier; import org.neo4j.driver.AuthTokenManager; +import org.neo4j.driver.ClientCertificateManager; import org.neo4j.driver.Config; import org.neo4j.driver.Driver; import org.neo4j.driver.Logging; @@ -64,13 +65,18 @@ public class DriverFactory { public static final String NO_ROUTING_CONTEXT_ERROR_MESSAGE = "Routing parameters are not supported with scheme 'bolt'. Given URI: "; - public final Driver newInstance(URI uri, AuthTokenManager authTokenManager, Config config) { - return newInstance(uri, authTokenManager, config, null, null, null); + public final Driver newInstance( + URI uri, + AuthTokenManager authTokenManager, + ClientCertificateManager clientCertificateManager, + Config config) { + return newInstance(uri, authTokenManager, clientCertificateManager, config, null, null, null); } public final Driver newInstance( URI uri, AuthTokenManager authTokenManager, + ClientCertificateManager clientCertificateManager, Config config, SecurityPlan securityPlan, EventLoopGroup eventLoopGroup, @@ -89,7 +95,8 @@ public final Driver newInstance( if (securityPlan == null) { var settings = new SecuritySettings(config.encrypted(), config.trustStrategy()); - securityPlan = SecurityPlans.createSecurityPlan(settings, uri.getScheme()); + securityPlan = SecurityPlans.createSecurityPlan( + settings, uri.getScheme(), clientCertificateManager, config.logging()); } var address = new BoltServerAddress(uri); diff --git a/driver/src/main/java/org/neo4j/driver/internal/InternalClientCertificate.java b/driver/src/main/java/org/neo4j/driver/internal/InternalClientCertificate.java new file mode 100644 index 0000000000..c89f988725 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/InternalClientCertificate.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal; + +import java.io.File; +import org.neo4j.driver.ClientCertificate; + +public record InternalClientCertificate(File certificate, File privateKey, String password) + implements ClientCertificate {} diff --git a/driver/src/main/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManager.java b/driver/src/main/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManager.java new file mode 100644 index 0000000000..2601915cb7 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManager.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.neo4j.driver.ClientCertificate; +import org.neo4j.driver.RotatingClientCertificateManager; + +public final class InternalRotatingClientCertificateManager implements RotatingClientCertificateManager { + private CompletionStage clientCertificateStage; + private InternalClientCertificate clientCertificate; + + public InternalRotatingClientCertificateManager(ClientCertificate clientCertificate) { + Objects.requireNonNull(clientCertificate); + updateState(clientCertificate); + } + + @Override + public synchronized CompletionStage getClientCertificate() { + if (clientCertificate != null) { + var stage = clientCertificateStage; + updateState(null); + return stage; + } else { + return clientCertificateStage; + } + } + + @Override + public void rotate(ClientCertificate clientCertificate) { + Objects.requireNonNull(clientCertificate); + synchronized (this) { + updateState(clientCertificate); + } + } + + private void updateState(ClientCertificate clientCertificate) { + this.clientCertificateStage = CompletableFuture.completedStage(clientCertificate); + this.clientCertificate = (InternalClientCertificate) clientCertificate; + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/ValidatingClientCertificateManager.java b/driver/src/main/java/org/neo4j/driver/internal/ValidatingClientCertificateManager.java new file mode 100644 index 0000000000..401bd8c357 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/ValidatingClientCertificateManager.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.neo4j.driver.ClientCertificate; +import org.neo4j.driver.ClientCertificateManager; +import org.neo4j.driver.exceptions.ClientException; + +public class ValidatingClientCertificateManager implements ClientCertificateManager { + private final ClientCertificateManager delegate; + + public ValidatingClientCertificateManager(ClientCertificateManager delegate) { + this.delegate = delegate; + } + + @Override + public CompletionStage getClientCertificate() { + CompletionStage certificateStage; + try { + certificateStage = delegate.getClientCertificate(); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture( + new ClientException("An exception has been thrown by the ClientCertificateManager.", throwable)); + } + return certificateStage; + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnector.java b/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnector.java index 5a64be241e..57dfc36951 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnector.java +++ b/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnector.java @@ -18,8 +18,12 @@ import io.netty.bootstrap.Bootstrap; import io.netty.channel.ChannelFuture; +import java.util.function.Function; import org.neo4j.driver.internal.BoltServerAddress; public interface ChannelConnector { - ChannelFuture connect(BoltServerAddress address, Bootstrap bootstrap); + ChannelFuture connect( + BoltServerAddress address, + Bootstrap bootstrap, + Function channelFutureExtensionMapper); } diff --git a/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnectorImpl.java b/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnectorImpl.java index f7e4149dc0..59e7a5fe84 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnectorImpl.java +++ b/driver/src/main/java/org/neo4j/driver/internal/async/connection/ChannelConnectorImpl.java @@ -26,6 +26,8 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.time.Clock; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; import org.neo4j.driver.AuthTokenManager; import org.neo4j.driver.Logging; import org.neo4j.driver.NotificationConfig; @@ -36,6 +38,7 @@ import org.neo4j.driver.internal.async.inbound.ConnectTimeoutHandler; import org.neo4j.driver.internal.cluster.RoutingContext; import org.neo4j.driver.internal.security.SecurityPlan; +import org.neo4j.driver.internal.util.Futures; public class ChannelConnectorImpl implements ChannelConnector { private final String userAgent; @@ -97,30 +100,45 @@ public ChannelConnectorImpl( } @Override - public ChannelFuture connect(BoltServerAddress address, Bootstrap bootstrap) { - bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeoutMillis); - bootstrap.handler(new NettyChannelInitializer( - address, securityPlan, connectTimeoutMillis, authTokenManager, clock, logging)); - bootstrap.resolver(addressResolverGroup); - - SocketAddress socketAddress; - try { - socketAddress = - new InetSocketAddress(domainNameResolver.resolve(address.connectionHost())[0], address.port()); - } catch (Throwable t) { - socketAddress = InetSocketAddress.createUnresolved(address.connectionHost(), address.port()); - } - - var channelConnected = bootstrap.connect(socketAddress); - - var channel = channelConnected.channel(); - var handshakeCompleted = channel.newPromise(); - var connectionInitialized = channel.newPromise(); - - installChannelConnectedListeners(address, channelConnected, handshakeCompleted); - installHandshakeCompletedListeners(handshakeCompleted, connectionInitialized); - - return connectionInitialized; + public ChannelFuture connect( + BoltServerAddress address, + Bootstrap bootstrap, + Function channelFutureExtensionMapper) { + var sslContextStage = securityPlan.sslContext(); + + var channelFutureCompletableFuture = new CompletableFuture(); + + sslContextStage.whenComplete((sslContext, throwable) -> { + if (throwable != null) { + channelFutureCompletableFuture.completeExceptionally(Futures.completionExceptionCause(throwable)); + } else { + bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeoutMillis); + bootstrap.handler(new NettyChannelInitializer( + address, securityPlan, connectTimeoutMillis, authTokenManager, sslContext, clock, logging)); + bootstrap.resolver(addressResolverGroup); + + SocketAddress socketAddress; + try { + socketAddress = new InetSocketAddress( + domainNameResolver.resolve(address.connectionHost())[0], address.port()); + } catch (Throwable t) { + socketAddress = InetSocketAddress.createUnresolved(address.connectionHost(), address.port()); + } + + var channelConnected = bootstrap.connect(socketAddress); + + var channel = channelConnected.channel(); + var handshakeCompleted = channel.newPromise(); + var connectionInitialized = channel.newPromise(); + + installChannelConnectedListeners(address, channelConnected, handshakeCompleted); + installHandshakeCompletedListeners(handshakeCompleted, connectionInitialized); + + channelFutureCompletableFuture.complete(channelFutureExtensionMapper.apply(connectionInitialized)); + } + }); + + return new DeferredChannelFuture(channelFutureCompletableFuture, logging); } private void installChannelConnectedListeners( diff --git a/driver/src/main/java/org/neo4j/driver/internal/async/connection/DeferredChannelFuture.java b/driver/src/main/java/org/neo4j/driver/internal/async/connection/DeferredChannelFuture.java new file mode 100644 index 0000000000..f131c34317 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/async/connection/DeferredChannelFuture.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal.async.connection; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GenericFutureListener; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import org.neo4j.driver.Logger; +import org.neo4j.driver.Logging; +import org.neo4j.driver.internal.util.Futures; + +/** + * This implementation is for {@link io.netty.channel.pool.SimpleChannelPool} usage only. It defers listener + * notification until the supplied {@link CompletionStage} completes. + */ +class DeferredChannelFuture implements ChannelFuture { + private final Logger logger; + + @SuppressWarnings("rawtypes") + private final CompletableFuture listenerFuture = new CompletableFuture<>(); + + @SuppressWarnings("unchecked") + public DeferredChannelFuture(CompletionStage channelFutureCompletionStage, Logging logging) { + this.logger = logging.getLog(getClass()); + + listenerFuture.thenCompose(ignored -> channelFutureCompletionStage).whenComplete((channelFuture, throwable) -> { + var listener = listenerFuture.join(); + if (throwable != null) { + throwable = Futures.completionExceptionCause(throwable); + try { + listener.operationComplete(new FailedChannelFuture(throwable)); + } catch (Throwable e) { + logger.error("An error occured while notifying listener.", e); + } + } else { + channelFuture.addListener(listener); + } + }); + } + + @Override + public Channel channel() { + return null; + } + + @Override + public boolean isSuccess() { + return false; + } + + @Override + public boolean isCancellable() { + return false; + } + + @Override + public Throwable cause() { + return null; + } + + @Override + public ChannelFuture addListener(GenericFutureListener> listener) { + listenerFuture.complete(listener); + return this; + } + + @SafeVarargs + @Override + public final ChannelFuture addListeners(GenericFutureListener>... listeners) { + return null; + } + + @Override + public ChannelFuture removeListener(GenericFutureListener> listener) { + return null; + } + + @SafeVarargs + @Override + public final ChannelFuture removeListeners(GenericFutureListener>... listeners) { + return null; + } + + @Override + public ChannelFuture sync() { + return null; + } + + @Override + public ChannelFuture syncUninterruptibly() { + return null; + } + + @Override + public ChannelFuture await() { + return null; + } + + @Override + public ChannelFuture awaitUninterruptibly() { + return null; + } + + @Override + public boolean await(long timeout, TimeUnit unit) { + return false; + } + + @Override + public boolean await(long timeoutMillis) { + return false; + } + + @Override + public boolean awaitUninterruptibly(long timeout, TimeUnit unit) { + return false; + } + + @Override + public boolean awaitUninterruptibly(long timeoutMillis) { + return false; + } + + @Override + public Void getNow() { + return null; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return false; + } + + @Override + public Void get() { + return null; + } + + @Override + public Void get(long timeout, TimeUnit unit) { + return null; + } + + @Override + public boolean isVoid() { + return false; + } + + private record FailedChannelFuture(Throwable throwable) implements ChannelFuture { + + @Override + public Channel channel() { + return null; + } + + @Override + public boolean isSuccess() { + return false; + } + + @Override + public boolean isCancellable() { + return false; + } + + @Override + public Throwable cause() { + return throwable; + } + + @Override + public ChannelFuture addListener(GenericFutureListener> listener) { + return null; + } + + @SafeVarargs + @Override + public final ChannelFuture addListeners(GenericFutureListener>... listeners) { + return null; + } + + @Override + public ChannelFuture removeListener(GenericFutureListener> listener) { + return null; + } + + @SafeVarargs + @Override + public final ChannelFuture removeListeners(GenericFutureListener>... listeners) { + return null; + } + + @Override + public ChannelFuture sync() { + return null; + } + + @Override + public ChannelFuture syncUninterruptibly() { + return null; + } + + @Override + public ChannelFuture await() { + return null; + } + + @Override + public ChannelFuture awaitUninterruptibly() { + return null; + } + + @Override + public boolean await(long timeout, TimeUnit unit) { + return false; + } + + @Override + public boolean await(long timeoutMillis) { + return false; + } + + @Override + public boolean awaitUninterruptibly(long timeout, TimeUnit unit) { + return false; + } + + @Override + public boolean awaitUninterruptibly(long timeoutMillis) { + return false; + } + + @Override + public Void getNow() { + return null; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return true; + } + + @Override + public Void get() { + return null; + } + + @Override + public Void get(long timeout, TimeUnit unit) { + return null; + } + + @Override + public boolean isVoid() { + return false; + } + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializer.java b/driver/src/main/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializer.java index fa1f4b3c82..4a28b091ea 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializer.java +++ b/driver/src/main/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializer.java @@ -25,6 +25,7 @@ import io.netty.channel.ChannelInitializer; import io.netty.handler.ssl.SslHandler; import java.time.Clock; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; import org.neo4j.driver.AuthTokenManager; import org.neo4j.driver.Logging; @@ -38,6 +39,7 @@ public class NettyChannelInitializer extends ChannelInitializer { private final SecurityPlan securityPlan; private final int connectTimeoutMillis; private final AuthTokenManager authTokenManager; + private final SSLContext sslContext; private final Clock clock; private final Logging logging; @@ -46,12 +48,14 @@ public NettyChannelInitializer( SecurityPlan securityPlan, int connectTimeoutMillis, AuthTokenManager authTokenManager, + SSLContext sslContext, Clock clock, Logging logging) { this.address = address; this.securityPlan = securityPlan; this.connectTimeoutMillis = connectTimeoutMillis; this.authTokenManager = authTokenManager; + this.sslContext = sslContext; this.clock = clock; this.logging = logging; } @@ -74,8 +78,8 @@ private SslHandler createSslHandler() { } private SSLEngine createSslEngine() { - var sslContext = securityPlan.sslContext(); var sslEngine = sslContext.createSSLEngine(address.host(), address.port()); + sslEngine.setNeedClientAuth(securityPlan.requiresClientAuth()); sslEngine.setUseClientMode(true); if (securityPlan.requiresHostnameVerification()) { var sslParameters = sslEngine.getSSLParameters(); diff --git a/driver/src/main/java/org/neo4j/driver/internal/async/pool/NettyChannelPool.java b/driver/src/main/java/org/neo4j/driver/internal/async/pool/NettyChannelPool.java index e792bd0c50..1932114ab1 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/async/pool/NettyChannelPool.java +++ b/driver/src/main/java/org/neo4j/driver/internal/async/pool/NettyChannelPool.java @@ -91,23 +91,24 @@ public class NettyChannelPool implements ExtendedChannelPool { @Override protected ChannelFuture connectChannel(Bootstrap bootstrap) { var creatingEvent = handler.channelCreating(id); - var connectedChannelFuture = connector.connect(address, bootstrap); - var channel = connectedChannelFuture.channel(); - // This ensures that handler.channelCreated is called before SimpleChannelPool calls - // handler.channelAcquired - var trackedChannelFuture = channel.newPromise(); - connectedChannelFuture.addListener(future -> { - if (future.isSuccess()) { - // notify pool handler about a successful connection - setPoolId(channel, id); - handler.channelCreated(channel, creatingEvent); - trackedChannelFuture.setSuccess(); - } else { - handler.channelFailedToCreate(id); - trackedChannelFuture.setFailure(future.cause()); - } + return connector.connect(address, bootstrap, connectedChannelFuture -> { + var channel = connectedChannelFuture.channel(); + // This ensures that handler.channelCreated is called before SimpleChannelPool calls + // handler.channelAcquired + var trackedChannelFuture = channel.newPromise(); + connectedChannelFuture.addListener(future -> { + if (future.isSuccess()) { + // notify pool handler about a successful connection + setPoolId(channel, id); + handler.channelCreated(channel, creatingEvent); + trackedChannelFuture.setSuccess(); + } else { + handler.channelFailedToCreate(id); + trackedChannelFuture.setFailure(future.cause()); + } + }); + return trackedChannelFuture; }); - return trackedChannelFuture; } }; } diff --git a/driver/src/main/java/org/neo4j/driver/internal/pki/DerUtils.java b/driver/src/main/java/org/neo4j/driver/internal/pki/DerUtils.java new file mode 100644 index 0000000000..4bd2d05198 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/pki/DerUtils.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal.pki; + +import static java.lang.String.format; + +import java.math.BigInteger; +import java.nio.ByteBuffer; + +/** + * Bare minimum parser for DER-encoded values. The support is sufficient + * to parse private and public keys. + *

+ * DER(Distinguished Encoding Rules) is a subset of BER(Basic Encoding Rules) + * with some limitations; length needs to be in a definite form and string/array + * values must use primitive encoding. + *

+ * Type structure is very simple: {@code IDENTIFIER LENGTH DATA} + */ +final class DerUtils { + private static final byte INTEGER_TAG = 0x02; + private static final byte OCTET_STRING_TAG = 0x04; + private static final byte SEQUENCE_TAG = 0x30; + + private DerUtils() {} + + /** + * Read an integer. + * + * @param input buffer positioned at the beginning of an integer. + * @return the integer at the current position. + */ + static BigInteger readDerInteger(ByteBuffer input) { + var len = der(input, INTEGER_TAG); + var value = new byte[len]; + input.get(value); + return new BigInteger(1, value); + } + + /** + * Read an octet string, more commonly known as a byte array. + * + * @param input buffer positioned at the beginning if an octet string. + * @return the octet string at the current position. + */ + static byte[] readDerOctetString(ByteBuffer input) { + var len = der(input, OCTET_STRING_TAG); + var value = new byte[len]; + input.get(value); + return value; + } + + /** + * Return the context specific data with tag {@code contextTag}. + *

+ * To break ambiguity, optional values with the same type can be tagged with numbers. + * In this case, the tag value represents the tag number, and the tag type is inferred. + * + * @param input buffer positioned at the beginning if the context specific data. + * @param contextTag the tag to which data we should find. + * @return the context data that is tagged with {@code contextTag}, or {@code null} if + * the tag is not present. + */ + @SuppressWarnings("SameParameterValue") + static byte[] getDerContext(ByteBuffer input, byte contextTag) { + var begin = input.position(); + while (input.hasRemaining()) { + var start = input.position(); + var tag = input.get(); + var length = getLength(input); + if (isContextSpecific(tag, contextTag)) { + // Found our tag, copy full block + return copyContext(input, start); + } + // Skip this block + input.position(input.position() + length); + } + input.position(begin); // Reset input + return null; // Found no matching tag + } + + /** + * Begin parsing a sequence. + * + * @param input buffer positioned at the beginning of a sequence. + * @return length of sequence. + */ + static int beginDerSequence(ByteBuffer input) { + return der(input, SEQUENCE_TAG); + } + + /** + * Consumes a tag. + * + * @param input buffer positions at the beginning of a tag. + * @param expectedTag what tag should be found. + * @return the length of the data following the tag. + * @throws IllegalArgumentException if the correct that is not found. + */ + private static int der(ByteBuffer input, int expectedTag) { + var tag = unsignedByte(input); + if (tag != expectedTag) { + throw new IllegalArgumentException(format("Expected tag '%02X' but found '%02X'", expectedTag, tag)); + } + return getLength(input); + } + + private static byte[] copyContext(ByteBuffer input, int dataStart) { + input.position(dataStart); + var tag = input.get(); + + if (isConstructed(tag)) { + var length = getLength(input); + var data = new byte[length]; + input.get(data); + + // Technically, we should read all data and concatenate "sub-values". However, + // this is not allowed in DER, so the only valid data consists of 0 or 1 + // "sub-values", which our code works for. + return data; + } + throw new IllegalArgumentException("Unable to extract non-constructed data."); + } + + /** + * Determines whether a tag matches the context tag. Context is defined as + * {@code [10tttttt]}, where 't' is the context tag. + * + * @param tag read tag from encoding. + * @param contextTag the context value we are looking for. + * @return {@code true} if {@code tag} matches {@code contextTag}, {@code false} otherwise. + */ + private static boolean isContextSpecific(byte tag, byte contextTag) { + if ((tag & 0b1100_0000) == 0b1000_0000) { + return (tag & 0b001_1111) == contextTag; + } + return false; + } + + /** + * Determines whether a tag is constructed, which is indicated by bit 6 being one. + * A constructed value is an encapsulation structure, that contains 0 or more + * "sub-values" that should be concatenated to form the final value. + * + * @return {@code true} if the value is constructed. + */ + private static boolean isConstructed(byte tag) { + return ((tag & 0b0010_0000) == 0b0010_0000); + } + + /** + * Parse the length octet's. They follow the simple scheme of: + *

+     *   Short form: [0xxxxxxx]
+     *   Long form: [1nnnnnnn]...[n-1][n] // where 'n' denotes the number of bytes
+     *     n = 0 // indefinite length, not allowed in DER
+     *     n = 127 // reserved
+     * 
+ * + * @return the length of the following content. + */ + private static int getLength(ByteBuffer input) { + var lengthByte = unsignedByte(input); + // if bit 8 is 0, short form + if ((lengthByte & 0b1000_0000) == 0) { + return lengthByte; + } + // otherwise, long form + lengthByte &= 0b0111_1111; + if (lengthByte == 0) { + throw new UnsupportedOperationException("Indefinite length is not allowed in DER."); + } + if (lengthByte > 2) { // 65mb should be enough, right? + throw new IllegalArgumentException("Too big content."); + } + var len = 0; + while (lengthByte-- > 0) { + len <<= Byte.SIZE; + len |= unsignedByte(input); + } + return len; + } + + /** + * Because Java... + */ + private static int unsignedByte(ByteBuffer input) { + return input.get() & 0xFF; + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/pki/PemFormats.java b/driver/src/main/java/org/neo4j/driver/internal/pki/PemFormats.java new file mode 100644 index 0000000000..506fb60ec4 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/pki/PemFormats.java @@ -0,0 +1,463 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal.pki; + +import static java.lang.String.format; +import static org.neo4j.driver.internal.pki.DerUtils.beginDerSequence; +import static org.neo4j.driver.internal.pki.DerUtils.getDerContext; +import static org.neo4j.driver.internal.pki.DerUtils.readDerInteger; +import static org.neo4j.driver.internal.pki.DerUtils.readDerOctetString; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.AlgorithmParameters; +import java.security.KeyException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.DSAPrivateKeySpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPrivateKeySpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.InvalidParameterSpecException; +import java.security.spec.KeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.HexFormat; +import java.util.Map; +import java.util.StringTokenizer; +import java.util.function.Function; +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Supported PEM format + */ +final class PemFormats { + private static final KeyFactory RSA_KEY_FACTORY; + private static final KeyFactory DSA_KEY_FACTORY; + private static final KeyFactory EC_KEY_FACTORY; + + private static final Function ALL_KEY_FACTORIES; + + static { + try { + // All exists as part of openJDK + RSA_KEY_FACTORY = KeyFactory.getInstance("RSA"); + DSA_KEY_FACTORY = KeyFactory.getInstance("DSA"); + EC_KEY_FACTORY = KeyFactory.getInstance("EC"); + ALL_KEY_FACTORIES = keySpec -> { + try { + return RSA_KEY_FACTORY.generatePrivate(keySpec); + } catch (InvalidKeySpecException e) { + try { + return DSA_KEY_FACTORY.generatePrivate(keySpec); + } catch (InvalidKeySpecException ex) { + try { + return EC_KEY_FACTORY.generatePrivate(keySpec); + } catch (InvalidKeySpecException exc) { + // We tried... + e.addSuppressed(ex); + e.addSuppressed(exc); + throw new IllegalStateException("Key does not match RSA, DSA or EC spec.", e); + } + } + } + }; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Non-conforming JDK implementation.", e); + } + } + + private PemFormats() {} + + interface PemFormat { + PrivateKey decodePrivate(byte[] der, Map headers, String password) throws KeyException; + + PublicKey decodePublicKey(byte[] der) throws KeyException; + } + + /** + * PEM format at described by RFC7468 section 10. + */ + static class Pkcs8 implements PemFormat { + static final String PRIVATE_LABEL = "PRIVATE KEY"; + static final String PUBLIC_LABEL = "PUBLIC KEY"; + + @Override + public PrivateKey decodePrivate(byte[] der, Map headers, String password) throws KeyException { + assertNoPassword(password); + return ALL_KEY_FACTORIES.apply(new PKCS8EncodedKeySpec(der)); + } + + @Override + public PublicKey decodePublicKey(byte[] der) throws KeyException { + KeySpec encodedKeySpec = new X509EncodedKeySpec(der); + try { + return RSA_KEY_FACTORY.generatePublic(encodedKeySpec); + } catch (InvalidKeySpecException e) { + try { + return DSA_KEY_FACTORY.generatePublic(encodedKeySpec); + } catch (InvalidKeySpecException ex) { + try { + return EC_KEY_FACTORY.generatePublic(encodedKeySpec); + } catch (InvalidKeySpecException exc) { + // We tried... + e.addSuppressed(ex); + e.addSuppressed(exc); + throw new KeyException("Public key does not match RSA, DSA or EC spec.", e); + } + } + } + } + } + + /** + * PEM format at described by RFC7468 section 11. + */ + static class Pkcs8Encrypted extends Pkcs8 { + static final String ENCRYPTED_LABEL = "ENCRYPTED PRIVATE KEY"; + + @Override + public PrivateKey decodePrivate(byte[] der, Map headers, String password) throws KeyException { + assertPassword(password); + try { + var keyInfo = new EncryptedPrivateKeyInfo(der); + var pbeKey = getSecretKey(keyInfo, password); + var cipher = getCipher(keyInfo); + cipher.init(Cipher.DECRYPT_MODE, pbeKey, keyInfo.getAlgParameters()); + return ALL_KEY_FACTORIES.apply(keyInfo.getKeySpec(cipher)); + } catch (Exception e) { + throw new KeyException("Unable to decrypt private key.", e); + } + } + + private static SecretKey getSecretKey(EncryptedPrivateKeyInfo keyInfo, String password) + throws InvalidKeySpecException, NoSuchAlgorithmException { + SecretKeyFactory keyFactory; + try { + // Try to find by algorithm name first + keyFactory = SecretKeyFactory.getInstance(keyInfo.getAlgName()); + } catch (NoSuchAlgorithmException e) { + // Maybe the algorithm parameter have a descent toString()? + keyFactory = + SecretKeyFactory.getInstance(keyInfo.getAlgParameters().toString()); + } + return keyFactory.generateSecret(new PBEKeySpec(password.toCharArray())); + } + + private static Cipher getCipher(EncryptedPrivateKeyInfo keyInfo) + throws NoSuchPaddingException, NoSuchAlgorithmException { + try { + return Cipher.getInstance(keyInfo.getAlgName()); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + return Cipher.getInstance(keyInfo.getAlgParameters().toString()); + } + } + } + + /** + * PEM Legacy described by RFC1421. + * Main difference is that the type of key is described in the label, and not as PKCS#8 as part + * of the structure. Encryption is supported and is defined in the header. + */ + abstract static class PemLegacy implements PemFormat { + + @Override + public PrivateKey decodePrivate(byte[] der, Map headers, String password) throws KeyException { + // Handle decryption + var procType = headers.get("Proc-Type"); + if (procType != null && procType.equals("4,ENCRYPTED")) { + assertPassword(password); + var deckInfo = headers.get("DEK-Info"); + if (deckInfo == null) { + throw new KeyException("Missing 'DEK-Info' in encrypted PRIVATE KEY."); + } + var tokenizer = new StringTokenizer(deckInfo, ","); + var algorithm = tokenizer.nextToken(); + var iv = HexFormat.of().parseHex(tokenizer.nextToken()); + der = decryptLegacyPem(der, algorithm, iv, password); + } else { + assertNoPassword(password); + } + + // Here we have an un-encrypted DER that can be parsed by the corresponding algorithm + var buffer = ByteBuffer.wrap(der); + if (beginDerSequence(buffer) != buffer.remaining()) { + throw new IllegalArgumentException("Malformed ASN.1 input."); + } + if (!version().equals(readDerInteger(buffer))) { + throw new IllegalArgumentException("PrivateKey version mismatch."); + } + try { + return decodePrivate0(buffer); + } catch (InvalidKeySpecException e) { + throw new KeyException(e); + } + } + + protected abstract PrivateKey decodePrivate0(ByteBuffer buffer) throws InvalidKeySpecException; + + protected abstract BigInteger version(); + } + + /** + * Parser for PKCS1 encoded keys as described by + * RFC3447 + *

+ * Unlike PKCS8, the type of the key is not encoded in the structure, so + * we need to parse the ASN.1 structures directly. + */ + static class PemPKCS1Rsa extends PemLegacy { + static final String PRIVATE_LABEL = "RSA PRIVATE KEY"; + static final String PUBLIC_LABEL = "RSA PUBLIC KEY"; + + /** + *

+         * RSAPublicKey ::= SEQUENCE {
+         *     modulus           INTEGER,  -- n
+         *     publicExponent    INTEGER   -- e
+         * }
+         * 
+ */ + @Override + public PublicKey decodePublicKey(byte[] der) throws KeyException { + var input = ByteBuffer.wrap(der); + if (beginDerSequence(input) != input.remaining()) { + throw new IllegalArgumentException("Malformed RSAPublicKey"); + } + var n = readDerInteger(input); // INTEGER modulus + var e = readDerInteger(input); // INTEGER publicExponent + try { + return RSA_KEY_FACTORY.generatePublic(new RSAPublicKeySpec(n, e)); + } catch (InvalidKeySpecException ex) { + throw new KeyException(ex); + } + } + + /** + *
+         * RSAPrivateKey ::= SEQUENCE {
+         *   version           Version,
+         *   modulus           INTEGER,  -- n
+         *   publicExponent    INTEGER,  -- e
+         *   privateExponent   INTEGER,  -- d
+         *   prime1            INTEGER,  -- p
+         *   prime2            INTEGER,  -- q
+         *   exponent1         INTEGER,  -- d mod (p-1)
+         *   exponent2         INTEGER,  -- d mod (q-1)
+         *   coefficient       INTEGER,  -- (inverse of q) mod p
+         *   otherPrimeInfos   OtherPrimeInfos OPTIONAL
+         * }
+         * 
+ */ + @Override + protected PrivateKey decodePrivate0(ByteBuffer buffer) throws InvalidKeySpecException { + var n = readDerInteger(buffer); // INTEGER modulus + var e = readDerInteger(buffer); // INTEGER publicExponent + var d = readDerInteger(buffer); // INTEGER privateExponent + var p = readDerInteger(buffer); // INTEGER prime1 + var q = readDerInteger(buffer); // INTEGER prime2 + var ep = readDerInteger(buffer); // INTEGER exponent1 + var eq = readDerInteger(buffer); // INTEGER exponent2 + var c = readDerInteger(buffer); // INTEGER coefficient + return RSA_KEY_FACTORY.generatePrivate(new RSAPrivateCrtKeySpec(n, e, d, p, q, ep, eq, c)); + } + + @Override + protected BigInteger version() { + return BigInteger.ZERO; + } + } + + /** + * Parser for DSA keys, no available standard but exists as a de facto standard as implemented by openSSL + */ + static class PemPKCS1Dsa extends PemLegacy { + static final String PRIVATE_LABEL = "DSA PRIVATE KEY"; + + @Override + public PublicKey decodePublicKey(byte[] der) { + throw new UnsupportedOperationException(); + } + + /** + *
+         * DSSPrivatKey_OpenSSL ::= SEQUENCE
+         *     version INTEGER,
+         *     p INTEGER,
+         *     q INTEGER,
+         *     g INTEGER,
+         *     y INTEGER,
+         *     x INTEGER
+         * }
+         * 
+ */ + @Override + protected PrivateKey decodePrivate0(ByteBuffer buffer) throws InvalidKeySpecException { + var p = readDerInteger(buffer); // INTEGER p + var q = readDerInteger(buffer); // INTEGER q + var g = readDerInteger(buffer); // INTEGER g + readDerInteger(buffer); // public key 'y' is not used in the private key + var x = readDerInteger(buffer); // INTEGER x + return DSA_KEY_FACTORY.generatePrivate(new DSAPrivateKeySpec(x, p, q, g)); + } + + @Override + protected BigInteger version() { + return BigInteger.ZERO; + } + } + + /** + * Parser of Elliptic Curve keys as described by RFC5915 + */ + static class PemPKCS1Ec extends PemLegacy { + static final String PRIVATE_LABEL = "EC PRIVATE KEY"; + + @Override + public PublicKey decodePublicKey(byte[] der) { + throw new UnsupportedOperationException(); + } + + /** + *
+         * ECPrivateKey ::= SEQUENCE {
+         *   version        INTEGER { ecPrivkeyVer1(1) } (ecPrivkeyVer1),
+         *   privateKey     OCTET STRING,
+         *   parameters [0] ECParameters {{ NamedCurve }} OPTIONAL,
+         *   publicKey  [1] BIT STRING OPTIONAL
+         * }
+         * 
+ */ + @Override + protected PrivateKey decodePrivate0(ByteBuffer buffer) throws InvalidKeySpecException { + try { + var s = new BigInteger(1, readDerOctetString(buffer)); + var parameters = getDerContext(buffer, (byte) 0); + var ecParams = AlgorithmParameters.getInstance("EC"); + ecParams.init(parameters); + var parameterSpec = ecParams.getParameterSpec(ECParameterSpec.class); + return EC_KEY_FACTORY.generatePrivate(new ECPrivateKeySpec(s, parameterSpec)); + } catch (NoSuchAlgorithmException | IOException | InvalidParameterSpecException e) { + throw new IllegalArgumentException("Failed to decode EC private key", e); + } + } + + @Override + protected BigInteger version() { + return BigInteger.ONE; + } + } + + private static void assertNoPassword(String password) throws KeyException { + if (password != null) { + throw new KeyException("Passphrase was provided but found un-encrypted private key."); + } + } + + private static void assertPassword(String password) throws KeyException { + if (password == null) { + throw new KeyException("Found encrypted private key but no passphrase was provided."); + } + } + + /** + * Supported encryption schemas for legacy PEM. + */ + private enum DecryptSchema { + DES_CBC(8, "DES", "DES/CBC/PKCS5Padding"), + DES_EDE3_CBC(24, "DESede", "DESede/CBC/PKCS5Padding"), + AES_128_CBC(16, "AES", "AES/CBC/PKCS5Padding"), + AES_192_CBC(24, "AES", "AES/CBC/PKCS5Padding"), + AES_256_CBC(32, "AES", "AES/CBC/PKCS5Padding"); + + final int keySize; + final String family; + final String cipher; + + DecryptSchema(int keySize, String family, String cipher) { + this.keySize = keySize; + this.family = family; + this.cipher = cipher; + } + } + + private static byte[] decryptLegacyPem(byte[] der, String algorithm, byte[] iv, String password) + throws KeyException { + try { + DecryptSchema decryptSchema; + try { + decryptSchema = DecryptSchema.valueOf(algorithm.replace("-", "_")); + } catch (IllegalArgumentException e) { + throw new KeyException(format("Encryption scheme %s is not supported.", algorithm)); + } + + // Take as many bytes from the digest as needed + var kdf = keyDerivationFunction(iv, password); + var key = new byte[decryptSchema.keySize]; + System.arraycopy(kdf, 0, key, 0, decryptSchema.keySize); + var secret = new SecretKeySpec(key, decryptSchema.family); + + // Decrypt + var cipher = Cipher.getInstance(decryptSchema.cipher); + cipher.init(Cipher.DECRYPT_MODE, secret, new IvParameterSpec(iv)); + return cipher.doFinal(der); + } catch (Exception e) { + throw new KeyException("Failed to decrypt PEM file.", e); + } + } + + /** + * OpenSSL de facto standard for generating keys for symmetric cyphers for PEM file encryption. + * It's basically PBKDF1 as described in PKCS#5 v1.5 with MD5 hash and iteration count of 1. + * + * @param iv initialization vector. + * @param password user password. + * @return an array of 32 bytes that should be used as keys for symmetric cyphers. + * @throws NoSuchAlgorithmException if MD5 message digest is not available. + */ + private static byte[] keyDerivationFunction(byte[] iv, String password) throws NoSuchAlgorithmException { + // https://github.com/openssl/openssl/blob/e4fd3fc379d76d9cd33ea6699268485606447737/crypto/pem/pem_lib.c#L378 + // https://github.com/openssl/openssl/blob/e4fd3fc379d76d9cd33ea6699268485606447737/crypto/evp/evp_key.c#L78 + var pw = password.getBytes(StandardCharsets.UTF_8); + var md5 = MessageDigest.getInstance("MD5"); + md5.update(pw); + md5.update(iv, 0, 8); + var d0 = md5.digest(); + md5.update(d0); + md5.update(pw); + md5.update(iv, 0, 8); + var d1 = md5.digest(); + var kdf = new byte[32]; + System.arraycopy(d0, 0, kdf, 0, 16); + System.arraycopy(d1, 0, kdf, 16, 16); + return kdf; + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/pki/PemParser.java b/driver/src/main/java/org/neo4j/driver/internal/pki/PemParser.java new file mode 100644 index 0000000000..11f2f2e5dc --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/pki/PemParser.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal.pki; + +import static java.lang.String.format; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.KeyException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertificateFactory; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * Parse a public or private key. + *

+ * In contrast to {@link CertificateFactory}, Java's {@link KeyFactory}'s does not support reading textual pem files. + * PEM format reference: RFC7468 + * This implementation has support for + *

  • PKCS#8/X.509 Public Key + *
  • PKCS#8 and encrypted PKCS#8 private keys + *
  • PKCS#1 encoded RSA, DSA and EC. + *
  • Encrypted PKCS#1 encoded RSA, DSA and EC. + */ +public final class PemParser { + private static final String BEGIN = "-----BEGIN "; + private static final String END = "-----END "; + + /** + * Mime decoder is almost identical to PEM specification. + *

    + * RFC2045 + */ + private static final Base64.Decoder BASE_64 = Base64.getMimeDecoder(); + + private static final Map parsers; + + static { + parsers = Map.of( + PemFormats.Pkcs8.PUBLIC_LABEL, new PemFormats.Pkcs8(), + PemFormats.Pkcs8.PRIVATE_LABEL, new PemFormats.Pkcs8(), + PemFormats.Pkcs8Encrypted.ENCRYPTED_LABEL, new PemFormats.Pkcs8Encrypted(), + PemFormats.PemPKCS1Rsa.PUBLIC_LABEL, new PemFormats.PemPKCS1Rsa(), + PemFormats.PemPKCS1Rsa.PRIVATE_LABEL, new PemFormats.PemPKCS1Rsa(), + PemFormats.PemPKCS1Dsa.PRIVATE_LABEL, new PemFormats.PemPKCS1Dsa(), + PemFormats.PemPKCS1Ec.PRIVATE_LABEL, new PemFormats.PemPKCS1Ec()); + } + + private String label; + private byte[] der; + private final Map headers = new HashMap<>(); + + public PemParser(InputStream in) throws IOException { + try (var reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.US_ASCII))) { + parse(reader); + } + } + + public PrivateKey getPrivateKey(String password) throws KeyException { + var pemFormat = parsers.get(label); + if (pemFormat != null) { + return pemFormat.decodePrivate(der, headers, password); + } + + throw new KeyException(format("Provided PEM does not contain a private key, found '%s'.", label)); + } + + public PublicKey getPublicKey() throws KeyException { + var pemFormat = parsers.get(label); + if (pemFormat != null) { + return pemFormat.decodePublicKey(der); + } + throw new KeyException(format("Provided PEM does not contain a public key, found '%s'.", label)); + } + + /** + * Read the input stream and extract all the components. + * @throws IOException on failure to read from the provided input stream. + */ + private void parse(BufferedReader reader) throws IOException { + // Find header, explanatory text is allowed before and after block, so ignore that + var line = reader.readLine(); + while (line != null && !line.startsWith(BEGIN)) { + line = reader.readLine(); + } + if (line == null) { + throw new IllegalStateException("File does not contain " + BEGIN + " encapsulation boundary."); + } + + // Get label + label = extractLabel(line); + var endMarker = END + label; + + // Extract base64 encoded DER + var sb = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if (line.contains(endMarker)) { + der = BASE_64.decode(sb.toString()); + return; + } + + // Look for headers, is allowed in legacy PEM + if (line.contains(":")) { + var kv = line.split(":"); + headers.put(kv[0].trim(), kv[1].trim()); + continue; + } + + sb.append(line.trim()); + } + throw new IllegalStateException("Missing footer: " + endMarker + "."); + } + + /** + * Extract the label from the format {@code -----BEGIN (label)-----}. + * @param line line containing the pre-encapsulation boundary. + * @return the label for the encapsulation. + */ + private static String extractLabel(String line) { + line = line.substring(BEGIN.length()); + var index = line.indexOf('-'); + + if (!line.endsWith("-----") || (line.length() - index) != "-----".length()) { + throw new IllegalStateException( + format("Unable to find label, expecting '-----BEGIN (label)-----' but found '%s'.", line)); + } + return line.substring(0, index); + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/security/SSLContextManager.java b/driver/src/main/java/org/neo4j/driver/internal/security/SSLContextManager.java new file mode 100644 index 0000000000..3e8b87240e --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/security/SSLContextManager.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal.security; + +import java.io.FileInputStream; +import java.io.IOException; +import java.security.KeyException; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import org.neo4j.driver.ClientCertificateManager; +import org.neo4j.driver.Logger; +import org.neo4j.driver.Logging; +import org.neo4j.driver.exceptions.ClientException; +import org.neo4j.driver.internal.InternalClientCertificate; +import org.neo4j.driver.internal.pki.PemParser; +import org.neo4j.driver.internal.util.Futures; + +class SSLContextManager { + private final ClientCertificateManager clientCertificateManager; + private final SecurityPlan.SSLContextSupplier sslContextSupplier; + private final Logger logger; + private CompletableFuture sslContextFuture; + private SSLContext sslContext; + private Throwable throwable; + + public SSLContextManager( + ClientCertificateManager clientCertificateManager, + SecurityPlan.SSLContextSupplier sslContextSupplier, + Logging logging) + throws NoSuchAlgorithmException, KeyManagementException { + this.clientCertificateManager = clientCertificateManager; + this.sslContextSupplier = sslContextSupplier; + logger = logging.getLog(getClass()); + + if (clientCertificateManager == null) { + var sslContext = sslContextSupplier.get(new KeyManager[0]); + sslContextFuture = CompletableFuture.completedFuture(sslContext); + } + } + + public CompletionStage getSSLContext() { + return clientCertificateManager != null ? getSSLContextWithClientCertificate() : sslContextFuture; + } + + private CompletionStage getSSLContextWithClientCertificate() { + CompletableFuture sslContextFuture; + CompletionStage sslContextStage = null; + synchronized (this) { + if (this.sslContextFuture == null) { + this.sslContextFuture = new CompletableFuture<>(); + sslContextFuture = this.sslContextFuture; + var sslContext = this.sslContext; + var previousThrowable = this.throwable; + sslContextStage = clientCertificateManager + .getClientCertificate() + .thenApply(clientCertificate -> { + if (clientCertificate != null) { + var certificate = (InternalClientCertificate) clientCertificate; + try { + var keyManagers = createKeyManagers(certificate); + return sslContextSupplier.get(keyManagers); + } catch (Throwable throwable) { + var exception = new ClientException( + "An error occured while loading client certficate.", throwable); + logger.error("An error occured while loading client certficate.", exception); + throw new CompletionException(exception); + } + } else { + if (previousThrowable != null) { + throw new CompletionException(previousThrowable); + } else { + if (sslContext == null) { + var exception = new ClientException( + "The initial client certificate returned by the manager must not be null."); + logger.error( + "The initial client certificate returned by the manager must not be null.", + exception); + throw new CompletionException(exception); + } else { + return sslContext; + } + } + } + }); + } else { + sslContextFuture = this.sslContextFuture; + } + } + + if (sslContextStage != null) { + sslContextStage.whenComplete((sslContext, throwable) -> { + throwable = Futures.completionExceptionCause(throwable); + synchronized (this) { + this.sslContextFuture = null; + this.sslContext = sslContext; + this.throwable = throwable; + } + if (throwable != null) { + sslContextFuture.completeExceptionally(throwable); + } else { + sslContextFuture.complete(this.sslContext); + } + }); + } + + return sslContextFuture; + } + + protected KeyManager[] createKeyManagers(InternalClientCertificate clientCertificate) + throws CertificateException, IOException, KeyException, KeyStoreException, NoSuchAlgorithmException, + UnrecoverableKeyException { + var certificateFactory = CertificateFactory.getInstance("X.509"); + var chain = certificateFactory.generateCertificates(new FileInputStream(clientCertificate.certificate())); + + var password = clientCertificate.password(); + var key = new PemParser(new FileInputStream(clientCertificate.privateKey())).getPrivateKey(password); + + var clientKeyStore = KeyStore.getInstance("JKS"); + var pwdChars = password != null ? password.toCharArray() : "password".toCharArray(); + clientKeyStore.load(null, null); + clientKeyStore.setKeyEntry("neo4j.javadriver.clientcert.", key, pwdChars, chain.toArray(new Certificate[0])); + + var keyMgrFactory = KeyManagerFactory.getInstance("SunX509"); + keyMgrFactory.init(clientKeyStore, pwdChars); + + return keyMgrFactory.getKeyManagers(); + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlan.java b/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlan.java index b832c9cccc..d1ee98c22c 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlan.java +++ b/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlan.java @@ -16,6 +16,10 @@ */ package org.neo4j.driver.internal.security; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CompletionStage; +import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import org.neo4j.driver.RevocationCheckingStrategy; @@ -25,9 +29,15 @@ public interface SecurityPlan { boolean requiresEncryption(); - SSLContext sslContext(); + boolean requiresClientAuth(); + + CompletionStage sslContext(); boolean requiresHostnameVerification(); RevocationCheckingStrategy revocationCheckingStrategy(); + + interface SSLContextSupplier { + SSLContext get(KeyManager[] keyManagers) throws NoSuchAlgorithmException, KeyManagementException; + } } diff --git a/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlanImpl.java b/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlanImpl.java index 7a8e9ba063..2d526b5289 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlanImpl.java +++ b/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlanImpl.java @@ -24,8 +24,10 @@ import java.io.IOException; import java.security.GeneralSecurityException; import java.security.InvalidAlgorithmParameterException; +import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.security.Security; import java.security.cert.CertificateException; import java.security.cert.PKIXBuilderParameters; @@ -34,44 +36,72 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; import javax.net.ssl.CertPathTrustManagerParameters; -import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import org.neo4j.driver.ClientCertificateManager; +import org.neo4j.driver.Logging; import org.neo4j.driver.RevocationCheckingStrategy; +import org.neo4j.driver.internal.util.Futures; /** * A SecurityPlan consists of encryption and trust details. */ public class SecurityPlanImpl implements SecurityPlan { public static SecurityPlan forAllCertificates( - boolean requiresHostnameVerification, RevocationCheckingStrategy revocationCheckingStrategy) - throws GeneralSecurityException { - var sslContext = SSLContext.getInstance("TLS"); - sslContext.init(new KeyManager[0], new TrustManager[] {new TrustAllTrustManager()}, null); - - return new SecurityPlanImpl(true, sslContext, requiresHostnameVerification, revocationCheckingStrategy); + boolean requiresHostnameVerification, + RevocationCheckingStrategy revocationCheckingStrategy, + ClientCertificateManager clientCertificateManager, + Logging logging) + throws NoSuchAlgorithmException, KeyManagementException { + return new SecurityPlanImpl( + keyManagers -> { + var sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagers, new TrustManager[] {new TrustAllTrustManager()}, null); + return sslContext; + }, + requiresHostnameVerification, + revocationCheckingStrategy, + clientCertificateManager, + logging); } public static SecurityPlan forCustomCASignedCertificates( List certFiles, boolean requiresHostnameVerification, - RevocationCheckingStrategy revocationCheckingStrategy) + RevocationCheckingStrategy revocationCheckingStrategy, + ClientCertificateManager clientCertificateManager, + Logging logging) throws GeneralSecurityException, IOException { - var sslContext = configureSSLContext(certFiles, revocationCheckingStrategy); - return new SecurityPlanImpl(true, sslContext, requiresHostnameVerification, revocationCheckingStrategy); + var sslContext = configureSSLContextSupplier(certFiles, revocationCheckingStrategy); + return new SecurityPlanImpl( + sslContext, + requiresHostnameVerification, + revocationCheckingStrategy, + clientCertificateManager, + logging); } public static SecurityPlan forSystemCASignedCertificates( - boolean requiresHostnameVerification, RevocationCheckingStrategy revocationCheckingStrategy) + boolean requiresHostnameVerification, + RevocationCheckingStrategy revocationCheckingStrategy, + ClientCertificateManager clientCertificateManager, + Logging logging) throws GeneralSecurityException, IOException { - var sslContext = configureSSLContext(Collections.emptyList(), revocationCheckingStrategy); - return new SecurityPlanImpl(true, sslContext, requiresHostnameVerification, revocationCheckingStrategy); + var sslContext = configureSSLContextSupplier(Collections.emptyList(), revocationCheckingStrategy); + return new SecurityPlanImpl( + sslContext, + requiresHostnameVerification, + revocationCheckingStrategy, + clientCertificateManager, + logging); } - private static SSLContext configureSSLContext( + private static SSLContextSupplier configureSSLContextSupplier( List customCertFiles, RevocationCheckingStrategy revocationCheckingStrategy) throws GeneralSecurityException, IOException { var trustedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); @@ -86,7 +116,6 @@ private static SSLContext configureSSLContext( var pkixBuilderParameters = configurePKIXBuilderParameters(trustedKeyStore, revocationCheckingStrategy); - var sslContext = SSLContext.getInstance("TLS"); var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); if (pkixBuilderParameters == null) { @@ -95,9 +124,11 @@ private static SSLContext configureSSLContext( trustManagerFactory.init(new CertPathTrustManagerParameters(pkixBuilderParameters)); } - sslContext.init(new KeyManager[0], trustManagerFactory.getTrustManagers(), null); - - return sslContext; + return keyManagers -> { + var sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagers, trustManagerFactory.getTrustManagers(), null); + return sslContext; + }; } private static PKIXBuilderParameters configurePKIXBuilderParameters( @@ -143,23 +174,36 @@ private static void loadSystemCertificates(KeyStore trustedKeyStore) throws Gene } public static SecurityPlan insecure() { - return new SecurityPlanImpl(false, null, false, RevocationCheckingStrategy.NO_CHECKS); + return new SecurityPlanImpl(); } private final boolean requiresEncryption; - private final SSLContext sslContext; + private final boolean requiresClientAuth; private final boolean requiresHostnameVerification; private final RevocationCheckingStrategy revocationCheckingStrategy; + private final Supplier> sslContextSupplier; private SecurityPlanImpl( - boolean requiresEncryption, - SSLContext sslContext, + SSLContextSupplier sslContextSupplier, boolean requiresHostnameVerification, - RevocationCheckingStrategy revocationCheckingStrategy) { - this.requiresEncryption = requiresEncryption; - this.sslContext = sslContext; + RevocationCheckingStrategy revocationCheckingStrategy, + ClientCertificateManager clientCertificateManager, + Logging logging) + throws NoSuchAlgorithmException, KeyManagementException { + this.requiresEncryption = true; this.requiresHostnameVerification = requiresHostnameVerification; this.revocationCheckingStrategy = revocationCheckingStrategy; + var sslContextManager = new SSLContextManager(clientCertificateManager, sslContextSupplier, logging); + this.sslContextSupplier = sslContextManager::getSSLContext; + this.requiresClientAuth = clientCertificateManager != null; + } + + private SecurityPlanImpl() { + this.requiresEncryption = false; + this.requiresHostnameVerification = false; + this.revocationCheckingStrategy = RevocationCheckingStrategy.NO_CHECKS; + this.sslContextSupplier = Futures::completedWithNull; + this.requiresClientAuth = false; } @Override @@ -168,8 +212,13 @@ public boolean requiresEncryption() { } @Override - public SSLContext sslContext() { - return sslContext; + public boolean requiresClientAuth() { + return requiresClientAuth; + } + + @Override + public CompletionStage sslContext() { + return sslContextSupplier.get(); } @Override diff --git a/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlans.java b/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlans.java index 353b387a22..62d3809f36 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlans.java +++ b/driver/src/main/java/org/neo4j/driver/internal/security/SecurityPlans.java @@ -22,21 +22,28 @@ import java.io.IOException; import java.security.GeneralSecurityException; +import org.neo4j.driver.ClientCertificateManager; import org.neo4j.driver.Config; +import org.neo4j.driver.Logging; import org.neo4j.driver.RevocationCheckingStrategy; import org.neo4j.driver.exceptions.ClientException; import org.neo4j.driver.internal.Scheme; import org.neo4j.driver.internal.SecuritySettings; public class SecurityPlans { - public static SecurityPlan createSecurityPlan(SecuritySettings settings, String uriScheme) { + public static SecurityPlan createSecurityPlan( + SecuritySettings settings, + String uriScheme, + ClientCertificateManager clientCertificateManager, + Logging logging) { Scheme.validateScheme(uriScheme); try { if (isSecurityScheme(uriScheme)) { assertSecuritySettingsNotUserConfigured(settings, uriScheme); - return createSecurityPlanFromScheme(uriScheme); + return createSecurityPlanFromScheme(uriScheme, clientCertificateManager, logging); } else { - return createSecurityPlanImpl(settings.encrypted(), settings.trustStrategy()); + return createSecurityPlanImpl( + settings.encrypted(), settings.trustStrategy(), clientCertificateManager, logging); } } catch (GeneralSecurityException | IOException ex) { throw new ClientException("Unable to establish SSL parameters", ex); @@ -68,12 +75,15 @@ private static boolean hasEqualTrustStrategy(SecuritySettings settings) { && t1.revocationCheckingStrategy() == t2.revocationCheckingStrategy(); } - private static SecurityPlan createSecurityPlanFromScheme(String scheme) + private static SecurityPlan createSecurityPlanFromScheme( + String scheme, ClientCertificateManager clientCertificateManager, Logging logging) throws GeneralSecurityException, IOException { if (isHighTrustScheme(scheme)) { - return SecurityPlanImpl.forSystemCASignedCertificates(true, RevocationCheckingStrategy.NO_CHECKS); + return SecurityPlanImpl.forSystemCASignedCertificates( + true, RevocationCheckingStrategy.NO_CHECKS, clientCertificateManager, logging); } else { - return SecurityPlanImpl.forAllCertificates(false, RevocationCheckingStrategy.NO_CHECKS); + return SecurityPlanImpl.forAllCertificates( + false, RevocationCheckingStrategy.NO_CHECKS, clientCertificateManager, logging); } } @@ -81,18 +91,26 @@ private static SecurityPlan createSecurityPlanFromScheme(String scheme) * Establish a complete SecurityPlan based on the details provided for * driver construction. */ - private static SecurityPlan createSecurityPlanImpl(boolean encrypted, Config.TrustStrategy trustStrategy) + private static SecurityPlan createSecurityPlanImpl( + boolean encrypted, + Config.TrustStrategy trustStrategy, + ClientCertificateManager clientCertificateManager, + Logging logging) throws GeneralSecurityException, IOException { if (encrypted) { var hostnameVerificationEnabled = trustStrategy.isHostnameVerificationEnabled(); var revocationCheckingStrategy = trustStrategy.revocationCheckingStrategy(); return switch (trustStrategy.strategy()) { case TRUST_CUSTOM_CA_SIGNED_CERTIFICATES -> SecurityPlanImpl.forCustomCASignedCertificates( - trustStrategy.certFiles(), hostnameVerificationEnabled, revocationCheckingStrategy); + trustStrategy.certFiles(), + hostnameVerificationEnabled, + revocationCheckingStrategy, + clientCertificateManager, + logging); case TRUST_SYSTEM_CA_SIGNED_CERTIFICATES -> SecurityPlanImpl.forSystemCASignedCertificates( - hostnameVerificationEnabled, revocationCheckingStrategy); + hostnameVerificationEnabled, revocationCheckingStrategy, clientCertificateManager, logging); case TRUST_ALL_CERTIFICATES -> SecurityPlanImpl.forAllCertificates( - hostnameVerificationEnabled, revocationCheckingStrategy); + hostnameVerificationEnabled, revocationCheckingStrategy, clientCertificateManager, logging); }; } else { return insecure(); diff --git a/driver/src/test/java/org/neo4j/driver/ClientCertificateManagersTest.java b/driver/src/test/java/org/neo4j/driver/ClientCertificateManagersTest.java new file mode 100644 index 0000000000..6ecaf0065f --- /dev/null +++ b/driver/src/test/java/org/neo4j/driver/ClientCertificateManagersTest.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +import java.io.File; +import org.junit.jupiter.api.Test; + +class ClientCertificateManagersTest { + @Test + void shoudReturnRotatingManager() { + var file = mock(File.class); + var certificate = ClientCertificates.of(file, file); + var manager = ClientCertificateManagers.rotating(certificate); + + assertNotNull(manager); + } +} diff --git a/driver/src/test/java/org/neo4j/driver/integration/ChannelConnectorImplIT.java b/driver/src/test/java/org/neo4j/driver/integration/ChannelConnectorImplIT.java index 4b142300ef..d55879a8b1 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/ChannelConnectorImplIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/ChannelConnectorImplIT.java @@ -31,13 +31,17 @@ import static org.neo4j.driver.testutil.TestUtil.await; import io.netty.bootstrap.Bootstrap; +import io.netty.channel.ChannelFuture; import io.netty.handler.ssl.SslHandler; import java.io.IOException; import java.io.UncheckedIOException; import java.net.ServerSocket; -import java.security.GeneralSecurityException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -45,6 +49,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; import org.neo4j.driver.AuthTokenManager; import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Logging; import org.neo4j.driver.RevocationCheckingStrategy; import org.neo4j.driver.exceptions.AuthenticationException; import org.neo4j.driver.exceptions.ServiceUnavailableException; @@ -88,7 +93,11 @@ void tearDown() { void shouldConnect() throws Exception { ChannelConnector connector = newConnector(neo4j.authTokenManager()); - var channelFuture = connector.connect(neo4j.address(), bootstrap); + var ft = new CompletableFuture(); + connector + .connect(neo4j.address(), bootstrap, Function.identity()) + .addListener(future -> ft.complete((ChannelFuture) future)); + var channelFuture = ft.join(); assertTrue(channelFuture.await(10, TimeUnit.SECONDS)); var channel = channelFuture.channel(); @@ -100,7 +109,11 @@ void shouldConnect() throws Exception { void shouldSetupHandlers() throws Exception { ChannelConnector connector = newConnector(neo4j.authTokenManager(), trustAllCertificates(), 10_000); - var channelFuture = connector.connect(neo4j.address(), bootstrap); + var ft = new CompletableFuture(); + connector + .connect(neo4j.address(), bootstrap, Function.identity()) + .addListener(future -> ft.complete((ChannelFuture) future)); + var channelFuture = ft.join(); assertTrue(channelFuture.await(10, TimeUnit.SECONDS)); var channel = channelFuture.channel(); @@ -115,7 +128,11 @@ void shouldSetupHandlers() throws Exception { void shouldFailToConnectToWrongAddress() throws Exception { ChannelConnector connector = newConnector(neo4j.authTokenManager()); - var channelFuture = connector.connect(new BoltServerAddress("wrong-localhost"), bootstrap); + var ft = new CompletableFuture(); + connector + .connect(new BoltServerAddress("wrong-localhost"), bootstrap, Function.identity()) + .addListener(future -> ft.complete((ChannelFuture) future)); + var channelFuture = ft.join(); assertTrue(channelFuture.await(10, TimeUnit.SECONDS)); var channel = channelFuture.channel(); @@ -133,7 +150,11 @@ void shouldFailToConnectWithWrongCredentials() throws Exception { var authToken = AuthTokens.basic("neo4j", "wrong-password"); ChannelConnector connector = newConnector(new StaticAuthTokenManager(authToken)); - var channelFuture = connector.connect(neo4j.address(), bootstrap); + var ft = new CompletableFuture(); + connector + .connect(neo4j.address(), bootstrap, Function.identity()) + .addListener(future -> ft.complete((ChannelFuture) future)); + var channelFuture = ft.join(); assertTrue(channelFuture.await(10, TimeUnit.SECONDS)); var channel = channelFuture.channel(); @@ -143,11 +164,15 @@ void shouldFailToConnectWithWrongCredentials() throws Exception { } @Test - void shouldEnforceConnectTimeout() throws Exception { + void shouldEnforceConnectTimeout() throws NoSuchAlgorithmException, KeyManagementException { ChannelConnector connector = newConnector(neo4j.authTokenManager(), 1000); // try connect to a non-routable ip address 10.0.0.0, it will never respond - var channelFuture = connector.connect(new BoltServerAddress("10.0.0.0"), bootstrap); + var ft = new CompletableFuture(); + connector + .connect(new BoltServerAddress("10.0.0.0"), bootstrap, Function.identity()) + .addListener(future -> ft.complete((ChannelFuture) future)); + var channelFuture = ft.join(); assertThrows(ServiceUnavailableException.class, () -> await(channelFuture)); } @@ -184,7 +209,11 @@ void shouldThrowServiceUnavailableExceptionOnFailureDuringConnect() throws Excep }); ChannelConnector connector = newConnector(neo4j.authTokenManager()); - var channelFuture = connector.connect(address, bootstrap); + var ft = new CompletableFuture(); + connector + .connect(address, bootstrap, Function.identity()) + .addListener(future -> ft.complete((ChannelFuture) future)); + var channelFuture = ft.join(); // connect operation should fail with ServiceUnavailableException assertThrows(ServiceUnavailableException.class, () -> await(channelFuture)); @@ -197,19 +226,24 @@ private void testReadTimeoutOnConnect(SecurityPlan securityPlan) throws IOExcept var address = new BoltServerAddress("localhost", server.getLocalPort()); ChannelConnector connector = newConnector(neo4j.authTokenManager(), securityPlan, timeoutMillis); - var channelFuture = connector.connect(address, bootstrap); + var ft = new CompletableFuture(); + connector + .connect(address, bootstrap, Function.identity()) + .addListener(future -> ft.complete((ChannelFuture) future)); + var channelFuture = ft.join(); var e = assertThrows(ServiceUnavailableException.class, () -> await(channelFuture)); assertEquals(e.getMessage(), "Unable to establish connection in " + timeoutMillis + "ms"); } } - private ChannelConnectorImpl newConnector(AuthTokenManager authTokenManager) throws Exception { + private ChannelConnectorImpl newConnector(AuthTokenManager authTokenManager) + throws NoSuchAlgorithmException, KeyManagementException { return newConnector(authTokenManager, Integer.MAX_VALUE); } private ChannelConnectorImpl newConnector(AuthTokenManager authTokenManager, int connectTimeoutMillis) - throws Exception { + throws NoSuchAlgorithmException, KeyManagementException { return newConnector(authTokenManager, trustAllCertificates(), connectTimeoutMillis); } @@ -227,7 +261,7 @@ private ChannelConnectorImpl newConnector( BoltAgentUtil.VALUE); } - private static SecurityPlan trustAllCertificates() throws GeneralSecurityException { - return SecurityPlanImpl.forAllCertificates(false, RevocationCheckingStrategy.NO_CHECKS); + private static SecurityPlan trustAllCertificates() throws NoSuchAlgorithmException, KeyManagementException { + return SecurityPlanImpl.forAllCertificates(false, RevocationCheckingStrategy.NO_CHECKS, null, Logging.none()); } } diff --git a/driver/src/test/java/org/neo4j/driver/integration/ConnectionHandlingIT.java b/driver/src/test/java/org/neo4j/driver/integration/ConnectionHandlingIT.java index 7998ab3730..daee0f71cf 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/ConnectionHandlingIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/ConnectionHandlingIT.java @@ -90,6 +90,7 @@ void createDriver() { driver = driverFactory.newInstance( neo4j.uri(), authTokenProvider, + null, Config.builder().withFetchSize(1).build(), SecurityPlanImpl.insecure(), null, diff --git a/driver/src/test/java/org/neo4j/driver/integration/ConnectionPoolIT.java b/driver/src/test/java/org/neo4j/driver/integration/ConnectionPoolIT.java index f759d19680..421a1121d1 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/ConnectionPoolIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/ConnectionPoolIT.java @@ -92,7 +92,7 @@ void shouldDisposeChannelsBasedOnMaxLifetime() throws Exception { .withMaxConnectionLifetime(maxConnLifetimeHours, TimeUnit.HOURS) .build(); driver = driverFactory.newInstance( - neo4j.uri(), neo4j.authTokenManager(), config, SecurityPlanImpl.insecure(), null, null); + neo4j.uri(), neo4j.authTokenManager(), null, config, SecurityPlanImpl.insecure(), null, null); // force driver create channel and return it to the pool startAndCloseTransactions(driver, 1); diff --git a/driver/src/test/java/org/neo4j/driver/integration/ErrorIT.java b/driver/src/test/java/org/neo4j/driver/integration/ErrorIT.java index 99c44fb16d..74824eb639 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/ErrorIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/ErrorIT.java @@ -255,8 +255,8 @@ private Throwable testChannelErrorHandling(Consumer messag var config = Config.builder().withLogging(DEV_NULL_LOGGING).build(); Throwable queryError = null; - try (var driver = - driverFactory.newInstance(uri, authTokenProvider, config, SecurityPlanImpl.insecure(), null, null)) { + try (var driver = driverFactory.newInstance( + uri, authTokenProvider, null, config, SecurityPlanImpl.insecure(), null, null)) { driver.verifyConnectivity(); try (var session = driver.session()) { messageFormatSetup.accept(driverFactory.getFailingMessageFormat()); diff --git a/driver/src/test/java/org/neo4j/driver/integration/ServerKilledIT.java b/driver/src/test/java/org/neo4j/driver/integration/ServerKilledIT.java index a733a29973..bed0fc5542 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/ServerKilledIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/ServerKilledIT.java @@ -123,6 +123,6 @@ private static void acquireAndReleaseConnections(int count, Driver driver) { private Driver createDriver(Clock clock, Config config) { DriverFactory factory = new DriverFactoryWithClock(clock); return factory.newInstance( - neo4j.uri(), neo4j.authTokenManager(), config, SecurityPlanImpl.insecure(), null, null); + neo4j.uri(), neo4j.authTokenManager(), null, config, SecurityPlanImpl.insecure(), null, null); } } diff --git a/driver/src/test/java/org/neo4j/driver/integration/SessionBoltV3IT.java b/driver/src/test/java/org/neo4j/driver/integration/SessionBoltV3IT.java index 1da068cf9f..8e4bc8990e 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/SessionBoltV3IT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/SessionBoltV3IT.java @@ -265,7 +265,13 @@ void shouldSendGoodbyeWhenClosingDriver() { var driverFactory = new MessageRecordingDriverFactory(); try (var otherDriver = driverFactory.newInstance( - driver.uri(), driver.authTokenManager(), defaultConfig(), SecurityPlanImpl.insecure(), null, null)) { + driver.uri(), + driver.authTokenManager(), + null, + defaultConfig(), + SecurityPlanImpl.insecure(), + null, + null)) { List sessions = new ArrayList<>(); List txs = new ArrayList<>(); for (var i = 0; i < txCount; i++) { diff --git a/driver/src/test/java/org/neo4j/driver/integration/SessionIT.java b/driver/src/test/java/org/neo4j/driver/integration/SessionIT.java index c62b452166..a181bb9e0f 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/SessionIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/SessionIT.java @@ -128,7 +128,7 @@ void shouldKnowSessionIsClosed() { @Test void shouldHandleNullConfig() { // Given - driver = GraphDatabase.driver(neo4j.uri(), neo4j.authTokenManager(), null); + driver = GraphDatabase.driver(neo4j.uri(), neo4j.authTokenManager(), (Config) null); var session = driver.session(); // When @@ -1317,7 +1317,13 @@ private Driver newDriverWithoutRetries() { private Driver newDriverWithFixedRetries(int maxRetriesCount) { DriverFactory driverFactory = new DriverFactoryWithFixedRetryLogic(maxRetriesCount); return driverFactory.newInstance( - neo4j.uri(), neo4j.authTokenManager(), noLoggingConfig(), SecurityPlanImpl.insecure(), null, null); + neo4j.uri(), + neo4j.authTokenManager(), + null, + noLoggingConfig(), + SecurityPlanImpl.insecure(), + null, + null); } private Driver newDriverWithLimitedRetries(int maxTxRetryTime) { diff --git a/driver/src/test/java/org/neo4j/driver/integration/SharedEventLoopIT.java b/driver/src/test/java/org/neo4j/driver/integration/SharedEventLoopIT.java index 894435ae82..56776464e8 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/SharedEventLoopIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/SharedEventLoopIT.java @@ -80,6 +80,7 @@ private Driver createDriver(EventLoopGroup eventLoopGroup) { return driverFactory.newInstance( neo4j.uri(), neo4j.authTokenManager(), + null, Config.defaultConfig(), SecurityPlanImpl.insecure(), eventLoopGroup, diff --git a/driver/src/test/java/org/neo4j/driver/integration/TransactionIT.java b/driver/src/test/java/org/neo4j/driver/integration/TransactionIT.java index 0ac24d02c6..095a473b48 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/TransactionIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/TransactionIT.java @@ -352,7 +352,7 @@ void shouldThrowWhenConnectionKilledDuringTransaction() { var config = Config.builder().withLogging(DEV_NULL_LOGGING).build(); try (var driver = factory.newInstance( - session.uri(), session.authTokenManager(), config, SecurityPlanImpl.insecure(), null, null)) { + session.uri(), session.authTokenManager(), null, config, SecurityPlanImpl.insecure(), null, null)) { var e = assertThrows(ServiceUnavailableException.class, () -> { try (var session1 = driver.session(); var tx = session1.beginTransaction()) { diff --git a/driver/src/test/java/org/neo4j/driver/integration/UnmanagedTransactionIT.java b/driver/src/test/java/org/neo4j/driver/integration/UnmanagedTransactionIT.java index 233d47c525..0b3a06f0d2 100644 --- a/driver/src/test/java/org/neo4j/driver/integration/UnmanagedTransactionIT.java +++ b/driver/src/test/java/org/neo4j/driver/integration/UnmanagedTransactionIT.java @@ -207,7 +207,7 @@ private void testCommitAndRollbackFailurePropagation(boolean commit) { var config = Config.builder().withLogging(DEV_NULL_LOGGING).build(); try (var driver = driverFactory.newInstance( - neo4j.uri(), neo4j.authTokenManager(), config, SecurityPlanImpl.insecure(), null, null)) { + neo4j.uri(), neo4j.authTokenManager(), null, config, SecurityPlanImpl.insecure(), null, null)) { var session = ((InternalDriver) driver).newSession(SessionConfig.defaultConfig(), null); { var tx = beginTransaction(session); diff --git a/driver/src/test/java/org/neo4j/driver/internal/CustomSecurityPlanTest.java b/driver/src/test/java/org/neo4j/driver/internal/CustomSecurityPlanTest.java index 6598ee96b4..f0f49f13e1 100644 --- a/driver/src/test/java/org/neo4j/driver/internal/CustomSecurityPlanTest.java +++ b/driver/src/test/java/org/neo4j/driver/internal/CustomSecurityPlanTest.java @@ -45,6 +45,7 @@ void testCustomSecurityPlanUsed() { driverFactory.newInstance( URI.create("neo4j://somewhere:1234"), new StaticAuthTokenManager(AuthTokens.none()), + null, Config.defaultConfig(), securityPlan, null, diff --git a/driver/src/test/java/org/neo4j/driver/internal/DriverFactoryTest.java b/driver/src/test/java/org/neo4j/driver/internal/DriverFactoryTest.java index fc64256a87..33c5f6c13a 100644 --- a/driver/src/test/java/org/neo4j/driver/internal/DriverFactoryTest.java +++ b/driver/src/test/java/org/neo4j/driver/internal/DriverFactoryTest.java @@ -211,6 +211,7 @@ void shouldUseBuiltInRediscoveryByDefault() { var driver = driverFactory.newInstance( URI.create("neo4j://localhost:7687"), new StaticAuthTokenManager(AuthTokens.none()), + null, Config.defaultConfig(), null, null, @@ -237,6 +238,7 @@ void shouldUseSuppliedRediscovery() { var driver = driverFactory.newInstance( URI.create("neo4j://localhost:7687"), new StaticAuthTokenManager(AuthTokens.none()), + null, Config.defaultConfig(), null, null, @@ -256,7 +258,7 @@ private Driver createDriver(String uri, DriverFactory driverFactory) { private Driver createDriver(String uri, DriverFactory driverFactory, Config config) { var auth = AuthTokens.none(); - return driverFactory.newInstance(URI.create(uri), new StaticAuthTokenManager(auth), config); + return driverFactory.newInstance(URI.create(uri), new StaticAuthTokenManager(auth), null, config); } private static ConnectionPool connectionPoolMock() { diff --git a/driver/src/test/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManagerTests.java b/driver/src/test/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManagerTests.java new file mode 100644 index 0000000000..0b531333d2 --- /dev/null +++ b/driver/src/test/java/org/neo4j/driver/internal/InternalRotatingClientCertificateManagerTests.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +import java.io.File; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.ClientCertificates; + +class InternalRotatingClientCertificateManagerTests { + InternalRotatingClientCertificateManager manager; + + @Test + void shouldThrowOnNullInitialCertificate() { + assertThrows(NullPointerException.class, () -> new InternalRotatingClientCertificateManager(null)); + } + + @Test + void shouldThrowOnNullUpdatedCertificate() { + var file = mock(File.class); + var certificate = ClientCertificates.of(file, file); + var manager = new InternalRotatingClientCertificateManager(certificate); + + assertThrows(NullPointerException.class, () -> manager.rotate(null)); + } + + @Test + void shouldSupplyNullOnSubsequentCalls() { + var file = mock(File.class); + var certificate = ClientCertificates.of(file, file); + var manager = new InternalRotatingClientCertificateManager(certificate); + var actualCertificate = + manager.getClientCertificate().toCompletableFuture().join(); + + for (var i = 0; i < 5; i++) { + assertNull(manager.getClientCertificate().toCompletableFuture().join()); + } + + assertEquals(certificate, actualCertificate); + } + + @Test + void shouldUpdateCertificate() { + var file = mock(File.class); + var certificate = ClientCertificates.of(file, file); + var manager = new InternalRotatingClientCertificateManager(certificate); + var actualCertificate = + manager.getClientCertificate().toCompletableFuture().join(); + manager.getClientCertificate().toCompletableFuture().join(); + + manager.rotate(certificate); + + var updatedCertificate = + manager.getClientCertificate().toCompletableFuture().join(); + assertEquals(certificate, actualCertificate); + assertEquals(certificate, updatedCertificate); + } +} diff --git a/driver/src/test/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializerTest.java b/driver/src/test/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializerTest.java index 76120cc3eb..e12c328185 100644 --- a/driver/src/test/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializerTest.java +++ b/driver/src/test/java/org/neo4j/driver/internal/async/connection/NettyChannelInitializerTest.java @@ -34,12 +34,14 @@ import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.ssl.SslHandler; -import java.security.GeneralSecurityException; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; import java.time.Clock; import javax.net.ssl.SNIHostName; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.neo4j.driver.AuthTokens; +import org.neo4j.driver.Logging; import org.neo4j.driver.RevocationCheckingStrategy; import org.neo4j.driver.internal.BoltServerAddress; import org.neo4j.driver.internal.security.SecurityPlan; @@ -56,7 +58,7 @@ void tearDown() { } @Test - void shouldAddSslHandlerWhenRequiresEncryption() throws Exception { + void shouldAddSslHandlerWhenRequiresEncryption() throws NoSuchAlgorithmException, KeyManagementException { var security = trustAllCertificates(); var initializer = newInitializer(security); @@ -76,7 +78,7 @@ void shouldNotAddSslHandlerWhenDoesNotRequireEncryption() { } @Test - void shouldAddSslHandlerWithHandshakeTimeout() throws Exception { + void shouldAddSslHandlerWithHandshakeTimeout() throws NoSuchAlgorithmException, KeyManagementException { var timeoutMillis = 424242; var security = trustAllCertificates(); var initializer = newInitializer(security, timeoutMillis); @@ -104,13 +106,15 @@ void shouldUpdateChannelAttributes() { } @Test - void shouldIncludeSniHostName() throws Exception { + void shouldIncludeSniHostName() throws NoSuchAlgorithmException, KeyManagementException { var address = new BoltServerAddress("database.neo4j.com", 8989); + var securityPlan = trustAllCertificates(); var initializer = new NettyChannelInitializer( address, - trustAllCertificates(), + securityPlan, 10000, new StaticAuthTokenManager(AuthTokens.none()), + securityPlan.sslContext().toCompletableFuture().join(), Clock.systemUTC(), DEV_NULL_LOGGING); @@ -126,18 +130,20 @@ void shouldIncludeSniHostName() throws Exception { } @Test - void shouldEnableHostnameVerificationWhenConfigured() throws Exception { + void shouldEnableHostnameVerificationWhenConfigured() throws NoSuchAlgorithmException, KeyManagementException { testHostnameVerificationSetting(true, "HTTPS"); } @Test - void shouldNotEnableHostnameVerificationWhenNotConfigured() throws Exception { + void shouldNotEnableHostnameVerificationWhenNotConfigured() + throws NoSuchAlgorithmException, KeyManagementException { testHostnameVerificationSetting(false, null); } - private void testHostnameVerificationSetting(boolean enabled, String expectedValue) throws Exception { - var initializer = - newInitializer(SecurityPlanImpl.forAllCertificates(enabled, RevocationCheckingStrategy.NO_CHECKS)); + private void testHostnameVerificationSetting(boolean enabled, String expectedValue) + throws NoSuchAlgorithmException, KeyManagementException { + var initializer = newInitializer(SecurityPlanImpl.forAllCertificates( + enabled, RevocationCheckingStrategy.NO_CHECKS, null, Logging.none())); initializer.initChannel(channel); @@ -162,11 +168,12 @@ private static NettyChannelInitializer newInitializer( securityPlan, connectTimeoutMillis, new StaticAuthTokenManager(AuthTokens.none()), + securityPlan.sslContext().toCompletableFuture().join(), clock, DEV_NULL_LOGGING); } - private static SecurityPlan trustAllCertificates() throws GeneralSecurityException { - return SecurityPlanImpl.forAllCertificates(false, RevocationCheckingStrategy.NO_CHECKS); + private static SecurityPlan trustAllCertificates() throws NoSuchAlgorithmException, KeyManagementException { + return SecurityPlanImpl.forAllCertificates(false, RevocationCheckingStrategy.NO_CHECKS, null, Logging.none()); } } diff --git a/driver/src/test/java/org/neo4j/driver/internal/security/SSLContextManagerTests.java b/driver/src/test/java/org/neo4j/driver/internal/security/SSLContextManagerTests.java new file mode 100644 index 0000000000..7b4fc69b11 --- /dev/null +++ b/driver/src/test/java/org/neo4j/driver/internal/security/SSLContextManagerTests.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +import java.io.File; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.ClientCertificateManager; +import org.neo4j.driver.ClientCertificates; +import org.neo4j.driver.Logger; +import org.neo4j.driver.Logging; +import org.neo4j.driver.exceptions.ClientException; +import org.neo4j.driver.internal.InternalClientCertificate; + +class SSLContextManagerTests { + private SSLContextManager manager; + + @Test + void shouldErrorOnInitialNullCertificate() throws NoSuchAlgorithmException, KeyManagementException { + // GIVEN + var clientManager = mock(ClientCertificateManager.class); + given(clientManager.getClientCertificate()).willReturn(CompletableFuture.completedStage(null)); + var contextSupplier = mock(SecurityPlan.SSLContextSupplier.class); + var logging = mock(Logging.class); + var logger = mock(Logger.class); + given(logging.getLog(any(Class.class))).willReturn(logger); + manager = new SSLContextManager(clientManager, contextSupplier, logging); + Consumer> verify = contextFuture -> { + assertTrue(contextFuture.isCompletedExceptionally()); + var completionException = assertThrows(CompletionException.class, contextFuture::join); + assertInstanceOf(ClientException.class, completionException.getCause()); + }; + + for (var i = 0; i < 5; i++) { + // WHEN + var contextFuture = manager.getSSLContext().toCompletableFuture(); + // THEN + verify.accept(contextFuture); + } + then(contextSupplier).shouldHaveNoInteractions(); + } + + @SuppressWarnings("unchecked") + @Test + void shouldAllowNullCertificate() throws NoSuchAlgorithmException, KeyManagementException { + // GIVEN + var file = mock(File.class); + var certificate = ClientCertificates.of(file, file); + var clientManager = mock(ClientCertificateManager.class); + given(clientManager.getClientCertificate()) + .willReturn(CompletableFuture.completedStage(certificate), CompletableFuture.completedStage(null)); + var context = mock(SSLContext.class); + var contextSupplier = mock(SecurityPlan.SSLContextSupplier.class); + var keyManagers = new KeyManager[0]; + given(contextSupplier.get(keyManagers)).willReturn(context); + var logging = mock(Logging.class); + var logger = mock(Logger.class); + given(logging.getLog(any(Class.class))).willReturn(logger); + manager = new ExtendedSSLContextManager(clientManager, contextSupplier, logging, ignored -> new KeyManager[0]); + var context1 = manager.getSSLContext().toCompletableFuture().join(); + + // WHEN + var context2 = manager.getSSLContext().toCompletableFuture().join(); + + // THEN + assertEquals(context1, context2); + then(clientManager).should(times(2)).getClientCertificate(); + then(contextSupplier).should().get(keyManagers); + } + + @SuppressWarnings("unchecked") + @Test + void shouldErrorOnCreatingKeyManagers() throws NoSuchAlgorithmException, KeyManagementException { + // GIVEN + var file = mock(File.class); + var certificate = (InternalClientCertificate) ClientCertificates.of(file, file); + var clientManager = mock(ClientCertificateManager.class); + given(clientManager.getClientCertificate()) + .willReturn(CompletableFuture.completedStage(certificate), CompletableFuture.completedStage(null)); + var contextSupplier = mock(SecurityPlan.SSLContextSupplier.class); + var logging = mock(Logging.class); + var logger = mock(Logger.class); + given(logging.getLog(any(Class.class))).willReturn(logger); + Function keyManagersFunction = mock(Function.class); + var exception = new RuntimeException(); + given(keyManagersFunction.apply(certificate)).willThrow(exception); + manager = new ExtendedSSLContextManager(clientManager, contextSupplier, logging, keyManagersFunction); + Consumer> verify = contextFuture -> { + assertTrue(contextFuture.isCompletedExceptionally()); + var completionException = assertThrows(CompletionException.class, contextFuture::join); + assertInstanceOf(ClientException.class, completionException.getCause()); + assertEquals(exception, completionException.getCause().getCause()); + }; + + for (var i = 0; i < 2; i++) { + // WHEN + var contextFuture = manager.getSSLContext().toCompletableFuture(); + // THEN + verify.accept(contextFuture); + } + then(clientManager).should(times(2)).getClientCertificate(); + then(keyManagersFunction).should().apply(certificate); + then(contextSupplier).shouldHaveNoInteractions(); + } + + @SuppressWarnings("unchecked") + @Test + void shouldReplaceErrorWithValidContext() throws NoSuchAlgorithmException, KeyManagementException { + // GIVEN + var file = mock(File.class); + var certificate = (InternalClientCertificate) ClientCertificates.of(file, file); + var clientManager = mock(ClientCertificateManager.class); + given(clientManager.getClientCertificate()).willReturn(CompletableFuture.completedStage(certificate)); + var context = mock(SSLContext.class); + var contextSupplier = mock(SecurityPlan.SSLContextSupplier.class); + given(contextSupplier.get(any())).willReturn(context); + var logging = mock(Logging.class); + var logger = mock(Logger.class); + given(logging.getLog(any(Class.class))).willReturn(logger); + Function keyManagersFunction = mock(Function.class); + var exception = new RuntimeException(); + var callNum = new AtomicInteger(); + var keyManagers = new KeyManager[0]; + given(keyManagersFunction.apply(certificate)).willAnswer(invocation -> { + if (callNum.get() > 0) { + return keyManagers; + } else { + callNum.getAndIncrement(); + throw exception; + } + }); + manager = new ExtendedSSLContextManager(clientManager, contextSupplier, logging, keyManagersFunction); + var failedContextFuture = manager.getSSLContext().toCompletableFuture(); + assertTrue(failedContextFuture.isCompletedExceptionally()); + + // WHEN + var actualContext = manager.getSSLContext().toCompletableFuture().join(); + + // THEN + assertEquals(context, actualContext); + then(clientManager).should(times(2)).getClientCertificate(); + then(keyManagersFunction).should(times(2)).apply(certificate); + then(contextSupplier).should().get(keyManagers); + } + + @Test + void shouldAcceptNullCertificateManager() throws NoSuchAlgorithmException, KeyManagementException { + // GIVEN + var context = mock(SSLContext.class); + var contextSupplier = mock(SecurityPlan.SSLContextSupplier.class); + var keyManagers = new KeyManager[0]; + given(contextSupplier.get(keyManagers)).willReturn(context); + var logging = mock(Logging.class); + var logger = mock(Logger.class); + given(logging.getLog(any(Class.class))).willReturn(logger); + manager = new SSLContextManager(null, contextSupplier, logging); + + var actualContext = manager.getSSLContext().toCompletableFuture().join(); + + assertEquals(context, actualContext); + then(contextSupplier).should().get(keyManagers); + } + + private static class ExtendedSSLContextManager extends SSLContextManager { + private final Function keyManagersFunction; + + public ExtendedSSLContextManager( + ClientCertificateManager clientCertificateManager, + SecurityPlan.SSLContextSupplier sslContextSupplier, + Logging logging, + Function keyManagersFunction) + throws NoSuchAlgorithmException, KeyManagementException { + super(clientCertificateManager, sslContextSupplier, logging); + this.keyManagersFunction = keyManagersFunction; + } + + @Override + protected KeyManager[] createKeyManagers(InternalClientCertificate clientCertificate) { + return keyManagersFunction.apply(clientCertificate); + } + } +} diff --git a/driver/src/test/java/org/neo4j/driver/internal/security/SecurityPlans.java b/driver/src/test/java/org/neo4j/driver/internal/security/SecurityPlans.java index 849ece030e..39d4f4c4f9 100644 --- a/driver/src/test/java/org/neo4j/driver/internal/security/SecurityPlans.java +++ b/driver/src/test/java/org/neo4j/driver/internal/security/SecurityPlans.java @@ -28,6 +28,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.neo4j.driver.Config; +import org.neo4j.driver.Logging; import org.neo4j.driver.exceptions.ClientException; import org.neo4j.driver.internal.SecuritySettings; @@ -53,7 +54,7 @@ private static Stream allSecureSchemes() { void testEncryptionSchemeEnablesEncryption(String scheme) { var securitySettings = new SecuritySettings.SecuritySettingsBuilder().build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertTrue(securityPlan.requiresEncryption()); } @@ -63,7 +64,7 @@ void testEncryptionSchemeEnablesEncryption(String scheme) { void testSystemCertCompatibleConfiguration(String scheme) { var securitySettings = new SecuritySettings.SecuritySettingsBuilder().build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertTrue(securityPlan.requiresEncryption()); assertTrue(securityPlan.requiresHostnameVerification()); @@ -75,7 +76,7 @@ void testSystemCertCompatibleConfiguration(String scheme) { void testSelfSignedCertConfigDisablesHostnameVerification(String scheme) { var securitySettings = new SecuritySettings.SecuritySettingsBuilder().build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertTrue(securityPlan.requiresEncryption()); assertFalse(securityPlan.requiresHostnameVerification()); @@ -87,7 +88,9 @@ void testThrowsOnUserCustomizedEncryption(String scheme) { var securitySettings = new SecuritySettings.SecuritySettingsBuilder().withEncryption().build(); - var ex = assertThrows(ClientException.class, () -> SecurityPlans.createSecurityPlan(securitySettings, scheme)); + var ex = assertThrows( + ClientException.class, + () -> SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none())); assertTrue(ex.getMessage() .contains(String.format( @@ -101,7 +104,9 @@ void testThrowsOnUserCustomizedTrustConfiguration(String scheme) { .withTrustStrategy(Config.TrustStrategy.trustAllCertificates()) .build(); - var ex = assertThrows(ClientException.class, () -> SecurityPlans.createSecurityPlan(securitySettings, scheme)); + var ex = assertThrows( + ClientException.class, + () -> SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none())); assertTrue(ex.getMessage() .contains(String.format( @@ -116,7 +121,9 @@ void testThrowsOnUserCustomizedTrustConfigurationAndEncryption(String scheme) { .withEncryption() .build(); - var ex = assertThrows(ClientException.class, () -> SecurityPlans.createSecurityPlan(securitySettings, scheme)); + var ex = assertThrows( + ClientException.class, + () -> SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none())); assertTrue(ex.getMessage() .contains(String.format( @@ -128,7 +135,7 @@ void testThrowsOnUserCustomizedTrustConfigurationAndEncryption(String scheme) { void testNoEncryption(String scheme) { var securitySettings = new SecuritySettings.SecuritySettingsBuilder().build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertFalse(securityPlan.requiresEncryption()); } @@ -139,7 +146,7 @@ void testConfiguredEncryption(String scheme) { var securitySettings = new SecuritySettings.SecuritySettingsBuilder().withEncryption().build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertTrue(securityPlan.requiresEncryption()); } @@ -152,7 +159,7 @@ void testConfiguredAllCertificates(String scheme) { .withTrustStrategy(Config.TrustStrategy.trustAllCertificates()) .build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertTrue(securityPlan.requiresEncryption()); } @@ -166,7 +173,7 @@ void testConfigureStrictRevocationChecking(String scheme) { .withEncryption() .build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertEquals(STRICT, securityPlan.revocationCheckingStrategy()); } @@ -180,7 +187,7 @@ void testConfigureVerifyIfPresentRevocationChecking(String scheme) { .withEncryption() .build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertEquals(VERIFY_IF_PRESENT, securityPlan.revocationCheckingStrategy()); } @@ -193,7 +200,7 @@ void testRevocationCheckingDisabledByDefault(String scheme) { .withEncryption() .build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme); + var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, scheme, null, Logging.none()); assertEquals(NO_CHECKS, securityPlan.revocationCheckingStrategy()); } diff --git a/driver/src/test/java/org/neo4j/driver/internal/util/io/ChannelTrackingConnector.java b/driver/src/test/java/org/neo4j/driver/internal/util/io/ChannelTrackingConnector.java index 00440cbda1..e1bbe1f0ea 100644 --- a/driver/src/test/java/org/neo4j/driver/internal/util/io/ChannelTrackingConnector.java +++ b/driver/src/test/java/org/neo4j/driver/internal/util/io/ChannelTrackingConnector.java @@ -20,6 +20,7 @@ import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import java.util.List; +import java.util.function.Function; import org.neo4j.driver.internal.BoltServerAddress; import org.neo4j.driver.internal.async.connection.ChannelConnector; @@ -33,9 +34,13 @@ public ChannelTrackingConnector(ChannelConnector realConnector, List ch } @Override - public ChannelFuture connect(BoltServerAddress address, Bootstrap bootstrap) { - var channelFuture = realConnector.connect(address, bootstrap); - channels.add(channelFuture.channel()); - return channelFuture; + public ChannelFuture connect( + BoltServerAddress address, + Bootstrap bootstrap, + Function channelFutureExtensionMapper) { + return realConnector.connect(address, bootstrap, channelFuture -> { + channels.add(channelFuture.channel()); + return channelFutureExtensionMapper.apply(channelFuture); + }); } } diff --git a/driver/src/test/java/org/neo4j/driver/org/neo4j/driver/ClientCertificatesTests.java b/driver/src/test/java/org/neo4j/driver/org/neo4j/driver/ClientCertificatesTests.java new file mode 100644 index 0000000000..4a57b56152 --- /dev/null +++ b/driver/src/test/java/org/neo4j/driver/org/neo4j/driver/ClientCertificatesTests.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.org.neo4j.driver; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; + +import java.io.File; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.neo4j.driver.ClientCertificates; + +class ClientCertificatesTests { + @ParameterizedTest + @MethodSource("shouldThrowOnNullArgs") + void shouldThrowOnNull(File certificate, File key) { + assertThrows(NullPointerException.class, () -> ClientCertificates.of(certificate, key)); + } + + static Stream shouldThrowOnNullArgs() { + return Stream.of( + Arguments.of(null, mock(File.class)), Arguments.of(mock(File.class), null), Arguments.of(null, null)); + } + + @ParameterizedTest + @MethodSource("shouldThrowOnNullWithPassword") + void shouldThrowOnNullWithPassword(File certificate, File key, String password) { + assertThrows(NullPointerException.class, () -> ClientCertificates.of(certificate, key, password)); + } + + static Stream shouldThrowOnNullWithPassword() { + return Stream.of( + Arguments.of(null, mock(File.class), "password"), + Arguments.of(mock(File.class), null, "password"), + Arguments.of(null, null, "password")); + } + + @Test + void shouldAcceptNullPassword() { + var clientCertificate = ClientCertificates.of(mock(File.class), mock(File.class), null); + + assertNotNull(clientCertificate); + } +} diff --git a/examples/pom.xml b/examples/pom.xml index 060db236e8..add6b4cea6 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -6,7 +6,7 @@ org.neo4j.driver neo4j-java-driver-parent - 5.18-SNAPSHOT + 5.19-SNAPSHOT org.neo4j.doc.driver diff --git a/pom.xml b/pom.xml index b3b473fb9f..072fd84512 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.neo4j.driver neo4j-java-driver-parent - 5.18-SNAPSHOT + 5.19-SNAPSHOT pom Neo4j Java Driver Project diff --git a/testkit-backend/pom.xml b/testkit-backend/pom.xml index 9da5ffb90d..ab73cd2c09 100644 --- a/testkit-backend/pom.xml +++ b/testkit-backend/pom.xml @@ -7,7 +7,7 @@ neo4j-java-driver-parent org.neo4j.driver - 5.18-SNAPSHOT + 5.19-SNAPSHOT testkit-backend diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/TestkitState.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/TestkitState.java index 3822823c56..d5ab468f63 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/TestkitState.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/TestkitState.java @@ -43,6 +43,7 @@ import neo4j.org.testkit.backend.messages.responses.TestkitResponse; import org.neo4j.driver.AuthTokenManager; import org.neo4j.driver.BookmarkManager; +import org.neo4j.driver.ClientCertificateManager; import org.neo4j.driver.Logging; import org.neo4j.driver.internal.cluster.RoutingTableRegistry; import reactor.core.publisher.Mono; @@ -54,6 +55,8 @@ public class TestkitState { private static final String RESULT_NOT_FOUND_MESSAGE = "Could not find result"; private static final String BOOKMARK_MANAGER_NOT_FOUND_MESSAGE = "Could not find bookmark manager"; private static final String AUTH_PROVIDER_NOT_FOUND_MESSAGE = "Could not find authentication provider"; + private static final String CLIENT_CERTIFICATE_PROVIDER_NOT_FOUND_MESSAGE = + "Could not find client certificate provider"; private final Map driverIdToDriverHolder = new HashMap<>(); @@ -79,6 +82,7 @@ public class TestkitState { private final Map bookmarkManagerIdToBookmarkManager = new HashMap<>(); private final Logging logging; private final Map authProviderIdToAuthProvider = new HashMap<>(); + private final Map managerIdToClientCertificateManager = new HashMap<>(); @Getter private final Map errors = new HashMap<>(); @@ -260,6 +264,20 @@ public void removeAuthProvider(String id) { } } + public void addClientCertificateManager(String id, ClientCertificateManager manager) { + managerIdToClientCertificateManager.put(id, manager); + } + + public ClientCertificateManager getClientCertificateManager(String id) { + return get(id, managerIdToClientCertificateManager, CLIENT_CERTIFICATE_PROVIDER_NOT_FOUND_MESSAGE); + } + + public void removeClientCertificateManager(String id) { + if (managerIdToClientCertificateManager.remove(id) == null) { + throw new RuntimeException(CLIENT_CERTIFICATE_PROVIDER_NOT_FOUND_MESSAGE); + } + } + private String add(T value, Map idToT) { var id = newId(); idToT.put(id, value); diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificate.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificate.java new file mode 100644 index 0000000000..c44be03e11 --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificate.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.requests; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "name") +public class ClientCertificate { + private Data data; + + @Getter + @Setter + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Data { + String certfile; + String keyfile; + String password; + } +} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderClose.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderClose.java new file mode 100644 index 0000000000..4526a3a705 --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderClose.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.requests; + +import lombok.Getter; +import lombok.Setter; +import neo4j.org.testkit.backend.TestkitState; +import neo4j.org.testkit.backend.messages.responses.ClientCertificateProvider; +import neo4j.org.testkit.backend.messages.responses.TestkitResponse; + +@Setter +@Getter +public class ClientCertificateProviderClose extends AbstractBasicTestkitRequest { + private AuthTokenManagerCloseBody data; + + @Override + protected TestkitResponse processAndCreateResponse(TestkitState testkitState) { + testkitState.removeClientCertificateManager(data.getId()); + return ClientCertificateProvider.builder() + .data(ClientCertificateProvider.ClientCertificateProviderBody.builder() + .id(data.getId()) + .build()) + .build(); + } + + @Setter + @Getter + public static class AuthTokenManagerCloseBody { + private String id; + } +} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderCompleted.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderCompleted.java new file mode 100644 index 0000000000..638901d684 --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ClientCertificateProviderCompleted.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.requests; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class ClientCertificateProviderCompleted implements TestkitCallbackResult { + private ClientCertificateProviderCompletedBody data; + + @Override + public String getCallbackId() { + return data.getRequestId(); + } + + @Setter + @Getter + public static class ClientCertificateProviderCompletedBody { + private String requestId; + private ClientCertificate clientCertificate; + private boolean hasUpdate; + } +} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java index a95f6b8c23..3fea70b749 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java @@ -68,7 +68,8 @@ public class GetFeatures implements TestkitRequest { "Feature:API:Session:AuthConfig", "Feature:Auth:Managed", "Feature:API:Driver.SupportsSessionAuth", - "Feature:API:RetryableExceptions")); + "Feature:API:RetryableExceptions", + "Feature:API:SSLClientCertificate")); private static final Set SYNC_FEATURES = new HashSet<>(Arrays.asList( "Feature:Bolt:3.0", diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewClientCertificateProvider.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewClientCertificateProvider.java new file mode 100644 index 0000000000..f65e3b9d94 --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewClientCertificateProvider.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.requests; + +import java.nio.file.Paths; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import lombok.Getter; +import lombok.Setter; +import neo4j.org.testkit.backend.TestkitState; +import neo4j.org.testkit.backend.messages.responses.ClientCertificateProvider; +import neo4j.org.testkit.backend.messages.responses.ClientCertificateProviderRequest; +import neo4j.org.testkit.backend.messages.responses.TestkitCallback; +import neo4j.org.testkit.backend.messages.responses.TestkitResponse; +import org.neo4j.driver.ClientCertificate; +import org.neo4j.driver.ClientCertificateManager; +import org.neo4j.driver.ClientCertificates; + +@Setter +@Getter +public class NewClientCertificateProvider extends AbstractBasicTestkitRequest { + private NewClientCertificateProviderBody data; + + @Override + protected TestkitResponse processAndCreateResponse(TestkitState testkitState) { + var id = testkitState.newId(); + testkitState.addClientCertificateManager(id, new TestkitClientCertificateManager(id, testkitState)); + return ClientCertificateProvider.builder() + .data(ClientCertificateProvider.ClientCertificateProviderBody.builder() + .id(id) + .build()) + .build(); + } + + private record TestkitClientCertificateManager(String id, TestkitState testkitState) + implements ClientCertificateManager { + @Override + public CompletionStage getClientCertificate() { + var callbackId = testkitState.newId(); + + var callback = ClientCertificateProviderRequest.builder() + .data(ClientCertificateProviderRequest.ClientCertificateProviderRequestBody.builder() + .clientCertificateProviderId(id) + .build()) + .build(); + + ClientCertificate clientCertificate = null; + var callbackStage = dispatchTestkitCallback(testkitState, callback); + try { + var response = callbackStage.toCompletableFuture().get(); + if (response instanceof ClientCertificateProviderCompleted clientCertificateComplete) { + var data = clientCertificateComplete.getData(); + var certificateData = data.getClientCertificate().getData(); + var hasUpdate = data.isHasUpdate(); + if (hasUpdate) { + clientCertificate = ClientCertificates.of( + Paths.get(certificateData.getCertfile()).toFile(), + Paths.get(certificateData.getKeyfile()).toFile(), + certificateData.getPassword()); + } + } + } catch (Exception e) { + throw new RuntimeException("Unexpected failure during Testkit callback", e); + } + return CompletableFuture.completedStage(clientCertificate); + } + + private CompletionStage dispatchTestkitCallback( + TestkitState testkitState, TestkitCallback response) { + var future = new CompletableFuture(); + testkitState.getCallbackIdToFuture().put(response.getCallbackId(), future); + testkitState.getResponseWriter().accept(response); + return future; + } + } + + @Setter + @Getter + public static class NewClientCertificateProviderBody {} +} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewDriver.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewDriver.java index 781f44a5c0..bbeb178a3b 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewDriver.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/NewDriver.java @@ -45,6 +45,9 @@ import neo4j.org.testkit.backend.messages.responses.TestkitCallback; import neo4j.org.testkit.backend.messages.responses.TestkitResponse; import org.neo4j.driver.AuthTokenManager; +import org.neo4j.driver.ClientCertificateManager; +import org.neo4j.driver.ClientCertificateManagers; +import org.neo4j.driver.ClientCertificates; import org.neo4j.driver.Config; import org.neo4j.driver.NotificationConfig; import org.neo4j.driver.internal.BoltServerAddress; @@ -101,6 +104,16 @@ public TestkitResponse process(TestkitState testkitState) { configBuilder.withNotificationConfig( toNotificationConfig(data.notificationsMinSeverity, data.notificationsDisabledCategories)); configBuilder.withDriverMetrics(); + var clientCertificateManager = Optional.ofNullable(data.getClientCertificateProviderId()) + .map(testkitState::getClientCertificateManager) + .or(() -> Optional.ofNullable(data.getClientCertificate()) + .map(ClientCertificate::getData) + .map(certificateData -> ClientCertificates.of( + Paths.get(certificateData.getCertfile()).toFile(), + Paths.get(certificateData.getKeyfile()).toFile(), + certificateData.getPassword())) + .map(ClientCertificateManagers::rotating)) + .orElse(null); configBuilder.withLogging(testkitState.getLogging()); org.neo4j.driver.Driver driver; var config = configBuilder.build(); @@ -108,6 +121,7 @@ public TestkitResponse process(TestkitState testkitState) { driver = driver( URI.create(data.uri), authTokenManager, + clientCertificateManager, config, domainNameResolver, configureSecuritySettingsBuilder(), @@ -203,15 +217,17 @@ private CompletionStage dispatchTestkitCallback( private org.neo4j.driver.Driver driver( URI uri, AuthTokenManager authTokenManager, + ClientCertificateManager clientCertificateManager, Config config, DomainNameResolver domainNameResolver, SecuritySettings.SecuritySettingsBuilder securitySettingsBuilder, TestkitState testkitState, String driverId) { var securitySettings = securitySettingsBuilder.build(); - var securityPlan = SecurityPlans.createSecurityPlan(securitySettings, uri.getScheme()); + var securityPlan = SecurityPlans.createSecurityPlan( + securitySettings, uri.getScheme(), clientCertificateManager, config.logging()); return new DriverFactoryWithDomainNameResolver(domainNameResolver, testkitState, driverId) - .newInstance(uri, authTokenManager, config, securityPlan, null, null); + .newInstance(uri, authTokenManager, clientCertificateManager, config, securityPlan, null, null); } private Optional handleExceptionAsErrorResponse(TestkitState testkitState, RuntimeException e) { @@ -293,6 +309,8 @@ public static class NewDriverBody { private boolean encrypted; private List trustedCertificates; private Boolean telemetryDisabled; + private ClientCertificate clientCertificate; + private String clientCertificateProviderId; } @RequiredArgsConstructor diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java index 9a7ea00bc3..237840feb7 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java @@ -73,7 +73,10 @@ @JsonSubTypes.Type(FakeTimeTick.class), @JsonSubTypes.Type(FakeTimeUninstall.class), @JsonSubTypes.Type(CheckSessionAuthSupport.class), - @JsonSubTypes.Type(VerifyAuthentication.class) + @JsonSubTypes.Type(VerifyAuthentication.class), + @JsonSubTypes.Type(NewClientCertificateProvider.class), + @JsonSubTypes.Type(ClientCertificateProviderCompleted.class), + @JsonSubTypes.Type(ClientCertificateProviderClose.class) }) public interface TestkitRequest { TestkitResponse process(TestkitState testkitState); diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProvider.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProvider.java new file mode 100644 index 0000000000..fca91060bb --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProvider.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.responses; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ClientCertificateProvider implements TestkitResponse { + private ClientCertificateProviderBody data; + + @Override + public String testkitName() { + return "ClientCertificateProvider"; + } + + @Getter + @Builder + public static class ClientCertificateProviderBody { + private String id; + } +} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProviderRequest.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProviderRequest.java new file mode 100644 index 0000000000..d00d79d6db --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/ClientCertificateProviderRequest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.responses; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ClientCertificateProviderRequest implements TestkitCallback { + private ClientCertificateProviderRequestBody data; + + @Override + public String getCallbackId() { + return data.getId(); + } + + @Override + public String testkitName() { + return "ClientCertificateProviderRequest"; + } + + @Getter + @Builder + public static class ClientCertificateProviderRequestBody { + private String id; + private String clientCertificateProviderId; + } +} diff --git a/testkit-tests/pom.xml b/testkit-tests/pom.xml index e00827f881..55a8d3280c 100644 --- a/testkit-tests/pom.xml +++ b/testkit-tests/pom.xml @@ -6,7 +6,7 @@ org.neo4j.driver neo4j-java-driver-parent - 5.18-SNAPSHOT + 5.19-SNAPSHOT ..