diff --git a/driver-core/src/main/com/mongodb/AutoEncryptionSettings.java b/driver-core/src/main/com/mongodb/AutoEncryptionSettings.java index 187d3421235..dfe7e52d40d 100644 --- a/driver-core/src/main/com/mongodb/AutoEncryptionSettings.java +++ b/driver-core/src/main/com/mongodb/AutoEncryptionSettings.java @@ -17,6 +17,7 @@ package com.mongodb; import com.mongodb.annotations.NotThreadSafe; +import com.mongodb.connection.ProxySettings; import com.mongodb.lang.Nullable; import org.bson.BsonDocument; @@ -69,6 +70,7 @@ public final class AutoEncryptionSettings { private final String keyVaultNamespace; private final Map> kmsProviders; private final Map kmsProviderSslContextMap; + private final ProxySettings proxySettings; private final Map>> kmsProviderPropertySuppliers; private final Map schemaMap; private final Map extraOptions; @@ -88,6 +90,7 @@ public static final class Builder { private String keyVaultNamespace; private Map> kmsProviders; private Map kmsProviderSslContextMap = new HashMap<>(); + private ProxySettings proxySettings = ProxySettings.builder().build(); private Map>> kmsProviderPropertySuppliers = new HashMap<>(); private Map schemaMap = Collections.emptyMap(); private Map extraOptions = Collections.emptyMap(); @@ -162,6 +165,26 @@ public Builder kmsProviderSslContextMap(final Map kmsProvide return this; } + /** + * Sets the proxy to route Key Management Service (KMS) requests through. + * + *

Both {@link com.mongodb.connection.ProxyProtocol#HTTP HTTP} and + * {@link com.mongodb.connection.ProxyProtocol#SOCKS5 SOCKS5} proxies are supported. TLS is always negotiated + * end-to-end with the KMS host, so the proxy relays the session without being able to read it.

+ * + *

Defaults to a {@link ProxySettings} with no host, in which case the driver connects to KMS hosts + * directly.

+ * + * @param proxySettings the proxy settings, which may not be null. + * @return this + * @see #getProxySettings() + * @since 5.11 + */ + public Builder proxySettings(final ProxySettings proxySettings) { + this.proxySettings = notNull("proxySettings", proxySettings); + return this; + } + /** * Sets the map from namespace to local schema document * @@ -406,6 +429,16 @@ public Map getKmsProviderSslContextMap() { return unmodifiableMap(kmsProviderSslContextMap); } + /** + * Gets the proxy that Key Management Service (KMS) requests are routed through. + * + * @return the proxy settings, never null. {@link ProxySettings#isProxyEnabled()} is false if no proxy is configured. + * @since 5.11 + */ + public ProxySettings getProxySettings() { + return proxySettings; + } + /** * Gets the map of namespace to local JSON schema. *

@@ -529,6 +562,7 @@ private AutoEncryptionSettings(final Builder builder) { this.keyVaultNamespace = notNull("keyVaultNamespace", builder.keyVaultNamespace); this.kmsProviders = notNull("kmsProviders", builder.kmsProviders); this.kmsProviderSslContextMap = notNull("kmsProviderSslContextMap", builder.kmsProviderSslContextMap); + this.proxySettings = builder.proxySettings; this.kmsProviderPropertySuppliers = notNull("kmsProviderPropertySuppliers", builder.kmsProviderPropertySuppliers); this.schemaMap = notNull("schemaMap", builder.schemaMap); this.extraOptions = notNull("extraOptions", builder.extraOptions); diff --git a/driver-core/src/main/com/mongodb/ClientEncryptionSettings.java b/driver-core/src/main/com/mongodb/ClientEncryptionSettings.java index 252d9d0ff9c..d310f4830ee 100644 --- a/driver-core/src/main/com/mongodb/ClientEncryptionSettings.java +++ b/driver-core/src/main/com/mongodb/ClientEncryptionSettings.java @@ -18,6 +18,7 @@ import com.mongodb.annotations.Alpha; import com.mongodb.annotations.NotThreadSafe; +import com.mongodb.connection.ProxySettings; import com.mongodb.annotations.Reason; import com.mongodb.lang.Nullable; @@ -49,6 +50,7 @@ public final class ClientEncryptionSettings { private final Map> kmsProviders; private final Map>> kmsProviderPropertySuppliers; private final Map kmsProviderSslContextMap; + private final ProxySettings proxySettings; @Nullable private final Long timeoutMS; @Nullable @@ -65,6 +67,7 @@ public static final class Builder { private Map> kmsProviders; private Map>> kmsProviderPropertySuppliers = new HashMap<>(); private Map kmsProviderSslContextMap = new HashMap<>(); + private ProxySettings proxySettings = ProxySettings.builder().build(); @Nullable private Long timeoutMS; @Nullable @@ -136,6 +139,26 @@ public Builder kmsProviderSslContextMap(final Map kmsProvide return this; } + /** + * Sets the proxy to route Key Management Service (KMS) requests through. + * + *

Both {@link com.mongodb.connection.ProxyProtocol#HTTP HTTP} and + * {@link com.mongodb.connection.ProxyProtocol#SOCKS5 SOCKS5} proxies are supported. TLS is always negotiated + * end-to-end with the KMS host, so the proxy relays the session without being able to read it.

+ * + *

Defaults to a {@link ProxySettings} with no host, in which case the driver connects to KMS hosts + * directly.

+ * + * @param proxySettings the proxy settings, which may not be null. + * @return this + * @see #getProxySettings() + * @since 5.11 + */ + public Builder proxySettings(final ProxySettings proxySettings) { + this.proxySettings = notNull("proxySettings", proxySettings); + return this; + } + /** * The cache expiration time for data encryption keys. *

Defaults to {@code null} which defers to libmongocrypt's default which is currently 60000 ms. Set to 0 to disable key expiration.

@@ -335,6 +358,16 @@ public Map getKmsProviderSslContextMap() { return unmodifiableMap(kmsProviderSslContextMap); } + /** + * Gets the proxy that Key Management Service (KMS) requests are routed through. + * + * @return the proxy settings, never null. {@link ProxySettings#isProxyEnabled()} is false if no proxy is configured. + * @since 5.11 + */ + public ProxySettings getProxySettings() { + return proxySettings; + } + /** * Returns the cache expiration time for data encryption keys. * @@ -399,6 +432,7 @@ private ClientEncryptionSettings(final Builder builder) { this.kmsProviders = notNull("kmsProviders", builder.kmsProviders); this.kmsProviderPropertySuppliers = notNull("kmsProviderPropertySuppliers", builder.kmsProviderPropertySuppliers); this.kmsProviderSslContextMap = notNull("kmsProviderSslContextMap", builder.kmsProviderSslContextMap); + this.proxySettings = builder.proxySettings; this.timeoutMS = builder.timeoutMS; this.keyExpirationMS = builder.keyExpirationMS; } diff --git a/driver-core/src/main/com/mongodb/connection/ProxyProtocol.java b/driver-core/src/main/com/mongodb/connection/ProxyProtocol.java new file mode 100644 index 00000000000..067eb4d6ed6 --- /dev/null +++ b/driver-core/src/main/com/mongodb/connection/ProxyProtocol.java @@ -0,0 +1,60 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.connection; + +import com.mongodb.AutoEncryptionSettings; +import com.mongodb.ClientEncryptionSettings; + +/** + * The protocol spoken to a proxy server. + * + * @see ProxySettings.Builder#protocol(ProxyProtocol) + * @since 5.11 + */ +public enum ProxyProtocol { + /** + * SOCKS5, as specified by RFC 1928. + * + *

This is the default, and is the only protocol supported for connections to a MongoDB server.

+ */ + SOCKS5, + + /** + * HTTP, using the {@code CONNECT} method to establish a tunnel to the target host. + * + *

This protocol is currently supported only for Key Management Service (KMS) requests made by in-use + * encryption, configured via {@link ClientEncryptionSettings.Builder#proxySettings(ProxySettings)} or + * {@link AutoEncryptionSettings.Builder#proxySettings(ProxySettings)}. Configuring it for connections to a + * MongoDB server is rejected when the client is created.

+ * + *

A port must be specified explicitly with {@link ProxySettings.Builder#port(int)}, as there is no + * standard port for an HTTP proxy.

+ */ + HTTP, + + /** + * HTTP over TLS, using the {@code CONNECT} method to establish a tunnel to the target host. + * + *

The connection to the proxy itself is protected with TLS, configured by + * {@link ProxySettings.Builder#sslContext(javax.net.ssl.SSLContext)}. The tunnel then carries a second, independent + * TLS session negotiated end-to-end with the target host, so the proxy cannot read it.

+ * + *

As with {@link #HTTP}, this is supported only for Key Management Service (KMS) requests, and a port must be + * specified explicitly.

+ */ + HTTPS +} diff --git a/driver-core/src/main/com/mongodb/connection/ProxySettings.java b/driver-core/src/main/com/mongodb/connection/ProxySettings.java index ed95a50d96b..bbacfcd3d88 100644 --- a/driver-core/src/main/com/mongodb/connection/ProxySettings.java +++ b/driver-core/src/main/com/mongodb/connection/ProxySettings.java @@ -23,6 +23,7 @@ import com.mongodb.annotations.Immutable; import com.mongodb.lang.Nullable; +import javax.net.ssl.SSLContext; import java.nio.charset.StandardCharsets; import java.util.Objects; @@ -31,16 +32,36 @@ import static com.mongodb.assertions.Assertions.notNull; /** - * This setting is only applicable when communicating with a MongoDB server using the synchronous variant of {@code MongoClient}. - *

- * This setting is furthermore ignored if: + * The settings for reaching a destination through a proxy server. + * + *

These settings are applied in two independent places, which support different + * {@linkplain ProxyProtocol protocols}:

*
    - *
  • the communication is via {@linkplain com.mongodb.UnixServerAddress Unix domain socket}.
  • - *
  • a {@link TransportSettings} is {@linkplain MongoClientSettings.Builder#transportSettings(TransportSettings)} - * configured}.
  • + *
  • Connections to a MongoDB server, configured through {@link SocketSettings#getProxySettings()}. + * Only {@link ProxyProtocol#SOCKS5} is supported here; configuring any other protocol is rejected when the + * connection is established. This is furthermore ignored if: + *
      + *
    • the communication is via {@linkplain com.mongodb.UnixServerAddress Unix domain socket}.
    • + *
    • a {@link TransportSettings} is {@linkplain MongoClientSettings.Builder#transportSettings(TransportSettings)} + * configured}.
    • + *
    + *
  • + *
  • Key Management Service (KMS) requests made by in-use encryption, configured through + * {@link ClientEncryptionSettings#getProxySettings()} or {@link AutoEncryptionSettings#getProxySettings()}. + * All {@linkplain ProxyProtocol protocols} are supported here. TLS is always negotiated end-to-end with the KMS + * host, so a proxy relays the session without being able to read it.
  • *
* + *

These settings are only applicable when using the synchronous variant of {@code MongoClient}. They are ignored by + * the reactive streams driver, which rejects a proxy configured for connections to a MongoDB server and does not + * currently route KMS requests through a proxy.

+ * + *

A proxy configured for connections to a MongoDB server is not applied to KMS requests, and vice versa; each must + * be configured where it is needed.

+ * * @see SocketSettings#getProxySettings() + * @see ClientEncryptionSettings#getProxySettings() + * @see AutoEncryptionSettings#getProxySettings() * @see ClientEncryptionSettings#getKeyVaultMongoClientSettings() * @see AutoEncryptionSettings#getKeyVaultMongoClientSettings() * @since 4.11 @@ -59,6 +80,9 @@ public final class ProxySettings { private final String username; @Nullable private final String password; + private final ProxyProtocol protocol; + @Nullable + private final SSLContext sslContext; /** * Creates a {@link Builder} for creating a new {@link ProxySettings} instance. @@ -83,6 +107,9 @@ public static ProxySettings.Builder builder(final ProxySettings proxySettings) { * A builder for an instance of {@code ProxySettings}. */ public static final class Builder { + private ProxyProtocol protocol = ProxyProtocol.SOCKS5; + @Nullable + private SSLContext sslContext; private String host; private Integer port; private String username; @@ -107,17 +134,21 @@ public ProxySettings.Builder applySettings(final ProxySettings proxySettings) { this.port = proxySettings.port; this.username = proxySettings.username; this.password = proxySettings.password; + this.protocol = proxySettings.protocol; + this.sslContext = proxySettings.sslContext; return this; } /** - * Sets the SOCKS5 proxy host to establish a connection through. + * Sets the proxy host to establish a connection through. * *

The host can be specified as an IPv4 address (e.g., "192.168.1.1"), * an IPv6 address (e.g., "2001:0db8:85a3:0000:0000:8a2e:0370:7334"), * or a domain name (e.g., "proxy.example.com").

* - * @param host The SOCKS5 proxy host to set. + *

Setting a host is what {@linkplain #isProxyEnabled() enables} the proxy.

+ * + * @param host The proxy host to set. * @return This ProxySettings.Builder instance, configured with the specified proxy host. * @throws IllegalArgumentException If the provided host is null or empty after trimming. * @see ProxySettings.Builder#port(int) @@ -131,14 +162,15 @@ public ProxySettings.Builder host(final String host) { } /** - * Sets the port number for the SOCKS5 proxy server. The port should be a non-negative integer - * representing the port through which the SOCKS5 proxy connection will be established. + * Sets the port number for the proxy server. The port should be a non-negative integer + * representing the port through which the proxy connection will be established. *

* If a port is specified via this method, a corresponding host must be provided using the {@link #host(String)} method. *

- * If no port is provided, the default port 1080 will be used. + * If no port is provided, the default SOCKS5 port {@value #DEFAULT_PORT} is used. A port must be specified + * explicitly for {@link ProxyProtocol#HTTP} and {@link ProxyProtocol#HTTPS}, as neither has a standard port. * - * @param port The port number to set for the SOCKS5 proxy server. + * @param port The port number to set for the proxy server. * @return This ProxySettings.Builder instance, configured with the specified proxy port. * @throws IllegalArgumentException If the provided port is negative. * @see ProxySettings.Builder#host(String) @@ -151,11 +183,16 @@ public ProxySettings.Builder port(final int port) { } /** - * Sets the username for authenticating with the SOCKS5 proxy server. + * Sets the username for authenticating with the proxy server. * The provided username should not be empty or null. *

* If a username is specified, the corresponding password and proxy host must also be specified using the * {@link #password(String)} and {@link #host(String)} methods, respectively. + *

+ * The credentials are used for username/password authentication when the + * {@linkplain #protocol(ProxyProtocol) protocol} is {@link ProxyProtocol#SOCKS5}, and for {@code Basic} + * authentication via the {@code Proxy-Authorization} header for the HTTP protocols. Other proxy + * authentication schemes, such as {@code Digest}, {@code NTLM} and {@code Negotiate}, are not supported. * * @param username The username to set for proxy authentication. * @return This ProxySettings.Builder instance, configured with the specified username. @@ -174,7 +211,7 @@ public ProxySettings.Builder username(final String username) { } /** - * Sets the password for authenticating with the SOCKS5 proxy server. + * Sets the password for authenticating with the proxy server. * The provided password should not be empty or null. *

* If a password is specified, the corresponding username and proxy host must also be specified using the @@ -197,6 +234,40 @@ public ProxySettings.Builder password(final String password) { } + /** + * Sets the protocol spoken to the proxy server. + * + *

Defaults to {@link ProxyProtocol#SOCKS5}.

+ * + * @param protocol the proxy protocol, which may not be null. + * @return this {@link Builder} instance, configured with the specified proxy protocol. + * @see ProxySettings#getProtocol() + * @since 5.11 + */ + public ProxySettings.Builder protocol(final ProxyProtocol protocol) { + this.protocol = notNull("protocol", protocol); + return this; + } + + /** + * Sets the {@link SSLContext} used for the TLS connection to the proxy server. + * + *

This is used only when the {@linkplain #protocol(ProxyProtocol) protocol} is + * {@link ProxyProtocol#HTTPS}. It configures TLS between the client and the proxy; it does not affect the + * separate TLS session negotiated end-to-end with the target host through the tunnel.

+ * + *

Defaults to {@code null}, in which case the default {@link SSLContext} is used.

+ * + * @param sslContext the SSL context for the connection to the proxy, or null to use the default. + * @return this {@link Builder} instance, configured with the specified SSL context. + * @see ProxySettings#getSslContext() + * @since 5.11 + */ + public ProxySettings.Builder sslContext(@Nullable final SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + /** * Takes the proxy settings from the given {@code ConnectionString} and applies them to the {@link Builder}. * @@ -242,7 +313,7 @@ public ProxySettings build() { } /** - * Gets the SOCKS5 proxy host. + * Gets the proxy host. * * @return the proxy host value. {@code null} if and only if the {@linkplain #isProxyEnabled() proxy functionality is not enabled}. * @see Builder#host(String) @@ -253,9 +324,9 @@ public String getHost() { } /** - * Gets the SOCKS5 proxy port. + * Gets the proxy port. * - * @return The port number of the SOCKS5 proxy. If a custom port has been set using {@link Builder#port(int)}, + * @return The port number of the proxy. If a custom port has been set using {@link Builder#port(int)}, * that custom port value is returned. Otherwise, the default SOCKS5 port {@value #DEFAULT_PORT} is returned. * @see Builder#port(int) */ @@ -267,7 +338,7 @@ public int getPort() { } /** - * Gets the SOCKS5 proxy username. + * Gets the proxy username. * * @return the proxy username value. * @see Builder#username(String) @@ -278,7 +349,7 @@ public String getUsername() { } /** - * Gets the SOCKS5 proxy password. + * Gets the proxy password. * * @return the proxy password value. * @see Builder#password(String) @@ -289,7 +360,30 @@ public String getPassword() { } /** - * Checks if the SOCKS5 proxy is enabled. + * Gets the protocol spoken to the proxy server. + * + * @return the proxy protocol. Defaults to {@link ProxyProtocol#SOCKS5}. + * @see Builder#protocol(ProxyProtocol) + * @since 5.11 + */ + public ProxyProtocol getProtocol() { + return protocol; + } + + /** + * Gets the {@link SSLContext} used for the TLS connection to the proxy server. + * + * @return the SSL context for the connection to the proxy, or null to use the default. + * @see Builder#sslContext(SSLContext) + * @since 5.11 + */ + @Nullable + public SSLContext getSslContext() { + return sslContext; + } + + /** + * Checks if the proxy is enabled. * * @return {@code true} if the proxy is enabled, {@code false} otherwise. * @see Builder#host(String) @@ -310,12 +404,14 @@ public boolean equals(final Object o) { return Objects.equals(host, that.host) && Objects.equals(port, that.port) && Objects.equals(username, that.username) - && Objects.equals(password, that.password); + && Objects.equals(password, that.password) + && protocol == that.protocol + && Objects.equals(sslContext, that.sslContext); } @Override public int hashCode() { - return Objects.hash(host, port, username, password); + return Objects.hash(host, port, username, password, protocol, sslContext); } @Override @@ -323,6 +419,7 @@ public String toString() { return "ProxySettings{" + "host=" + host + ", port=" + port + + ", protocol=" + protocol + ", username=" + ", password=" + '}'; @@ -340,10 +437,17 @@ private ProxySettings(final ProxySettings.Builder builder) { isTrue("Both proxyUsername and proxyPassword must be set together. They cannot be set individually", (builder.username == null) == (builder.password == null)); + if (builder.protocol != ProxyProtocol.SOCKS5) { + isTrue("proxyPort must be specified explicitly when the proxy protocol is " + builder.protocol, + builder.port != null); + } + this.host = builder.host; this.port = builder.port; this.username = builder.username; this.password = builder.password; + this.protocol = builder.protocol; + this.sslContext = builder.sslContext; } } diff --git a/driver-core/src/main/com/mongodb/connection/SocketSettings.java b/driver-core/src/main/com/mongodb/connection/SocketSettings.java index 4e6890e785c..5155bc178c7 100644 --- a/driver-core/src/main/com/mongodb/connection/SocketSettings.java +++ b/driver-core/src/main/com/mongodb/connection/SocketSettings.java @@ -210,7 +210,8 @@ public int getReadTimeout(final TimeUnit timeUnit) { /** * Gets the proxy settings used for connecting to MongoDB via a SOCKS5 proxy server. * - * @return The {@link ProxySettings} instance containing the SOCKS5 proxy configuration. + * @return The {@link ProxySettings} instance containing the proxy configuration used for connections to a MongoDB + * server. Only {@link ProxyProtocol#SOCKS5} is supported for such connections. * @see Builder#applyToProxySettings(Block) * @since 4.11 */ diff --git a/driver-core/src/main/com/mongodb/internal/capi/KmsSocketConnector.java b/driver-core/src/main/com/mongodb/internal/capi/KmsSocketConnector.java new file mode 100644 index 00000000000..3b93605a896 --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/capi/KmsSocketConnector.java @@ -0,0 +1,201 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal.capi; + +import com.mongodb.MongoSocketException; +import com.mongodb.ServerAddress; +import com.mongodb.connection.ProxyProtocol; +import com.mongodb.connection.ProxySettings; +import com.mongodb.internal.connection.HttpProxyTunnel; +import com.mongodb.internal.connection.SocksSocket; +import com.mongodb.internal.connection.SslHelper; +import com.mongodb.lang.Nullable; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; + +import static com.mongodb.assertions.Assertions.assertNotNull; + +/** + * Establishes the TLS connection used for a Key Management Service (KMS) request, optionally through a proxy. + * + *

When a proxy is configured, the connection to the KMS host is established through it first, and TLS is then + * layered on top of the resulting socket. TLS is always negotiated end-to-end with the KMS host: Server Name Indication + * and certificate hostname verification are configured from the KMS address rather than from the proxy, so a proxy + * relays the session without being able to read it.

+ * + *

This class is not part of the public API and may be removed or changed at any time

+ */ +public final class KmsSocketConnector { + + /** + * Connects to the KMS host and returns a socket with an established TLS session. + * + * @param sslContext the SSL context configured for the KMS provider, or null to use the default + * @param proxySettings the proxy to reach the KMS host through, or null to connect directly + * @param kmsAddress the address of the KMS host + * @param soTimeoutMillis the socket read timeout to apply + * @param connectTimeoutMillis the time available to establish the connection, or 0 if no limit applies + * @return a connected socket with an established TLS session with the KMS host + * @throws IOException if the connection, the proxy handshake, or the TLS handshake fails + */ + public static SSLSocket connect(@Nullable final SSLContext sslContext, + @Nullable final ProxySettings proxySettings, final ServerAddress kmsAddress, + final int soTimeoutMillis, final long connectTimeoutMillis) throws IOException { + SSLSocketFactory sslSocketFactory = sslContext == null + ? (SSLSocketFactory) SSLSocketFactory.getDefault() : sslContext.getSocketFactory(); + + if (proxySettings == null || !proxySettings.isProxyEnabled()) { + return connectDirectly(sslSocketFactory, kmsAddress, soTimeoutMillis, connectTimeoutMillis); + } + return connectThroughProxy(sslSocketFactory, proxySettings, kmsAddress, soTimeoutMillis, connectTimeoutMillis); + } + + private static SSLSocket connectDirectly(final SSLSocketFactory sslSocketFactory, final ServerAddress kmsAddress, + final int soTimeoutMillis, final long connectTimeoutMillis) throws IOException { + SSLSocket socket = (SSLSocket) sslSocketFactory.createSocket(); + SSLParameters sslParameters = socket.getSSLParameters(); + SslHelper.enableHostNameVerification(sslParameters); + socket.setSSLParameters(sslParameters); + try { + socket.setSoTimeout(soTimeoutMillis); + socket.connect(new InetSocketAddress(InetAddress.getByName(kmsAddress.getHost()), kmsAddress.getPort()), + Math.toIntExact(connectTimeoutMillis)); + } catch (IOException | RuntimeException e) { + closeSocket(socket); + throw e; + } + return socket; + } + + /** + * Reaches the KMS host through the configured proxy, then layers TLS for the KMS host on the resulting socket. + */ + private static SSLSocket connectThroughProxy(final SSLSocketFactory sslSocketFactory, + final ProxySettings proxySettings, final ServerAddress kmsAddress, final int soTimeoutMillis, + final long connectTimeoutMillis) throws IOException { + int connectTimeout = Math.toIntExact(connectTimeoutMillis); + Socket proxySocket = proxySettings.getProtocol() == ProxyProtocol.SOCKS5 + ? connectSocksProxy(proxySettings, kmsAddress, soTimeoutMillis, connectTimeout) + : connectHttpProxy(proxySettings, kmsAddress, soTimeoutMillis, connectTimeout); + + SSLSocket socket; + try { + // Layers TLS over the already-established tunnel. autoClose ensures that closing the returned socket also + // closes the underlying proxy socket. + socket = (SSLSocket) sslSocketFactory.createSocket(proxySocket, kmsAddress.getHost(), kmsAddress.getPort(), true); + } catch (IOException | RuntimeException e) { + closeSocket(proxySocket); + throw e; + } + + try { + // Even though the proxy connection is already established, the TLS handshake has not been performed yet, + // so SSL parameters can still be set. They target the KMS host, not the proxy. + SSLParameters sslParameters = socket.getSSLParameters(); + SslHelper.enableSni(kmsAddress.getHost(), sslParameters); + SslHelper.enableHostNameVerification(sslParameters); + socket.setSSLParameters(sslParameters); + socket.setSoTimeout(soTimeoutMillis); + // Handshake explicitly so that a TLS failure is reported here rather than on the first write. + socket.startHandshake(); + } catch (IOException | RuntimeException e) { + closeSocket(socket); + throw e; + } + return socket; + } + + /** + * Opens a connection to an HTTP proxy and asks it to tunnel to the KMS host. When the protocol is + * {@link ProxyProtocol#HTTPS} the connection to the proxy is itself protected with TLS, verified against the + * proxy's own identity; the tunnel then carries a second, independent TLS session with the KMS host. + */ + private static Socket connectHttpProxy(final ProxySettings proxySettings, final ServerAddress kmsAddress, + final int soTimeoutMillis, final int connectTimeoutMillis) throws IOException { + boolean useTls = proxySettings.getProtocol() == ProxyProtocol.HTTPS; + String proxyHost = assertNotNull(proxySettings.getHost()); + int proxyPort = proxySettings.getPort(); + + Socket proxySocket = useTls ? proxySslSocketFactory(proxySettings).createSocket() : new Socket(); + try { + proxySocket.setSoTimeout(soTimeoutMillis); + proxySocket.connect(new InetSocketAddress(proxyHost, proxyPort), connectTimeoutMillis); + if (useTls) { + SSLSocket proxyTlsSocket = (SSLSocket) proxySocket; + SSLParameters sslParameters = proxyTlsSocket.getSSLParameters(); + SslHelper.enableSni(proxyHost, sslParameters); + SslHelper.enableHostNameVerification(sslParameters); + proxyTlsSocket.setSSLParameters(sslParameters); + try { + proxyTlsSocket.startHandshake(); + } catch (SSLException e) { + // A plaintext proxy answering a TLS handshake produces an obscure JSSE error, so name the likely + // cause rather than letting it surface unexplained. + throw new MongoSocketException("TLS handshake with the " + ProxyProtocol.HTTPS + " proxy at " + + proxyHost + ":" + proxyPort + " failed. If the proxy does not use TLS, configure" + + " ProxySettings with " + ProxyProtocol.HTTP + " instead. Cause: " + e.getMessage(), + kmsAddress, e); + } + } + HttpProxyTunnel.establishTunnel(proxySocket, kmsAddress, proxySettings); + } catch (IOException | RuntimeException e) { + closeSocket(proxySocket); + throw e; + } + return proxySocket; + } + + private static SSLSocketFactory proxySslSocketFactory(final ProxySettings proxySettings) { + SSLContext proxySslContext = proxySettings.getSslContext(); + return proxySslContext == null + ? (SSLSocketFactory) SSLSocketFactory.getDefault() : proxySslContext.getSocketFactory(); + } + + private static Socket connectSocksProxy(final ProxySettings proxySettings, final ServerAddress kmsAddress, + final int soTimeoutMillis, final int connectTimeoutMillis) throws IOException { + SocksSocket proxySocket = new SocksSocket(proxySettings); + try { + proxySocket.setSoTimeout(soTimeoutMillis); + // Unresolved, so that the proxy resolves the KMS host rather than the client. + proxySocket.connect(InetSocketAddress.createUnresolved(kmsAddress.getHost(), kmsAddress.getPort()), + connectTimeoutMillis); + } catch (IOException | RuntimeException e) { + closeSocket(proxySocket); + throw e; + } + return proxySocket; + } + + private static void closeSocket(final Socket socket) { + try { + socket.close(); + } catch (IOException | RuntimeException e) { + // ignore + } + } + + private KmsSocketConnector() { + } +} diff --git a/driver-core/src/main/com/mongodb/internal/connection/HttpProxyTunnel.java b/driver-core/src/main/com/mongodb/internal/connection/HttpProxyTunnel.java new file mode 100644 index 00000000000..4eba558a73e --- /dev/null +++ b/driver-core/src/main/com/mongodb/internal/connection/HttpProxyTunnel.java @@ -0,0 +1,147 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal.connection; + +import com.mongodb.MongoSocketException; +import com.mongodb.ServerAddress; +import com.mongodb.connection.ProxySettings; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static com.mongodb.assertions.Assertions.assertNotNull; + +/** + * Establishes a tunnel to a target host through an HTTP proxy using the {@code CONNECT} method, as described by + * RFC 9110. + * + *

Once the tunnel is established the proxy relays bytes without interpreting them, so TLS can be negotiated + * end-to-end with the target host over the same socket.

+ * + *

This class is not part of the public API and may be removed or changed at any time

+ */ +public final class HttpProxyTunnel { + + /** + * Bounds the response so that a proxy which never sends the end of the header block cannot cause an unbounded read. + */ + private static final int MAX_RESPONSE_BYTES = 8192; + + private static final String CRLF = "\r\n"; + + /** + * Sends a {@code CONNECT} request for {@code target} over {@code socket} and consumes the response. + * + *

The target is sent as a host name, so that the proxy resolves it. This matters when the client cannot resolve + * the target itself, which is common in the networks where a proxy is mandatory.

+ * + *

On return, the socket carries a transparent tunnel to {@code target} and TLS may be negotiated over it.

+ * + * @param socket a connected socket to the proxy + * @param target the host the tunnel should reach + * @param proxySettings the proxy settings, used for optional {@code Basic} authentication + * @throws IOException if the request cannot be written, the response cannot be read, or the proxy declines + */ + public static void establishTunnel(final Socket socket, final ServerAddress target, + final ProxySettings proxySettings) throws IOException { + writeConnectRequest(socket.getOutputStream(), target, proxySettings); + String statusLine = readResponse(socket.getInputStream(), target); + int statusCode = parseStatusCode(statusLine, target); + if (statusCode / 100 != 2) { + throw new MongoSocketException("HTTP proxy " + proxySettings.getHost() + ":" + proxySettings.getPort() + + " refused to establish a tunnel to " + target + ": " + statusLine, target); + } + } + + private static void writeConnectRequest(final OutputStream outputStream, final ServerAddress target, + final ProxySettings proxySettings) throws IOException { + String hostPort = target.getHost() + ":" + target.getPort(); + StringBuilder request = new StringBuilder() + .append("CONNECT ").append(hostPort).append(" HTTP/1.1").append(CRLF) + .append("Host: ").append(hostPort).append(CRLF); + String username = proxySettings.getUsername(); + if (username != null) { + String credentials = username + ":" + assertNotNull(proxySettings.getPassword()); + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + request.append("Proxy-Authorization: Basic ").append(encoded).append(CRLF); + } + request.append(CRLF); + outputStream.write(request.toString().getBytes(StandardCharsets.US_ASCII)); + outputStream.flush(); + } + + /** + * Reads the response one byte at a time, stopping at the end of the header block. Buffering would risk consuming + * bytes of the TLS handshake that is subsequently performed over this same socket. + * + * @return the status line + */ + private static String readResponse(final InputStream inputStream, final ServerAddress target) throws IOException { + StringBuilder response = new StringBuilder(); + while (!endsWithBlankLine(response)) { + int b = inputStream.read(); + if (b == -1) { + throw new MongoSocketException( + "HTTP proxy closed the connection before completing the tunnel to " + target, target); + } + response.append((char) b); + if (response.length() > MAX_RESPONSE_BYTES) { + throw new MongoSocketException("HTTP proxy response exceeded " + MAX_RESPONSE_BYTES + + " bytes while establishing a tunnel to " + target, target); + } + } + int endOfStatusLine = response.indexOf(CRLF); + return response.substring(0, endOfStatusLine); + } + + /** + * Extracts the status code, tolerating any HTTP version, as proxies differ in the version they reply with. + */ + private static int parseStatusCode(final String statusLine, final ServerAddress target) throws IOException { + int firstSpace = statusLine.indexOf(' '); + if (firstSpace < 0 || !statusLine.startsWith("HTTP/")) { + throw new MongoSocketException( + "HTTP proxy returned a malformed response while establishing a tunnel to " + target + + ": " + statusLine, target); + } + int secondSpace = statusLine.indexOf(' ', firstSpace + 1); + String code = secondSpace < 0 + ? statusLine.substring(firstSpace + 1) + : statusLine.substring(firstSpace + 1, secondSpace); + try { + return Integer.parseInt(code.trim()); + } catch (NumberFormatException e) { + throw new MongoSocketException( + "HTTP proxy returned an unparseable status while establishing a tunnel to " + target + + ": " + statusLine, target); + } + } + + private static boolean endsWithBlankLine(final CharSequence response) { + int length = response.length(); + return length >= 4 + && response.charAt(length - 4) == '\r' && response.charAt(length - 3) == '\n' + && response.charAt(length - 2) == '\r' && response.charAt(length - 1) == '\n'; + } + + private HttpProxyTunnel() { + } +} diff --git a/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java b/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java index a1c3ed0d914..62efcd261ec 100644 --- a/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java +++ b/driver-core/src/main/com/mongodb/internal/connection/SocketStream.java @@ -16,11 +16,13 @@ package com.mongodb.internal.connection; +import com.mongodb.MongoClientException; import com.mongodb.MongoSocketException; import com.mongodb.MongoSocketOpenException; import com.mongodb.MongoSocketReadException; import com.mongodb.ServerAddress; import com.mongodb.connection.AsyncCompletionHandler; +import com.mongodb.connection.ProxyProtocol; import com.mongodb.connection.ProxySettings; import com.mongodb.connection.SocketSettings; import com.mongodb.connection.SslSettings; @@ -89,6 +91,13 @@ public void open(final OperationContext operationContext) { protected Socket initializeSocket(final OperationContext operationContext) throws IOException { ProxySettings proxySettings = settings.getProxySettings(); if (proxySettings.isProxyEnabled()) { + // An HTTP proxy is currently supported only for KMS requests made by in-use encryption, so reject it here + // rather than silently speaking SOCKS5 to a proxy that is not expecting it. + if (proxySettings.getProtocol() != ProxyProtocol.SOCKS5) { + throw new MongoClientException("The " + proxySettings.getProtocol() + " proxy protocol is not supported" + + " for connections to a MongoDB server. It may be used for KMS requests via" + + " ClientEncryptionSettings or AutoEncryptionSettings."); + } if (sslSettings.isEnabled()) { assertTrue(socketFactory instanceof SSLSocketFactory); SSLSocketFactory sslSocketFactory = (SSLSocketFactory) socketFactory; diff --git a/driver-core/src/test/unit/com/mongodb/KmsProxySettingsTest.java b/driver-core/src/test/unit/com/mongodb/KmsProxySettingsTest.java new file mode 100644 index 00000000000..e257552941f --- /dev/null +++ b/driver-core/src/test/unit/com/mongodb/KmsProxySettingsTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb; + +import com.mongodb.connection.ProxyProtocol; +import com.mongodb.connection.ProxySettings; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class KmsProxySettingsTest { + + private static final ProxySettings HTTP_PROXY = ProxySettings.builder() + .protocol(ProxyProtocol.HTTP) + .host("proxy.example.com") + .port(8080) + .build(); + + @Test + void shouldDefaultToSocks5() { + assertEquals(ProxyProtocol.SOCKS5, ProxySettings.builder().build().getProtocol()); + } + + @Test + void shouldRequireAnExplicitPortForHttpProtocols() { + assertThrows(IllegalStateException.class, () -> ProxySettings.builder() + .protocol(ProxyProtocol.HTTP) + .host("proxy.example.com") + .build()); + assertThrows(IllegalStateException.class, () -> ProxySettings.builder() + .protocol(ProxyProtocol.HTTPS) + .host("proxy.example.com") + .build()); + } + + @Test + void shouldRoundTripThroughApplySettings() { + ProxySettings copy = ProxySettings.builder(HTTP_PROXY).build(); + + assertEquals(HTTP_PROXY, copy); + assertEquals(ProxyProtocol.HTTP, copy.getProtocol()); + assertEquals(8080, copy.getPort()); + assertTrue(copy.isProxyEnabled()); + } + + @Test + void shouldNotRenderCredentialsInToString() { + String rendered = ProxySettings.builder(HTTP_PROXY) + .username("u53rn4m3") + .password("p4ssw0rd") + .build() + .toString(); + + assertFalse(rendered.contains("p4ssw0rd"), () -> "password leaked: " + rendered); + assertFalse(rendered.contains("u53rn4m3"), () -> "username leaked: " + rendered); + assertTrue(rendered.contains("protocol=HTTP")); + } + + @Test + void autoEncryptionSettingsShouldDefaultToNoProxy() { + AutoEncryptionSettings settings = AutoEncryptionSettings.builder() + .keyVaultNamespace("keyvault.datakeys") + .kmsProviders(new HashMap<>()) + .build(); + + assertFalse(settings.getProxySettings().isProxyEnabled()); + } + + @Test + void autoEncryptionSettingsShouldRoundTripProxySettings() { + AutoEncryptionSettings settings = AutoEncryptionSettings.builder() + .keyVaultNamespace("keyvault.datakeys") + .kmsProviders(new HashMap<>()) + .proxySettings(HTTP_PROXY) + .build(); + + assertSame(HTTP_PROXY, settings.getProxySettings()); + } + + @Test + void clientEncryptionSettingsShouldDefaultToNoProxy() { + assertFalse(clientEncryptionSettingsBuilder().build().getProxySettings().isProxyEnabled()); + } + + @Test + void clientEncryptionSettingsShouldRoundTripProxySettings() { + ClientEncryptionSettings settings = clientEncryptionSettingsBuilder() + .proxySettings(HTTP_PROXY) + .build(); + + assertSame(HTTP_PROXY, settings.getProxySettings()); + } + + private static ClientEncryptionSettings.Builder clientEncryptionSettingsBuilder() { + return ClientEncryptionSettings.builder() + .keyVaultMongoClientSettings(MongoClientSettings.builder().build()) + .keyVaultNamespace("keyvault.datakeys") + .kmsProviders(new HashMap<>()); + } +} diff --git a/driver-core/src/test/unit/com/mongodb/ProxySettingsTest.java b/driver-core/src/test/unit/com/mongodb/ProxySettingsTest.java index 3c719acf55a..754c4427011 100644 --- a/driver-core/src/test/unit/com/mongodb/ProxySettingsTest.java +++ b/driver-core/src/test/unit/com/mongodb/ProxySettingsTest.java @@ -151,6 +151,6 @@ void shouldNotExposeCredentialsInToString() { String stringValue = proxySettings.toString(); Assertions.assertEquals("ProxySettings{host=" + HOST + ", port=" + VALID_PORT - + ", username=, password=}", stringValue); + + ", protocol=SOCKS5, username=, password=}", stringValue); } } diff --git a/driver-core/src/test/unit/com/mongodb/internal/capi/KmsSocketConnectorTunnelTest.java b/driver-core/src/test/unit/com/mongodb/internal/capi/KmsSocketConnectorTunnelTest.java new file mode 100644 index 00000000000..dd761420569 --- /dev/null +++ b/driver-core/src/test/unit/com/mongodb/internal/capi/KmsSocketConnectorTunnelTest.java @@ -0,0 +1,380 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal.capi; + +import com.mongodb.MongoSocketException; +import com.mongodb.ServerAddress; +import com.mongodb.connection.ProxyProtocol; +import com.mongodb.connection.ProxySettings; +import com.mongodb.lang.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManagerFactory; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the driver reaches a KMS host through a proxy and then negotiates TLS end-to-end with the KMS host over + * the resulting tunnel. + * + *

Everything this test needs runs in-process: a minimal {@code HTTP CONNECT} proxy, a TLS server standing in for the + * KMS host, and a keystore loaded from test resources. There is no dependency on a network, on credentials, or on any + * external process, so it covers the tunneling behaviour that + * {@code AbstractClientSideEncryptionKmsProxyProseTest} can only cover where real AWS credentials are available.

+ */ +final class KmsSocketConnectorTunnelTest { + + private static final String KEYSTORE_RESOURCE = "/kms-tunnel-test.p12"; + private static final char[] KEYSTORE_PASSWORD = "changeit".toCharArray(); + + /** The certificate in the keystore is issued for the IP address 127.0.0.1 and for no other name. */ + private static final String CERTIFIED_HOST = "127.0.0.1"; + + private static final int TIMEOUT_MILLIS = 10_000; + + private final List toClose = new ArrayList<>(); + private final List threads = new ArrayList<>(); + + private FakeKmsServer kmsServer; + + @BeforeEach + void setUp() throws Exception { + kmsServer = new FakeKmsServer(); + } + + @AfterEach + void tearDown() throws Exception { + for (Closeable closeable : toClose) { + try { + closeable.close(); + } catch (IOException e) { + // ignore + } + } + for (Thread thread : threads) { + thread.join(5_000); + } + } + + @Test + void shouldConnectDirectlyWhenNoProxyIsConfigured() throws Exception { + SSLSocket socket = connect(ProxySettings.builder().build()); + + assertEquals("PONG", exchange(socket, "PING")); + assertEquals("PING", kmsServer.received()); + } + + @Test + void shouldTunnelThroughHttpProxy() throws Exception { + ConnectProxy proxy = new ConnectProxy(false); + + SSLSocket socket = connect(proxy.settings(ProxyProtocol.HTTP).build()); + + assertEquals("PONG", exchange(socket, "PING")); + assertEquals("PING", kmsServer.received(), "the KMS host must receive what was written to the tunneled socket"); + assertEquals(1, proxy.connectCount(), "the connection must have been made through the proxy"); + assertNull(proxy.proxyAuthorization(), "no Proxy-Authorization header is expected without credentials"); + } + + @Test + void shouldTunnelThroughHttpsProxy() throws Exception { + // Two nested TLS sessions: the driver's session with the proxy, and its session with the KMS host carried + // inside the tunnel. + ConnectProxy proxy = new ConnectProxy(true); + + SSLSocket socket = connect(proxy.settings(ProxyProtocol.HTTPS) + .sslContext(clientSslContext()) + .build()); + + assertEquals("PONG", exchange(socket, "PING")); + assertEquals("PING", kmsServer.received()); + assertEquals(1, proxy.connectCount()); + } + + @Test + void shouldSendBasicProxyAuthorizationWhenCredentialsAreConfigured() throws Exception { + ConnectProxy proxy = new ConnectProxy(false); + + SSLSocket socket = connect(proxy.settings(ProxyProtocol.HTTP) + .username("user") + .password("pass") + .build()); + + assertEquals("PONG", exchange(socket, "PING")); + // "user:pass" base64-encoded + assertEquals("Basic dXNlcjpwYXNz", proxy.proxyAuthorization()); + } + + @Test + void shouldAcceptAStatusLineWithAnyHttpVersion() throws Exception { + // Proxies differ in the HTTP version they reply with, so the status code alone must decide. + ConnectProxy proxy = new ConnectProxy(false); + proxy.respondWith("HTTP/1.0 200 Connection established"); + + SSLSocket socket = connect(proxy.settings(ProxyProtocol.HTTP).build()); + + assertEquals("PONG", exchange(socket, "PING")); + } + + @Test + void shouldVerifyCertificateAgainstKmsHostRatherThanProxy() throws Exception { + ConnectProxy proxy = new ConnectProxy(false); + + // "localhost" resolves to the same server, but the KMS host's certificate is issued for the IP address only. + // The handshake must therefore fail, proving that hostname verification is performed against the KMS address + // rather than against the proxy the socket is actually connected to. + ServerAddress unverifiableAddress = new ServerAddress("localhost", kmsServer.address().getPort()); + + assertThrows(SSLHandshakeException.class, () -> KmsSocketConnector.connect(clientSslContext(), + proxy.settings(ProxyProtocol.HTTP).build(), unverifiableAddress, TIMEOUT_MILLIS, TIMEOUT_MILLIS)); + } + + @Test + void shouldFailWhenProxyRefusesToTunnel() throws Exception { + ConnectProxy proxy = new ConnectProxy(false); + proxy.respondWith("HTTP/1.1 403 Forbidden"); + + MongoSocketException e = assertThrows(MongoSocketException.class, + () -> connect(proxy.settings(ProxyProtocol.HTTP).build())); + assertTrue(e.getMessage().contains("403"), () -> "unexpected failure: " + e.getMessage()); + } + + private SSLSocket connect(final ProxySettings proxySettings) throws Exception { + return KmsSocketConnector.connect(clientSslContext(), proxySettings, kmsServer.address(), + TIMEOUT_MILLIS, TIMEOUT_MILLIS); + } + + private String exchange(final SSLSocket socket, final String request) throws IOException { + try (SSLSocket tls = socket) { + tls.getOutputStream().write(request.getBytes(StandardCharsets.UTF_8)); + tls.getOutputStream().flush(); + byte[] response = new byte[16]; + int read = tls.getInputStream().read(response); + return new String(response, 0, read, StandardCharsets.UTF_8); + } + } + + // --- in-process HTTP CONNECT proxy ------------------------------------------------------------------------- + + private final class ConnectProxy { + private final ServerSocket serverSocket; + private volatile String responseStatusLine = "HTTP/1.1 200 Connection Established"; + private volatile int connectCount; + private final AtomicReference proxyAuthorization = new AtomicReference<>(); + + ConnectProxy(final boolean useTls) throws Exception { + this.serverSocket = useTls + ? serverSslContext().getServerSocketFactory().createServerSocket(0, 1, loopback()) + : new ServerSocket(0, 1, loopback()); + toClose.add(serverSocket); + start(this::acceptLoop, "kms-connect-proxy"); + } + + ProxySettings.Builder settings(final ProxyProtocol protocol) { + return ProxySettings.builder() + .protocol(protocol) + .host(CERTIFIED_HOST) + .port(serverSocket.getLocalPort()); + } + + void respondWith(final String statusLine) { + this.responseStatusLine = statusLine; + } + + int connectCount() { + return connectCount; + } + + @Nullable + String proxyAuthorization() { + return proxyAuthorization.get(); + } + + private void acceptLoop() { + while (!serverSocket.isClosed()) { + try { + Socket client = serverSocket.accept(); + toClose.add(client); + handle(client); + } catch (IOException e) { + return; + } + } + } + + private void handle(final Socket client) throws IOException { + List headers = readHeaders(client.getInputStream()); + String requestLine = headers.get(0); + for (String header : headers) { + if (header.regionMatches(true, 0, "Proxy-Authorization:", 0, "Proxy-Authorization:".length())) { + proxyAuthorization.set(header.substring("Proxy-Authorization:".length()).trim()); + } + } + if (!requestLine.startsWith("CONNECT ") || !responseStatusLine.contains(" 2")) { + client.getOutputStream().write((responseStatusLine + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + client.getOutputStream().flush(); + return; + } + String[] hostAndPort = requestLine.split(" ")[1].split(":"); + Socket upstream = new Socket(); + upstream.connect(new InetSocketAddress(hostAndPort[0], Integer.parseInt(hostAndPort[1])), + TIMEOUT_MILLIS); + toClose.add(upstream); + connectCount++; + client.getOutputStream().write((responseStatusLine + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + client.getOutputStream().flush(); + // From here the proxy is a blind pipe, which is what makes end-to-end TLS with the KMS host possible. + start(() -> pipe(client, upstream), "kms-proxy-pipe-out"); + start(() -> pipe(upstream, client), "kms-proxy-pipe-in"); + } + + private List readHeaders(final InputStream inputStream) throws IOException { + StringBuilder raw = new StringBuilder(); + while (!raw.toString().endsWith("\r\n\r\n")) { + int b = inputStream.read(); + if (b == -1) { + throw new IOException("client closed before completing the request: " + raw); + } + raw.append((char) b); + } + List headers = new ArrayList<>(); + for (String line : raw.toString().split("\r\n")) { + if (!line.isEmpty()) { + headers.add(line); + } + } + return headers; + } + + private void pipe(final Socket from, final Socket to) { + byte[] buffer = new byte[4096]; + try { + InputStream in = from.getInputStream(); + OutputStream out = to.getOutputStream(); + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + out.flush(); + } + } catch (IOException e) { + // the test is finished with this connection + } + } + } + + // --- in-process stand-in for the KMS host ----------------------------------------------------------------- + + private final class FakeKmsServer { + private final SSLServerSocket serverSocket; + private final AtomicReference received = new AtomicReference<>(); + + FakeKmsServer() throws Exception { + serverSocket = (SSLServerSocket) serverSslContext().getServerSocketFactory() + .createServerSocket(0, 1, loopback()); + toClose.add(serverSocket); + start(this::acceptLoop, "fake-kms-server"); + } + + ServerAddress address() { + return new ServerAddress(CERTIFIED_HOST, serverSocket.getLocalPort()); + } + + String received() { + return received.get(); + } + + private void acceptLoop() { + while (!serverSocket.isClosed()) { + try (SSLSocket accepted = (SSLSocket) serverSocket.accept()) { + byte[] buffer = new byte[16]; + int read = accepted.getInputStream().read(buffer); + if (read > 0) { + received.set(new String(buffer, 0, read, StandardCharsets.UTF_8)); + accepted.getOutputStream().write("PONG".getBytes(StandardCharsets.UTF_8)); + accepted.getOutputStream().flush(); + } + } catch (IOException e) { + return; + } + } + } + } + + // --- shared helpers --------------------------------------------------------------------------------------- + + private void start(final Runnable body, final String name) { + Thread thread = new Thread(body, name); + thread.setDaemon(true); + threads.add(thread); + thread.start(); + } + + private static InetAddress loopback() { + return InetAddress.getLoopbackAddress(); + } + + private static KeyStore keyStore() throws IOException, GeneralSecurityException { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (InputStream in = KmsSocketConnectorTunnelTest.class.getResourceAsStream(KEYSTORE_RESOURCE)) { + assertNotNull(in, KEYSTORE_RESOURCE + " is missing from the test resources"); + keyStore.load(in, KEYSTORE_PASSWORD); + } + return keyStore; + } + + private static SSLContext serverSslContext() throws IOException, GeneralSecurityException { + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore(), KEYSTORE_PASSWORD); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + return sslContext; + } + + private static SSLContext clientSslContext() throws IOException, GeneralSecurityException { + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore()); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + return sslContext; + } +} diff --git a/driver-core/src/test/unit/com/mongodb/internal/connection/HttpProxyNotSupportedForServerConnectionsTest.java b/driver-core/src/test/unit/com/mongodb/internal/connection/HttpProxyNotSupportedForServerConnectionsTest.java new file mode 100644 index 00000000000..9c47b38f919 --- /dev/null +++ b/driver-core/src/test/unit/com/mongodb/internal/connection/HttpProxyNotSupportedForServerConnectionsTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.internal.connection; + +import com.mongodb.MongoClientException; +import com.mongodb.ServerAddress; +import com.mongodb.connection.ProxyProtocol; +import com.mongodb.connection.SocketSettings; +import com.mongodb.connection.SslSettings; +import com.mongodb.internal.TimeoutContext; +import com.mongodb.internal.TimeoutSettings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import javax.net.SocketFactory; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * An HTTP proxy is supported only for KMS requests, so configuring one for connections to a MongoDB server must be + * rejected rather than silently speaking SOCKS5 to a proxy that is not expecting it. + */ +final class HttpProxyNotSupportedForServerConnectionsTest { + + @ParameterizedTest + @EnumSource(value = ProxyProtocol.class, names = {"HTTP", "HTTPS"}) + void shouldRejectHttpProxyForServerConnections(final ProxyProtocol protocol) { + SocketSettings socketSettings = SocketSettings.builder() + .applyToProxySettings(builder -> builder + .protocol(protocol) + .host("proxy.example.com") + .port(8080)) + .build(); + + MongoClientException e = assertThrows(MongoClientException.class, () -> openStream(socketSettings)); + assertTrue(e.getMessage().contains("not supported"), () -> "unexpected message: " + e.getMessage()); + assertTrue(e.getMessage().contains(protocol.toString()), () -> "unexpected message: " + e.getMessage()); + } + + @Test + void shouldNotRejectSocks5ProxyForServerConnections() { + SocketSettings socketSettings = SocketSettings.builder() + .applyToProxySettings(builder -> builder.host("proxy.example.com")) + .build(); + + // SOCKS5 is supported, so this gets as far as attempting to reach the proxy rather than being rejected outright. + assertThrows(Exception.class, () -> openStream(socketSettings), "expected a connection failure, not a rejection"); + } + + private static void openStream(final SocketSettings socketSettings) { + SocketStream stream = new SocketStream(new ServerAddress("cluster.example.com", 27017), + new DefaultInetAddressResolver(), socketSettings, SslSettings.builder().build(), + SocketFactory.getDefault(), PowerOfTwoBufferPool.DEFAULT); + stream.open(OperationContext.simpleOperationContext( + new TimeoutContext(TimeoutSettings.DEFAULT.withConnectTimeoutMS(10)))); + } +} diff --git a/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/crypt/Crypts.java b/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/crypt/Crypts.java index b06af01d476..45422e2b1d1 100644 --- a/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/crypt/Crypts.java +++ b/driver-reactive-streams/src/main/com/mongodb/reactivestreams/client/internal/crypt/Crypts.java @@ -21,6 +21,7 @@ import com.mongodb.MongoClientException; import com.mongodb.MongoClientSettings; import com.mongodb.MongoNamespace; +import com.mongodb.connection.ProxySettings; import com.mongodb.internal.crypt.capi.MongoCrypt; import com.mongodb.internal.crypt.capi.MongoCrypts; import com.mongodb.reactivestreams.client.MongoClient; @@ -41,6 +42,7 @@ private Crypts() { } public static Crypt createCrypt(final MongoClientSettings mongoClientSettings, final AutoEncryptionSettings autoEncryptionSettings) { + assertKmsProxyNotConfigured(autoEncryptionSettings.getProxySettings()); MongoClient sharedInternalClient = null; MongoClientSettings keyVaultMongoClientSettings = autoEncryptionSettings.getKeyVaultMongoClientSettings(); if (keyVaultMongoClientSettings == null || !autoEncryptionSettings.isBypassAutoEncryption()) { @@ -67,6 +69,7 @@ public static Crypt createCrypt(final MongoClientSettings mongoClientSettings, f } public static Crypt create(final MongoClient keyVaultClient, final ClientEncryptionSettings settings) { + assertKmsProxyNotConfigured(settings.getProxySettings()); return new Crypt(MongoCrypts.create(createMongoCryptOptions(settings)), createKeyRetriever(keyVaultClient, settings.getKeyVaultNamespace()), createKeyManagementService(settings.getKmsProviderSslContextMap()), @@ -75,6 +78,17 @@ public static Crypt create(final MongoClient keyVaultClient, final ClientEncrypt ); } + /** + * Routing KMS requests through a proxy is currently implemented only for the synchronous driver, so fail rather + * than silently connecting to KMS hosts directly. This mirrors how a proxy configured for connections to a MongoDB + * server is rejected in {@link MongoClients#create(MongoClientSettings, com.mongodb.MongoDriverInformation)}. + */ + private static void assertKmsProxyNotConfigured(final ProxySettings proxySettings) { + if (proxySettings.isProxyEnabled()) { + throw new MongoClientException("Routing KMS requests through a proxy is not supported for reactive clients"); + } + } + private static KeyRetriever createKeyRetriever(final MongoClient keyVaultClient, final String keyVaultNamespaceString) { return new KeyRetriever(keyVaultClient, new MongoNamespace(keyVaultNamespaceString)); diff --git a/driver-reactive-streams/src/test/unit/com/mongodb/reactivestreams/client/internal/crypt/CryptsProxyNotSupportedTest.java b/driver-reactive-streams/src/test/unit/com/mongodb/reactivestreams/client/internal/crypt/CryptsProxyNotSupportedTest.java new file mode 100644 index 00000000000..05b425ce89f --- /dev/null +++ b/driver-reactive-streams/src/test/unit/com/mongodb/reactivestreams/client/internal/crypt/CryptsProxyNotSupportedTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.reactivestreams.client.internal.crypt; + +import com.mongodb.AutoEncryptionSettings; +import com.mongodb.ClientEncryptionSettings; +import com.mongodb.MongoClientException; +import com.mongodb.MongoClientSettings; +import com.mongodb.connection.ProxyProtocol; +import com.mongodb.connection.ProxySettings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Routing KMS requests through a proxy is implemented only for the synchronous driver, so configuring one for a + * reactive client must fail rather than silently connecting to KMS hosts directly. + */ +final class CryptsProxyNotSupportedTest { + + @ParameterizedTest + @EnumSource(ProxyProtocol.class) + void shouldRejectAKmsProxyOnAutoEncryptionSettings(final ProxyProtocol protocol) { + AutoEncryptionSettings settings = AutoEncryptionSettings.builder() + .keyVaultNamespace("keyvault.datakeys") + .kmsProviders(new HashMap<>()) + .proxySettings(proxySettings(protocol)) + .build(); + + MongoClientException e = assertThrows(MongoClientException.class, + () -> Crypts.createCrypt(MongoClientSettings.builder().build(), settings)); + assertTrue(e.getMessage().contains("not supported for reactive clients"), + () -> "unexpected message: " + e.getMessage()); + } + + @ParameterizedTest + @EnumSource(ProxyProtocol.class) + void shouldRejectAKmsProxyOnClientEncryptionSettings(final ProxyProtocol protocol) { + ClientEncryptionSettings settings = ClientEncryptionSettings.builder() + .keyVaultMongoClientSettings(MongoClientSettings.builder().build()) + .keyVaultNamespace("keyvault.datakeys") + .kmsProviders(new HashMap<>()) + .proxySettings(proxySettings(protocol)) + .build(); + + MongoClientException e = assertThrows(MongoClientException.class, () -> Crypts.create(null, settings)); + assertTrue(e.getMessage().contains("not supported for reactive clients"), + () -> "unexpected message: " + e.getMessage()); + } + + @Test + void shouldNotRejectSettingsWithoutAProxy() { + ClientEncryptionSettings settings = ClientEncryptionSettings.builder() + .keyVaultMongoClientSettings(MongoClientSettings.builder().build()) + .keyVaultNamespace("keyvault.datakeys") + .kmsProviders(new HashMap<>()) + .build(); + + // Without a proxy the guard must not fire; construction then fails for an unrelated reason, which is not a + // MongoClientException about proxy support. + Throwable thrown = assertThrows(Throwable.class, () -> Crypts.create(null, settings)); + assertTrue(thrown.getMessage() == null || !thrown.getMessage().contains("not supported for reactive clients"), + () -> "the proxy guard fired unexpectedly: " + thrown.getMessage()); + } + + private static ProxySettings proxySettings(final ProxyProtocol protocol) { + return ProxySettings.builder() + .protocol(protocol) + .host("proxy.example.com") + .port(8080) + .build(); + } +} diff --git a/driver-sync/src/main/com/mongodb/client/internal/Crypts.java b/driver-sync/src/main/com/mongodb/client/internal/Crypts.java index 30319bbf4f8..71dcc6dcf98 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/Crypts.java +++ b/driver-sync/src/main/com/mongodb/client/internal/Crypts.java @@ -20,6 +20,7 @@ import com.mongodb.ClientEncryptionSettings; import com.mongodb.MongoClientSettings; import com.mongodb.MongoNamespace; +import com.mongodb.connection.ProxySettings; import com.mongodb.client.MongoClient; import com.mongodb.client.MongoClients; import com.mongodb.internal.crypt.capi.MongoCrypt; @@ -51,7 +52,7 @@ public static Crypt createCrypt(final MongoClientSettings mongoClientSettings, f return new Crypt( mongoCrypt, createKeyRetriever(keyVaultClient, settings.getKeyVaultNamespace()), - createKeyManagementService(settings.getKmsProviderSslContextMap()), + createKeyManagementService(settings.getKmsProviderSslContextMap(), settings.getProxySettings()), settings.getKmsProviders(), settings.getKmsProviderPropertySuppliers(), settings.isBypassAutoEncryption(), @@ -63,7 +64,7 @@ public static Crypt createCrypt(final MongoClientSettings mongoClientSettings, f static Crypt create(final MongoClient keyVaultClient, final ClientEncryptionSettings settings) { return new Crypt(MongoCrypts.create(createMongoCryptOptions(settings)), createKeyRetriever(keyVaultClient, settings.getKeyVaultNamespace()), - createKeyManagementService(settings.getKmsProviderSslContextMap()), + createKeyManagementService(settings.getKmsProviderSslContextMap(), settings.getProxySettings()), settings.getKmsProviders(), settings.getKmsProviderPropertySuppliers() ); @@ -73,8 +74,9 @@ private static KeyRetriever createKeyRetriever(final MongoClient keyVaultClient, return new KeyRetriever(keyVaultClient, new MongoNamespace(keyVaultNamespaceString)); } - private static KeyManagementService createKeyManagementService(final Map kmsProviderSslContextMap) { - return new KeyManagementService(kmsProviderSslContextMap, 10000); + private static KeyManagementService createKeyManagementService(final Map kmsProviderSslContextMap, + final ProxySettings proxySettings) { + return new KeyManagementService(kmsProviderSslContextMap, proxySettings, 10000); } private Crypts() { diff --git a/driver-sync/src/main/com/mongodb/client/internal/KeyManagementService.java b/driver-sync/src/main/com/mongodb/client/internal/KeyManagementService.java index 806f768a923..85f4a4e7bcd 100644 --- a/driver-sync/src/main/com/mongodb/client/internal/KeyManagementService.java +++ b/driver-sync/src/main/com/mongodb/client/internal/KeyManagementService.java @@ -17,24 +17,20 @@ package com.mongodb.client.internal; import com.mongodb.ServerAddress; +import com.mongodb.connection.ProxySettings; import com.mongodb.internal.TimeoutContext; -import com.mongodb.internal.connection.SslHelper; +import com.mongodb.internal.capi.KmsSocketConnector; import com.mongodb.internal.diagnostics.logging.Logger; import com.mongodb.internal.diagnostics.logging.Loggers; import com.mongodb.internal.time.Timeout; import com.mongodb.lang.Nullable; import com.mongodb.lang.NonNull; -import javax.net.SocketFactory; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.net.InetAddress; -import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketException; import java.nio.ByteBuffer; @@ -48,10 +44,13 @@ class KeyManagementService { private static final Logger LOGGER = Loggers.getLogger("client"); private final Map kmsProviderSslContextMap; + private final ProxySettings proxySettings; private final int timeoutMillis; - KeyManagementService(final Map kmsProviderSslContextMap, final int timeoutMillis) { + KeyManagementService(final Map kmsProviderSslContextMap, + final ProxySettings proxySettings, final int timeoutMillis) { this.kmsProviderSslContextMap = notNull("kmsProviderSslContextMap", kmsProviderSslContextMap); + this.proxySettings = notNull("proxySettings", proxySettings); this.timeoutMillis = timeoutMillis; } @@ -61,18 +60,15 @@ public InputStream stream(final String kmsProvider, final String host, final Byt LOGGER.info("Connecting to KMS server at " + serverAddress); SSLContext sslContext = kmsProviderSslContextMap.get(kmsProvider); - SocketFactory sslSocketFactory = sslContext == null - ? SSLSocketFactory.getDefault() : sslContext.getSocketFactory(); - SSLSocket socket = (SSLSocket) sslSocketFactory.createSocket(); - enableHostNameVerification(socket); + SSLSocket socket = KmsSocketConnector.connect(sslContext, proxySettings, serverAddress, timeoutMillis, + remainingMillis(operationTimeout)); - try { - socket.setSoTimeout(timeoutMillis); - socket.connect(new InetSocketAddress(InetAddress.getByName(serverAddress.getHost()), serverAddress.getPort()), timeoutMillis); - } catch (IOException e) { + // Establishing a proxy tunnel consumes part of the time budget, so re-check before issuing the request rather + // than sending one whose budget is already spent. + Timeout.nullAsInfinite(operationTimeout).onExpired(() -> { closeSocket(socket); - throw e; - } + TimeoutContext.throwMongoTimeoutException("Connecting to KMS server exceeded the timeout limit."); + }); try { OutputStream outputStream = socket.getOutputStream(); @@ -94,13 +90,20 @@ public InputStream stream(final String kmsProvider, final String host, final Byt } } - private void enableHostNameVerification(final SSLSocket socket) { - SSLParameters sslParameters = socket.getSSLParameters(); - if (sslParameters == null) { - sslParameters = new SSLParameters(); - } - SslHelper.enableHostNameVerification(sslParameters); - socket.setSSLParameters(sslParameters); + /** + * Determines the time available for reaching the KMS server, which the specification requires to be the time + * remaining in the operation when CSOT is in use. + * + *

Visible for testing.

+ * + * @return the remaining time available for connecting to the KMS server, in milliseconds, never larger than the + * configured connect timeout. + */ + long remainingMillis(@Nullable final Timeout operationTimeout) { + return Timeout.nullAsInfinite(operationTimeout).call(MILLISECONDS, + () -> (long) timeoutMillis, + (ms) -> Math.min(ms, timeoutMillis), + () -> TimeoutContext.throwMongoTimeoutException("Connecting to KMS server exceeded the timeout limit.")); } private void closeSocket(final Socket socket) { diff --git a/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideEncryptionKmsProxyProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideEncryptionKmsProxyProseTest.java new file mode 100644 index 00000000000..caaef74902d --- /dev/null +++ b/driver-sync/src/test/functional/com/mongodb/client/AbstractClientSideEncryptionKmsProxyProseTest.java @@ -0,0 +1,399 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.client; + +import com.mongodb.AutoEncryptionSettings; +import com.mongodb.ClientEncryptionSettings; +import com.mongodb.MongoClientSettings; +import com.mongodb.connection.ProxyProtocol; +import com.mongodb.connection.ProxySettings; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.vault.DataKeyOptions; +import com.mongodb.client.vault.ClientEncryption; +import com.mongodb.lang.Nullable; +import org.bson.BsonBinary; +import org.bson.BsonDocument; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.mongodb.ClusterFixture.isClientSideEncryptionTest; +import static com.mongodb.client.Fixture.getMongoClientSettingsBuilder; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Prose test 28, "KMS Connect Callback", implemented for the alternative mechanism this driver provides. + * + *

The specification states that "drivers are required to support an HTTP proxy but MAY omit + * {@code kmsConnectCallback} if they provide an alternative mechanism for proxy support", and the tests state that + * "drivers that do not implement {@code kmsConnectCallback} MUST use an alternative means of connecting to the HTTP + * proxy". This driver's alternative is declarative configuration through + * {@link ClientEncryptionSettings.Builder#proxySettings(ProxySettings)} and + * {@link AutoEncryptionSettings.Builder#proxySettings(ProxySettings)}, so the cases below configure a proxy rather than + * supplying a callback.

+ * + *

All cases require real AWS KMS credentials and are skipped when they are not available. The KMS HTTP proxy is + * started by {@code drivers-evergreen-tools}: {@code .evergreen/csfle/start-servers.sh} runs {@code kms_http_proxy.py} + * on port 9004 in plain HTTP mode and on port 9005 in HTTPS mode. Cases are skipped when the proxy is unreachable, so + * that this test does not fail when run outside that environment.

+ * + *

Case 6, "Retry", is not implemented: it is to be skipped by drivers that do not implement DRIVERS-1541, and this + * driver does not retry KMS requests.

+ * + * @see + * Prose test 28 + */ +public abstract class AbstractClientSideEncryptionKmsProxyProseTest { + + private static final String PROXY_HOST = "127.0.0.1"; + private static final int HTTP_PROXY_PORT = 9004; + private static final int HTTPS_PROXY_PORT = 9005; + + private static final String KEY_VAULT_NAMESPACE = "keyvault.datakeys"; + private static final String MASTER_KEY = "{" + + "region: \"us-east-1\", " + + "key: \"arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0\"}"; + + private static final Pattern CONNECT_COUNT_PATTERN = Pattern.compile("connect_count (\\d+)"); + + protected abstract ClientEncryption createClientEncryption(ClientEncryptionSettings settings); + + protected abstract MongoClient createMongoClient(MongoClientSettings settings); + + @BeforeEach + void requireAwsCredentials() { + assumeTrue(isClientSideEncryptionTest(), "Requires AWS KMS credentials"); + } + + @Test + @DisplayName("Case 1: plain HTTP proxy") + void testPlainHttpProxy() throws IOException { + assumeProxyIsRunning(false); + resetMetrics(false); + + try (ClientEncryption clientEncryption = createClientEncryption(clientEncryptionSettingsBuilder(null) + .proxySettings(httpProxySettings()) + .build())) { + assertNotNull(createDataKey(clientEncryption)); + } + + assertTrue(getConnectCount(false) >= 1, "expected the KMS request to be routed through the proxy"); + } + + @Test + @DisplayName("Case 2: HTTPS proxy") + void testHttpsProxy() throws IOException { + assumeTrue(caFile() != null, "Requires the proxy's CA file, e.g. $DRIVERS_TOOLS/.evergreen/x509gen/ca.pem"); + assumeProxyIsRunning(true); + resetMetrics(true); + + // Two independent TLS layers are in play here: the driver's connection to the proxy, verified against the + // proxy's CA, and its connection to the KMS host, carried end-to-end through the CONNECT tunnel and verified + // against the real KMS host's certificate. Creating the data key confirms the driver verified the KMS host's + // identity rather than the proxy's. + try (ClientEncryption clientEncryption = createClientEncryption(clientEncryptionSettingsBuilder(null) + .proxySettings(httpsProxySettings()) + .build())) { + assertNotNull(createDataKey(clientEncryption)); + } + + assertTrue(getConnectCount(true) >= 1, "expected the KMS request to be routed through the proxy"); + } + + @Test + @DisplayName("Case 3: full auto encryption pipeline via proxy") + void testAutoEncryptionPipelineViaProxy() throws IOException { + assumeProxyIsRunning(false); + + try (MongoClient client = createMongoClient(getMongoClientSettingsBuilder().build())) { + client.getDatabase("keyvault").getCollection("datakeys").drop(); + client.getDatabase("db").getCollection("coll").drop(); + + BsonBinary dataKeyId; + try (ClientEncryption clientEncryption = createClientEncryption(clientEncryptionSettingsBuilder(null) + .proxySettings(httpProxySettings()) + .build())) { + dataKeyId = createDataKey(clientEncryption); + assertNotNull(dataKeyId); + } + + Map schemaMap = new HashMap<>(); + schemaMap.put("db.coll", schemaForDataKey(dataKeyId)); + + resetMetrics(false); + + AutoEncryptionSettings autoEncryptionSettings = AutoEncryptionSettings.builder() + .keyVaultNamespace(KEY_VAULT_NAMESPACE) + .kmsProviders(awsKmsProviders()) + .schemaMap(schemaMap) + .proxySettings(httpProxySettings()) + .build(); + + try (MongoClient encryptedClient = createMongoClient(getMongoClientSettingsBuilder() + .autoEncryptionSettings(autoEncryptionSettings) + .build())) { + MongoCollection encryptedColl = encryptedClient.getDatabase("db").getCollection("coll"); + encryptedColl.insertOne(new Document("_id", 1).append("encrypted_string", "hello")); + + Document decrypted = encryptedColl.find(Filters.eq("_id", 1)).first(); + assertNotNull(decrypted); + assertEquals("hello", decrypted.get("encrypted_string")); + } + + // read with the unencrypted client to confirm the value is stored encrypted + Document stored = client.getDatabase("db").getCollection("coll").find(Filters.eq("_id", 1)).first(); + assertNotNull(stored); + assertInstanceOf(org.bson.types.Binary.class, stored.get("encrypted_string")); + } + + // only one KMS request is expected, since the decrypted key is cached + assertTrue(getConnectCount(false) >= 1, "expected KMS requests to be routed through the proxy"); + } + + @Test + @DisplayName("Case 4: Error") + void testProxyError() { + // The spec configures a callback that returns an error. The equivalent here is a proxy that cannot be reached, + // which must surface as a failure rather than silently bypassing the proxy. + ProxySettings unreachableProxy = ProxySettings.builder() + .protocol(ProxyProtocol.HTTP) + .host(PROXY_HOST) + .port(1) + .build(); + + try (ClientEncryption clientEncryption = createClientEncryption(clientEncryptionSettingsBuilder(null) + .proxySettings(unreachableProxy) + .build())) { + assertThrows(RuntimeException.class, () -> createDataKey(clientEncryption)); + } + } + + @Test + @DisplayName("Case 5: operation timeout is honored through the proxy") + void testTimeoutThroughProxy() throws IOException { + // The spec asserts that the callback receives a non-zero timeout. With declarative configuration there is no + // callback to observe, so this instead asserts that an operation with a timeout configured still succeeds + // through the proxy, exercising the same CSOT plumbing. + assumeProxyIsRunning(false); + + try (ClientEncryption clientEncryption = createClientEncryption(clientEncryptionSettingsBuilder(1000L) + .proxySettings(httpProxySettings()) + .build())) { + assertNotNull(createDataKey(clientEncryption)); + } + } + + private static ProxySettings httpProxySettings() { + return ProxySettings.builder() + .protocol(ProxyProtocol.HTTP) + .host(PROXY_HOST) + .port(HTTP_PROXY_PORT) + .build(); + } + + private static ProxySettings httpsProxySettings() { + return ProxySettings.builder() + .protocol(ProxyProtocol.HTTPS) + .host(PROXY_HOST) + .port(HTTPS_PROXY_PORT) + .sslContext(proxySslContext()) + .build(); + } + + private ClientEncryptionSettings.Builder clientEncryptionSettingsBuilder(@Nullable final Long timeoutMS) { + MongoClientSettings.Builder keyVaultSettingsBuilder = getMongoClientSettingsBuilder(); + if (timeoutMS != null) { + keyVaultSettingsBuilder.timeout(timeoutMS, MILLISECONDS); + } + ClientEncryptionSettings.Builder builder = ClientEncryptionSettings.builder() + .keyVaultMongoClientSettings(keyVaultSettingsBuilder.build()) + .keyVaultNamespace(KEY_VAULT_NAMESPACE) + .kmsProviders(awsKmsProviders()); + if (timeoutMS != null) { + builder.timeout(timeoutMS, MILLISECONDS); + } + return builder; + } + + private static Map> awsKmsProviders() { + Map aws = new HashMap<>(); + aws.put("accessKeyId", System.getenv("AWS_ACCESS_KEY_ID")); + aws.put("secretAccessKey", System.getenv("AWS_SECRET_ACCESS_KEY")); + Map> kmsProviders = new HashMap<>(); + kmsProviders.put("aws", aws); + return kmsProviders; + } + + private static BsonBinary createDataKey(final ClientEncryption clientEncryption) { + return clientEncryption.createDataKey("aws", new DataKeyOptions().masterKey(BsonDocument.parse(MASTER_KEY))); + } + + private static BsonDocument schemaForDataKey(final BsonBinary dataKeyId) { + String base64DataKeyId = Base64.getEncoder().encodeToString(dataKeyId.getData()); + return BsonDocument.parse("{" + + " bsonType: \"object\"," + + " properties: {" + + " encrypted_string: {" + + " encrypt: {" + + " keyId: [{\"$binary\": {\"base64\": \"" + base64DataKeyId + "\", \"subType\": \"04\"}}]," + + " bsonType: \"string\"," + + " algorithm: \"AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic\"" + + " }" + + " }" + + " }" + + "}"); + } + + // --- the proxy's control endpoints ------------------------------------------------------------------------- + + private void assumeProxyIsRunning(final boolean useTls) { + try { + getConnectCount(useTls); + } catch (IOException e) { + assumeTrue(false, "KMS HTTP proxy is not running on port " + + (useTls ? HTTPS_PROXY_PORT : HTTP_PROXY_PORT) + ": " + e.getMessage()); + } + } + + private void resetMetrics(final boolean useTls) throws IOException { + readControlResponse("/reset", "POST", useTls); + } + + private int getConnectCount(final boolean useTls) throws IOException { + String body = readControlResponse("/metrics", "GET", useTls); + Matcher matcher = CONNECT_COUNT_PATTERN.matcher(body); + if (!matcher.find()) { + throw new IOException("Could not find connect_count in the proxy's metrics response: " + body); + } + return Integer.parseInt(matcher.group(1)); + } + + private String readControlResponse(final String path, final String method, final boolean useTls) throws IOException { + int port = useTls ? HTTPS_PROXY_PORT : HTTP_PROXY_PORT; + URL url = new URL((useTls ? "https" : "http") + "://" + PROXY_HOST + ":" + port + path); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + if (useTls) { + HttpsURLConnection httpsConnection = (HttpsURLConnection) connection; + httpsConnection.setSSLSocketFactory(proxySslContext().getSocketFactory()); + // The proxy's certificate is verified against its CA above. Its subject does not necessarily match the + // loopback address that the control endpoints are reached on, which is immaterial for these tests. + httpsConnection.setHostnameVerifier((hostname, session) -> PROXY_HOST.equals(hostname)); + } + connection.setRequestMethod(method); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + try (InputStream inputStream = connection.getInputStream()) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + byte[] buffer = new byte[512]; + int read; + while ((read = inputStream.read(buffer)) != -1) { + body.write(buffer, 0, read); + } + return new String(body.toByteArray(), StandardCharsets.UTF_8); + } finally { + connection.disconnect(); + } + } + + // --- the proxy's CA ---------------------------------------------------------------------------------------- + + private static volatile SSLContext proxySslContext; + + private static SSLContext proxySslContext() { + SSLContext result = proxySslContext; + if (result == null) { + synchronized (AbstractClientSideEncryptionKmsProxyProseTest.class) { + result = proxySslContext; + if (result == null) { + result = buildProxySslContext(); + proxySslContext = result; + } + } + } + return result; + } + + private static SSLContext buildProxySslContext() { + String caFile = caFile(); + assertNotNull(caFile, "the proxy's CA file could not be located"); + try (InputStream caStream = Files.newInputStream(Paths.get(caFile))) { + X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(caStream); + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("csfle-proxy-ca", ca); + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + return sslContext; + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Could not build an SSLContext trusting the proxy's CA", e); + } + } + + /** + * @return the path to the CA certificate that signed the HTTPS proxy's certificate, or null if it cannot be found, + * in which case the HTTPS proxy case is skipped. + */ + @Nullable + private static String caFile() { + String caFile = System.getProperty("org.mongodb.test.csfle.tls.ca.file"); + if (caFile == null) { + caFile = System.getenv("CSFLE_TLS_CA_FILE"); + } + if (caFile == null) { + String driversTools = System.getenv("DRIVERS_TOOLS"); + if (driversTools != null) { + caFile = driversTools + "/.evergreen/x509gen/ca.pem"; + } + } + return caFile != null && new File(caFile).isFile() ? caFile : null; + } +} diff --git a/driver-sync/src/test/functional/com/mongodb/client/ClientSideEncryptionKmsProxyProseTest.java b/driver-sync/src/test/functional/com/mongodb/client/ClientSideEncryptionKmsProxyProseTest.java new file mode 100644 index 00000000000..dbb31d0bcfa --- /dev/null +++ b/driver-sync/src/test/functional/com/mongodb/client/ClientSideEncryptionKmsProxyProseTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.client; + +import com.mongodb.ClientEncryptionSettings; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.vault.ClientEncryption; +import com.mongodb.client.vault.ClientEncryptions; + +public class ClientSideEncryptionKmsProxyProseTest extends AbstractClientSideEncryptionKmsProxyProseTest { + @Override + protected ClientEncryption createClientEncryption(final ClientEncryptionSettings settings) { + return ClientEncryptions.create(settings); + } + + @Override + protected MongoClient createMongoClient(final MongoClientSettings settings) { + return MongoClients.create(settings); + } +} diff --git a/driver-sync/src/test/unit/com/mongodb/client/internal/KeyManagementServiceTest.java b/driver-sync/src/test/unit/com/mongodb/client/internal/KeyManagementServiceTest.java new file mode 100644 index 00000000000..0560fec79b9 --- /dev/null +++ b/driver-sync/src/test/unit/com/mongodb/client/internal/KeyManagementServiceTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.client.internal; + +import com.mongodb.MongoOperationTimeoutException; +import com.mongodb.connection.ProxySettings; +import com.mongodb.internal.time.Timeout; +import org.junit.jupiter.api.Test; + +import static com.mongodb.internal.time.Timeout.ZeroSemantics.ZERO_DURATION_MEANS_EXPIRED; +import static java.util.Collections.emptyMap; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The specification requires that a driver supporting CSOT pass the remaining {@code timeoutMS} when establishing a + * connection to a KMS host. Prose test 28 asserts this by observing a {@code kmsConnectCallback}; since this driver + * provides proxy support declaratively rather than through a callback, there is no callback to observe, so the + * requirement is verified here instead. + */ +final class KeyManagementServiceTest { + + private static final int CONNECT_TIMEOUT_MILLIS = 10_000; + + private final KeyManagementService keyManagementService = + new KeyManagementService(emptyMap(), ProxySettings.builder().build(), CONNECT_TIMEOUT_MILLIS); + + @Test + void shouldUseConfiguredConnectTimeoutWhenNoOperationTimeoutApplies() { + assertEquals(CONNECT_TIMEOUT_MILLIS, keyManagementService.remainingMillis(null)); + } + + @Test + void shouldPassRemainingOperationTimeoutWhenItIsShorter() { + Timeout operationTimeout = Timeout.expiresIn(500, MILLISECONDS, ZERO_DURATION_MEANS_EXPIRED); + + long remaining = keyManagementService.remainingMillis(operationTimeout); + + assertTrue(remaining > 0 && remaining <= 500, + () -> "expected the remaining operation timeout to be passed, but got " + remaining); + } + + @Test + void shouldNotExceedConfiguredConnectTimeoutWhenOperationTimeoutIsLonger() { + Timeout operationTimeout = Timeout.expiresIn(60_000, MILLISECONDS, ZERO_DURATION_MEANS_EXPIRED); + + assertEquals(CONNECT_TIMEOUT_MILLIS, keyManagementService.remainingMillis(operationTimeout)); + } + + @Test + void shouldThrowWhenOperationTimeoutHasExpired() { + Timeout expired = Timeout.expiresIn(0, MILLISECONDS, ZERO_DURATION_MEANS_EXPIRED); + + assertThrows(MongoOperationTimeoutException.class, () -> keyManagementService.remainingMillis(expired)); + } +}