diff --git a/src/MongoDB.Driver.Encryption/AutoEncryptionLibMongoController.cs b/src/MongoDB.Driver.Encryption/AutoEncryptionLibMongoController.cs index 802dde79e98..646aab90bef 100644 --- a/src/MongoDB.Driver.Encryption/AutoEncryptionLibMongoController.cs +++ b/src/MongoDB.Driver.Encryption/AutoEncryptionLibMongoController.cs @@ -65,7 +65,7 @@ private AutoEncryptionLibMongoCryptController( IMongoClient metadataClient, CryptClient cryptClient, AutoEncryptionOptions autoEncryptionOptions) - : base(cryptClient, keyVaultClient, autoEncryptionOptions.KeyVaultNamespace, autoEncryptionOptions.KmsProviders, autoEncryptionOptions.TlsOptions) + : base(cryptClient, keyVaultClient, autoEncryptionOptions.KeyVaultNamespace, autoEncryptionOptions.KmsProviders, autoEncryptionOptions.TlsOptions, autoEncryptionOptions.KmsConnector) { _internalClient = internalClient; // can be null _metadataClient = metadataClient; // can be null diff --git a/src/MongoDB.Driver.Encryption/ClientEncryptionOptions.cs b/src/MongoDB.Driver.Encryption/ClientEncryptionOptions.cs index 33764924622..eab2a89921f 100644 --- a/src/MongoDB.Driver.Encryption/ClientEncryptionOptions.cs +++ b/src/MongoDB.Driver.Encryption/ClientEncryptionOptions.cs @@ -26,6 +26,7 @@ public sealed class ClientEncryptionOptions { // private fields private TimeSpan? _keyExpiration; + private readonly IKmsConnector _kmsConnector; private readonly IMongoClient _keyVaultClient; private readonly CollectionNamespace _keyVaultNamespace; private readonly IReadOnlyDictionary> _kmsProviders; @@ -39,12 +40,14 @@ public sealed class ClientEncryptionOptions /// The key vault namespace. /// The KMS providers. /// The tls options. + /// The KMS connector used to open connections to KMS hosts. public ClientEncryptionOptions( IMongoClient keyVaultClient, CollectionNamespace keyVaultNamespace, IReadOnlyDictionary> kmsProviders, - Optional> tlsOptions = default) - : this(keyVaultClient, keyVaultNamespace, kmsProviders, tlsOptions, keyExpiration: null) + Optional> tlsOptions = default, + Optional kmsConnector = default) + : this(keyVaultClient, keyVaultNamespace, kmsProviders, tlsOptions, kmsConnector, keyExpiration: null) { } @@ -53,12 +56,14 @@ private ClientEncryptionOptions( CollectionNamespace keyVaultNamespace, IReadOnlyDictionary> kmsProviders, Optional> tlsOptions = default, + Optional kmsConnector = default, Optional keyExpiration = default) { _keyVaultClient = Ensure.IsNotNull(keyVaultClient, nameof(keyVaultClient)); _keyVaultNamespace = Ensure.IsNotNull(keyVaultNamespace, nameof(keyVaultNamespace)); _kmsProviders = Ensure.IsNotNull(kmsProviders, nameof(kmsProviders)); _tlsOptions = tlsOptions.WithDefault(new Dictionary()); + _kmsConnector = kmsConnector.WithDefault(null); _keyExpiration = keyExpiration.WithDefault(null); EnsureKmsProvidersAreValid(_kmsProviders); @@ -72,6 +77,14 @@ private ClientEncryptionOptions( /// public TimeSpan? KeyExpiration => _keyExpiration; + /// + /// Gets the KMS connector used to open connections to KMS hosts. + /// + /// + /// The KMS connector used to open connections to KMS hosts. + /// + public IKmsConnector KmsConnector => _kmsConnector; + /// /// Gets the key vault client. /// @@ -111,18 +124,21 @@ private ClientEncryptionOptions( /// The key vault namespace. /// The KMS providers. /// The tls options. + /// The KMS connector used to open connections to KMS hosts. /// A new ClientEncryptionOptions instance. public ClientEncryptionOptions With( Optional keyVaultClient = default, Optional keyVaultNamespace = default, Optional>> kmsProviders = default, - Optional> tlsOptions = default) + Optional> tlsOptions = default, + Optional kmsConnector = default) { return new ClientEncryptionOptions( keyVaultClient: keyVaultClient.WithDefault(_keyVaultClient), keyVaultNamespace: keyVaultNamespace.WithDefault(_keyVaultNamespace), kmsProviders: kmsProviders.WithDefault(_kmsProviders), tlsOptions: Optional.Create(tlsOptions.WithDefault(_tlsOptions)), + kmsConnector: Optional.Create(kmsConnector.WithDefault(_kmsConnector)), keyExpiration: _keyExpiration); } diff --git a/src/MongoDB.Driver.Encryption/ExplicitEncryptionLibMongoCryptController.cs b/src/MongoDB.Driver.Encryption/ExplicitEncryptionLibMongoCryptController.cs index ef1127936e9..fb2d31c7aca 100644 --- a/src/MongoDB.Driver.Encryption/ExplicitEncryptionLibMongoCryptController.cs +++ b/src/MongoDB.Driver.Encryption/ExplicitEncryptionLibMongoCryptController.cs @@ -34,7 +34,7 @@ public ExplicitEncryptionLibMongoCryptController( ClientEncryptionOptions clientEncryptionOptions) : base(cryptClient, Ensure.IsNotNull(Ensure.IsNotNull(clientEncryptionOptions, nameof(clientEncryptionOptions)).KeyVaultClient, nameof(clientEncryptionOptions.KeyVaultClient)), - clientEncryptionOptions.KeyVaultNamespace, clientEncryptionOptions.KmsProviders, clientEncryptionOptions.TlsOptions) + clientEncryptionOptions.KeyVaultNamespace, clientEncryptionOptions.KmsProviders, clientEncryptionOptions.TlsOptions, clientEncryptionOptions.KmsConnector) { } diff --git a/src/MongoDB.Driver.Encryption/KmsConnectorStreamFactory.cs b/src/MongoDB.Driver.Encryption/KmsConnectorStreamFactory.cs new file mode 100644 index 00000000000..3de72009079 --- /dev/null +++ b/src/MongoDB.Driver.Encryption/KmsConnectorStreamFactory.cs @@ -0,0 +1,57 @@ +/* Copyright 2010-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. +*/ + +using System; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver.Core.Connections; +using MongoDB.Driver.Core.Misc; + +namespace MongoDB.Driver.Encryption; + +internal sealed class KmsConnectorStreamFactory : IStreamFactory +{ + private readonly IKmsConnector _kmsConnector; + + public KmsConnectorStreamFactory(IKmsConnector kmsConnector) + { + _kmsConnector = Ensure.IsNotNull(kmsConnector, nameof(kmsConnector)); + } + + public Stream CreateStream(EndPoint endPoint, CancellationToken cancellationToken) + { + var stream = _kmsConnector.Connect(CreateConnectionContext(endPoint), cancellationToken); + return EnsureConnectResult(stream, nameof(IKmsConnector.Connect)); + } + + public async Task CreateStreamAsync(EndPoint endPoint, CancellationToken cancellationToken) + { + var stream = await _kmsConnector.ConnectAsync(CreateConnectionContext(endPoint), cancellationToken).ConfigureAwait(false); + return EnsureConnectResult(stream, nameof(IKmsConnector.ConnectAsync)); + } + + private static KmsConnectionContext CreateConnectionContext(EndPoint endPoint) + { + var dnsEndPoint = (DnsEndPoint)endPoint; + return new KmsConnectionContext(dnsEndPoint.Host, dnsEndPoint.Port); + } + + private static Stream EnsureConnectResult(Stream stream, string methodName) + { + return stream ?? throw new InvalidOperationException($"{nameof(IKmsConnector)}.{methodName} returned null."); + } +} diff --git a/src/MongoDB.Driver.Encryption/LibMongoCryptControllerBase.cs b/src/MongoDB.Driver.Encryption/LibMongoCryptControllerBase.cs index 48ab261866b..7e17555340b 100644 --- a/src/MongoDB.Driver.Encryption/LibMongoCryptControllerBase.cs +++ b/src/MongoDB.Driver.Encryption/LibMongoCryptControllerBase.cs @@ -39,8 +39,8 @@ internal abstract class LibMongoCryptControllerBase protected readonly CollectionNamespace _keyVaultNamespace; // private fields + private readonly IStreamFactory _kmsStreamFactory; private readonly IReadOnlyDictionary> _kmsProviders; - private readonly IStreamFactory _networkStreamFactory; private readonly IReadOnlyDictionary _tlsOptions; // constructors @@ -49,15 +49,16 @@ protected LibMongoCryptControllerBase( IMongoClient keyVaultClient, CollectionNamespace keyVaultNamespace, IReadOnlyDictionary> kmsProviders, - IReadOnlyDictionary tlsOptions) + IReadOnlyDictionary tlsOptions, + IKmsConnector kmsConnector) { _cryptClient = Ensure.IsNotNull(cryptClient, nameof(cryptClient)); _keyVaultClient = Ensure.IsNotNull(keyVaultClient, nameof(keyVaultClient)); // _keyVaultClient might not be fully constructed at this point, don't call any instance methods on it yet _keyVaultNamespace = Ensure.IsNotNull(keyVaultNamespace, nameof(keyVaultNamespace)); _keyVaultCollection = new Lazy>(GetKeyVaultCollection); // delay use _keyVaultClient _kmsProviders = Ensure.IsNotNull(kmsProviders, nameof(kmsProviders)); - _networkStreamFactory = new NetworkStreamFactory(); _tlsOptions = Ensure.IsNotNull(tlsOptions, nameof(tlsOptions)); + _kmsStreamFactory = kmsConnector != null ? new KmsConnectorStreamFactory(kmsConnector) : new NetworkStreamFactory(); // kmsConnector is optional; null means connect directly to the KMS host } // public properties @@ -287,7 +288,7 @@ private void SendKmsRequest(KmsRequest request, CancellationToken cancellation) var endpoint = CreateKmsEndPoint(request.Endpoint); var tlsStreamSettings = GetTlsStreamSettings(request.KmsProvider); - var sslStreamFactory = new SslStreamFactory(tlsStreamSettings, _networkStreamFactory); + var sslStreamFactory = new SslStreamFactory(tlsStreamSettings, _kmsStreamFactory); using var sslStream = sslStreamFactory.CreateStream(endpoint, cancellation); var sleepMs = request.Sleep; @@ -331,7 +332,7 @@ private async Task SendKmsRequestAsync(KmsRequest request, CancellationToken can var endpoint = CreateKmsEndPoint(request.Endpoint); var tlsStreamSettings = GetTlsStreamSettings(request.KmsProvider); - var sslStreamFactory = new SslStreamFactory(tlsStreamSettings, _networkStreamFactory); + var sslStreamFactory = new SslStreamFactory(tlsStreamSettings, _kmsStreamFactory); using var sslStream = await sslStreamFactory.CreateStreamAsync(endpoint, cancellation).ConfigureAwait(false); var sleepMs = request.Sleep; diff --git a/src/MongoDB.Driver/AutoEncryptionOptions.cs b/src/MongoDB.Driver/AutoEncryptionOptions.cs index c9864cd09be..ea2e0d30b9f 100644 --- a/src/MongoDB.Driver/AutoEncryptionOptions.cs +++ b/src/MongoDB.Driver/AutoEncryptionOptions.cs @@ -38,6 +38,7 @@ public sealed class AutoEncryptionOptions private TimeSpan? _keyExpiration; private readonly IReadOnlyDictionary _encryptedFieldsMap; private readonly IReadOnlyDictionary _extraOptions; + private readonly IKmsConnector _kmsConnector; private readonly IMongoClient _keyVaultClient; private readonly CollectionNamespace _keyVaultNamespace; private readonly IReadOnlyDictionary> _kmsProviders; @@ -57,6 +58,7 @@ public sealed class AutoEncryptionOptions /// The tls options. /// The encryptedFields map. /// The bypass query analysis flag. + /// The KMS connector used to open connections to KMS hosts. public AutoEncryptionOptions( CollectionNamespace keyVaultNamespace, IReadOnlyDictionary> kmsProviders, @@ -66,8 +68,9 @@ public AutoEncryptionOptions( Optional> schemaMap = default, Optional> tlsOptions = default, Optional> encryptedFieldsMap = default, - Optional bypassQueryAnalysis = default) - : this(keyVaultNamespace, kmsProviders, bypassAutoEncryption, extraOptions, keyVaultClient, schemaMap, tlsOptions, encryptedFieldsMap, bypassQueryAnalysis, keyExpiration: null) + Optional bypassQueryAnalysis = default, + Optional kmsConnector = default) + : this(keyVaultNamespace, kmsProviders, bypassAutoEncryption, extraOptions, keyVaultClient, schemaMap, tlsOptions, encryptedFieldsMap, bypassQueryAnalysis, kmsConnector, keyExpiration: null) { } @@ -81,6 +84,7 @@ private AutoEncryptionOptions( Optional> tlsOptions, Optional> encryptedFieldsMap, Optional bypassQueryAnalysis, + Optional kmsConnector, Optional keyExpiration) { _keyVaultNamespace = Ensure.IsNotNull(keyVaultNamespace, nameof(keyVaultNamespace)); @@ -89,6 +93,7 @@ private AutoEncryptionOptions( _bypassQueryAnalysis = bypassQueryAnalysis.WithDefault(null); _keyExpiration = keyExpiration.WithDefault(null); _extraOptions = extraOptions.WithDefault(null); + _kmsConnector = kmsConnector.WithDefault(null); _keyVaultClient = keyVaultClient.WithDefault(null); _schemaMap = schemaMap.WithDefault(null); _tlsOptions = tlsOptions.WithDefault(new Dictionary()); @@ -137,6 +142,14 @@ private AutoEncryptionOptions( /// public IReadOnlyDictionary ExtraOptions => _extraOptions; + /// + /// Gets the KMS connector used to open connections to KMS hosts. + /// + /// + /// The KMS connector used to open connections to KMS hosts. + /// + public IKmsConnector KmsConnector => _kmsConnector; + /// /// Gets the key vault client. /// @@ -199,6 +212,7 @@ public void SetKeyExpiration(TimeSpan? keyExpiration) /// The schema map. /// The tls options. /// The encryptedFields map. + /// The KMS connector used to open connections to KMS hosts. /// A new instance of . public AutoEncryptionOptions With( Optional keyVaultNamespace = default, @@ -209,7 +223,8 @@ public AutoEncryptionOptions With( Optional keyVaultClient = default, Optional> schemaMap = default, Optional> tlsOptions = default, - Optional> encryptedFieldsMap = default) + Optional> encryptedFieldsMap = default, + Optional kmsConnector = default) { return new AutoEncryptionOptions( keyVaultNamespace.WithDefault(_keyVaultNamespace), @@ -221,6 +236,7 @@ public AutoEncryptionOptions With( Optional.Create(tlsOptions.WithDefault(_tlsOptions)), Optional.Create(encryptedFieldsMap.WithDefault(_encryptedFieldsMap)), Optional.Create(bypassQueryAnalysis.WithDefault(_bypassQueryAnalysis)), + Optional.Create(kmsConnector.WithDefault(_kmsConnector)), _keyExpiration); } @@ -235,6 +251,7 @@ public override bool Equals(object obj) _bypassQueryAnalysis == rhs._bypassQueryAnalysis && _keyExpiration == rhs._keyExpiration && ExtraOptionsEquals(_extraOptions, rhs._extraOptions) && + object.ReferenceEquals(_kmsConnector, rhs._kmsConnector) && object.ReferenceEquals(_keyVaultClient, rhs._keyVaultClient) && _keyVaultNamespace.Equals(rhs._keyVaultNamespace) && KmsProvidersEqualityHelper.Equals(_kmsProviders, rhs._kmsProviders) && @@ -251,6 +268,7 @@ public override int GetHashCode() .Hash(_bypassQueryAnalysis) .Hash(_keyExpiration) .HashElements(_extraOptions) + .Hash(_kmsConnector) .Hash(_keyVaultClient) .Hash(_keyVaultNamespace) .HashElements(_kmsProviders) diff --git a/src/MongoDB.Driver/Encryption/IKmsConnector.cs b/src/MongoDB.Driver/Encryption/IKmsConnector.cs new file mode 100644 index 00000000000..df7884a23ed --- /dev/null +++ b/src/MongoDB.Driver/Encryption/IKmsConnector.cs @@ -0,0 +1,52 @@ +/* Copyright 2010-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. +*/ + +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MongoDB.Driver.Encryption; + +/// +/// Opens the transport connection used to reach a KMS host. When supplied via +/// ClientEncryptionOptions or , the driver +/// invokes this instead of opening a direct TCP connection to the KMS host, then wraps the +/// returned stream in TLS using the KMS provider's configured TLS options. +/// The primary use case is routing KMS traffic through an HTTP proxy via HTTPS CONNECT. +/// +/// +/// Both and must be implemented, even if the +/// application only uses one of the driver's sync or async encryption APIs: the driver calls +/// whichever method matches the API used for the operation in progress. An implementation that +/// only supports one direction can have the other throw. +/// +public interface IKmsConnector +{ + /// + /// Opens a connection to the specified KMS host. + /// + /// Describes the KMS host to connect to. + /// The cancellation token. + /// A stream connected to the KMS host. The driver wraps this stream in TLS. Must not be null. + Stream Connect(KmsConnectionContext context, CancellationToken cancellationToken); + + /// + /// Opens a connection to the specified KMS host. + /// + /// Describes the KMS host to connect to. + /// The cancellation token. + /// A stream connected to the KMS host. The driver wraps this stream in TLS. Must not be null. + Task ConnectAsync(KmsConnectionContext context, CancellationToken cancellationToken); +} diff --git a/src/MongoDB.Driver/Encryption/KmsConnectionContext.cs b/src/MongoDB.Driver/Encryption/KmsConnectionContext.cs new file mode 100644 index 00000000000..e3d48ce40c4 --- /dev/null +++ b/src/MongoDB.Driver/Encryption/KmsConnectionContext.cs @@ -0,0 +1,46 @@ +/* Copyright 2010-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. +*/ + +using MongoDB.Driver.Core.Misc; + +namespace MongoDB.Driver.Encryption; + +/// +/// Describes the KMS host that an is being asked to connect to. +/// +public sealed class KmsConnectionContext +{ + + /// + /// Initializes a new instance of the class. + /// + /// The KMS hostname. + /// The KMS port. + public KmsConnectionContext(string host, int port) + { + Host = Ensure.IsNotNullOrEmpty(host, nameof(host)); + Port = port; + } + + /// + /// Gets the KMS hostname (for example, kms.us-east-1.amazonaws.com). + /// + public string Host { get; } + + /// + /// Gets the KMS port. + /// + public int Port { get; } +} diff --git a/tests/MongoDB.Driver.Tests/Specifications/client-side-encryption/prose-tests/ClientEncryptionProseTests.cs b/tests/MongoDB.Driver.Tests/Specifications/client-side-encryption/prose-tests/ClientEncryptionProseTests.cs index a734591d378..af4bd301f6b 100644 --- a/tests/MongoDB.Driver.Tests/Specifications/client-side-encryption/prose-tests/ClientEncryptionProseTests.cs +++ b/tests/MongoDB.Driver.Tests/Specifications/client-side-encryption/prose-tests/ClientEncryptionProseTests.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -909,6 +910,148 @@ void TestCase(ClientEncryption testCaseClientEncryption, BsonDocument masterKey, } } + // KMS Connect Callback prose tests (prose test 28). + // https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#28-kms-connect-callback + // Case 5 (callback receives timeout) is omitted because it requires CSOT, which the C# driver does not implement + + // Case 1: plain HTTP proxy. The connector tunnels KMS traffic through the plain HTTP proxy on + // port 9004; the driver still negotiates TLS end-to-end with the KMS host through the tunnel. + // https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#case-1-plain-http-proxy + [Theory] + [ParameterAttributeData] + public void KmsConnectCallback_via_plain_http_proxy([Values(false, true)] bool async) + { + RequireEnvironment.Check().KmsProvider("aws"); + RequireEnvironment.Check().EnvironmentVariable("KMS_MOCK_SERVERS_ENABLED", isDefined: true); + + ResetProxyMetrics(HttpProxyPort, useTls: false); + + using var client = ConfigureClient(); + var connector = new HttpConnectProxyKmsConnector(HttpProxyPort, useTls: false, caCertificate: null); + using var clientEncryption = CreateAwsClientEncryptionWithConnector(client, connector); + + var dataKeyId = CreateDataKey(clientEncryption, "aws", new DataKeyOptions(masterKey: AwsMasterKey()), async); + + dataKeyId.Should().NotBe(Guid.Empty); + GetProxyConnectCount(HttpProxyPort, useTls: false).Should().BeGreaterOrEqualTo(1); + } + + // Case 2: HTTPS proxy. Two independent TLS layers are in play: the connector's client-to-proxy + // TLS (verified against the proxy CA) and the driver's client-to-KMS TLS carried through the + // CONNECT tunnel (verified against the real KMS host). Success confirms the KMS host - not the + // proxy - was verified. + // https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#case-2-https-proxy + [Theory] + [ParameterAttributeData] + public void KmsConnectCallback_via_https_proxy([Values(false, true)] bool async) + { + RequireEnvironment.Check().KmsProvider("aws"); + RequireEnvironment.Check().EnvironmentVariable("KMS_MOCK_SERVERS_ENABLED", isDefined: true); + using var caCertificate = LoadProxyCaCertificate(); + + ResetProxyMetrics(HttpsProxyPort, useTls: true, caCertificate); + + using var client = ConfigureClient(); + var connector = new HttpConnectProxyKmsConnector(HttpsProxyPort, useTls: true, caCertificate); + using var clientEncryption = CreateAwsClientEncryptionWithConnector(client, connector); + + var dataKeyId = CreateDataKey(clientEncryption, "aws", new DataKeyOptions(masterKey: AwsMasterKey()), async); + + dataKeyId.Should().NotBe(Guid.Empty); + GetProxyConnectCount(HttpsProxyPort, useTls: true, caCertificate).Should().BeGreaterOrEqualTo(1); + } + + // Case 3: full auto encryption pipeline via proxy. Exercises encrypt-on-insert and + // decrypt-on-find with KMS traffic routed through the proxy. + // https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#case-3-full-auto-encryption-pipeline-via-proxy + [Theory] + [ParameterAttributeData] + public void KmsConnectCallback_full_auto_encryption_pipeline([Values(false, true)] bool async) + { + RequireEnvironment.Check().KmsProvider("aws"); + RequireEnvironment.Check().EnvironmentVariable("KMS_MOCK_SERVERS_ENABLED", isDefined: true); + + using var client = ConfigureClient(); + var connector = new HttpConnectProxyKmsConnector(HttpProxyPort, useTls: false, caCertificate: null); + using var clientEncryption = CreateAwsClientEncryptionWithConnector(client, connector); + + var dataKeyId = CreateDataKey(clientEncryption, "aws", new DataKeyOptions(masterKey: AwsMasterKey()), async); + + var schema = new BsonDocument + { + { "bsonType", "object" }, + { + "properties", + new BsonDocument("encrypted_string", new BsonDocument("encrypt", new BsonDocument + { + { "keyId", new BsonArray { new BsonBinaryData(dataKeyId, GuidRepresentation.Standard) } }, + { "bsonType", "string" }, + { "algorithm", "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" } + })) + } + }; + + ResetProxyMetrics(HttpProxyPort, useTls: false); + + using var clientEncrypted = ConfigureClientEncrypted( + schemaMap: new BsonDocument(__collCollectionNamespace.FullName, schema), + kmsProviderFilter: "aws", + autoEncryptionOptionsConfigurator: options => options.With(kmsConnector: Optional.Create(connector))); + + var encryptedCollection = GetCollection(clientEncrypted, __collCollectionNamespace); + Insert(encryptedCollection, async, new BsonDocument { { "_id", 1 }, { "encrypted_string", "hello" } }); + + var decrypted = Find(encryptedCollection, new BsonDocument("_id", 1), async).Single(); + decrypted["encrypted_string"].AsString.Should().Be("hello"); + + var stillEncrypted = Find(GetCollection(client, __collCollectionNamespace), new BsonDocument("_id", 1), async).Single(); + stillEncrypted["encrypted_string"].BsonType.Should().Be(BsonType.Binary); + + // Exactly one KMS request is expected since the decrypted key is cached; more than one would indicate a DEK caching regression. + GetProxyConnectCount(HttpProxyPort, useTls: false).Should().Be(1); + } + + // Case 4: Error. A connector that fails with a non-network error must surface the error to the caller + // rather than having it swallowed by the KMS retry path. + // https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#case-4-error + [Theory] + [ParameterAttributeData] + public void KmsConnectCallback_error_propagates([Values(false, true)] bool async) + { + RequireEnvironment.Check().KmsProvider("aws"); + RequireEnvironment.Check().EnvironmentVariable("KMS_MOCK_SERVERS_ENABLED", isDefined: true); + + using var client = ConfigureClient(); + var connector = new FailingKmsConnector("Test Error"); + using var clientEncryption = CreateAwsClientEncryptionWithConnector(client, connector); + + var exception = Record.Exception(() => CreateDataKey(clientEncryption, "aws", new DataKeyOptions(masterKey: AwsMasterKey()), async)); + + exception.Should().NotBeNull(); + GetExceptionChain(exception).Should().Contain(e => e.Message.Contains("Test Error")); + } + + // Case 6: Retry. A connector that fails with a network error must be retried. + // https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#case-6-retry + [Theory] + [ParameterAttributeData] + public void KmsConnectCallback_retries_after_network_error([Values(false, true)] bool async) + { + RequireEnvironment.Check().KmsProvider("aws"); + RequireEnvironment.Check().EnvironmentVariable("KMS_MOCK_SERVERS_ENABLED", isDefined: true); + + ResetProxyMetrics(HttpProxyPort, useTls: false); + + using var client = ConfigureClient(); + var connector = new RetryOnceKmsConnector(new HttpConnectProxyKmsConnector(HttpProxyPort, useTls: false, caCertificate: null)); + using var clientEncryption = CreateAwsClientEncryptionWithConnector(client, connector); + + var dataKeyId = CreateDataKey(clientEncryption, "aws", new DataKeyOptions(masterKey: AwsMasterKey()), async); + + dataKeyId.Should().NotBe(Guid.Empty); + connector.InvocationCount.Should().BeGreaterThan(1); + } + [Theory] [MemberData(nameof(DeadlockTest_MemberData))] public void DeadlockTest( @@ -4049,6 +4192,304 @@ private class Patient public string Name { get; set; } public string Ssn { get; set; } } + + // KMS Connect Callback helpers (prose test 28) + private const int HttpProxyPort = 9004; + private const int HttpsProxyPort = 9005; + + private static BsonDocument AwsMasterKey() => new BsonDocument + { + { "region", "us-east-1" }, + { "key", "arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0" } + }; + + private ClientEncryption CreateAwsClientEncryptionWithConnector(IMongoClient client, IKmsConnector kmsConnector) + { + var kmsProviders = EncryptionTestHelper.GetKmsProviders("aws"); + var clientEncryptionOptions = new ClientEncryptionOptions( + keyVaultClient: client, + keyVaultNamespace: __keyVaultCollectionNamespace, + kmsProviders: kmsProviders, + kmsConnector: Optional.Create(kmsConnector)); + return new ClientEncryption(clientEncryptionOptions); + } + + private static IEnumerable GetExceptionChain(Exception exception) + { + for (var current = exception; current != null; current = current.InnerException) + { + yield return current; + } + } + + private static void ResetProxyMetrics(int port, bool useTls, X509Certificate2 caCertificate = null) + { + using var httpClient = CreateProxyHttpClient(useTls ? caCertificate : null); + using var response = httpClient.PostAsync(ProxyUrl(port, useTls, "reset"), content: null).GetAwaiter().GetResult(); + response.EnsureSuccessStatusCode(); + } + + private static int GetProxyConnectCount(int port, bool useTls, X509Certificate2 caCertificate = null) + { + using var httpClient = CreateProxyHttpClient(useTls ? caCertificate : null); + var body = httpClient.GetStringAsync(ProxyUrl(port, useTls, "metrics")).GetAwaiter().GetResult(); + + // The proxy's /metrics response starts with "connect_count " as its first line + var connectCount = body.Split('\n')[0].Split(' ')[1]; + return int.Parse(connectCount, CultureInfo.InvariantCulture); + } + + private static string ProxyUrl(int port, bool useTls, string path) => + $"{(useTls ? "https" : "http")}://127.0.0.1:{port}/{path}"; + + private static HttpClient CreateProxyHttpClient(X509Certificate2 caCertificate) + { + if (caCertificate == null) + { + return new HttpClient(); + } + + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => ValidateAgainstCa(cert, caCertificate) + }; + return new HttpClient(handler); + } + + private static X509Certificate2 LoadProxyCaCertificate() + { + var caFile = Environment.GetEnvironmentVariable("CSFLE_TLS_CA_FILE"); + if (string.IsNullOrEmpty(caFile)) + { + throw new InvalidOperationException("CSFLE_TLS_CA_FILE is not set while KMS_MOCK_SERVERS_ENABLED is; the proxy test environment is misconfigured."); + } + + if (!File.Exists(caFile)) + { + throw new FileNotFoundException($"Proxy CA file was not found at {caFile}.", caFile); + } + + var pem = File.ReadAllText(caFile); + const string header = "-----BEGIN CERTIFICATE-----"; + const string footer = "-----END CERTIFICATE-----"; + var headerIndex = pem.IndexOf(header, StringComparison.Ordinal); + var footerIndex = pem.IndexOf(footer, StringComparison.Ordinal); + if (headerIndex < 0 || footerIndex <= headerIndex) + { + throw new FormatException($"Proxy CA file {caFile} is not a valid PEM certificate."); + } + + var start = headerIndex + header.Length; + var base64 = pem.Substring(start, footerIndex - start).Replace("\r", "").Replace("\n", "").Trim(); + return LoadCertificate(Convert.FromBase64String(base64)); + } + + private static X509Certificate2 LoadCertificate(byte[] rawData) + { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadCertificate(rawData); +#else + return new X509Certificate2(rawData); +#endif + } + + private static bool ValidateAgainstCa(X509Certificate2 certificate, X509Certificate2 caCertificate) + { + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + chain.ChainPolicy.ExtraStore.Add(caCertificate); + + // Build must succeed - it validates expiry, signatures, and chain integrity. + // AllowUnknownCertificateAuthority only forgives the CA being absent from the machine trust + // store; the thumbprint check then confirms the chain terminates at our expected CA rather + // than any other root. + if (!chain.Build(certificate)) + { + return false; + } + + var elements = chain.ChainElements; + var root = elements[elements.Count - 1].Certificate; + return string.Equals(root.Thumbprint, caCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase); + } + + private sealed class HttpConnectProxyKmsConnector : IKmsConnector + { + private readonly X509Certificate2 _caCertificate; + private readonly int _proxyPort; + private readonly bool _useTls; + + public HttpConnectProxyKmsConnector(int proxyPort, bool useTls, X509Certificate2 caCertificate) + { + _proxyPort = proxyPort; + _useTls = useTls; + _caCertificate = caCertificate; + } + + public Stream Connect(KmsConnectionContext context, CancellationToken cancellationToken) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Connect("127.0.0.1", _proxyPort); + Stream stream = new NetworkStream(socket, ownsSocket: true); + try + { + if (_useTls) + { + var sslStream = CreateProxySslStream(stream); + sslStream.AuthenticateAsClient("127.0.0.1"); + stream = sslStream; + } + + var request = Encoding.ASCII.GetBytes(BuildConnectRequest(context.Host, context.Port)); + stream.Write(request, 0, request.Length); + EnsureConnectSucceeded(ReadConnectResponse(stream)); + return stream; + } + catch + { + stream.Dispose(); + throw; + } + } + + public async Task ConnectAsync(KmsConnectionContext context, CancellationToken cancellationToken) + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync("127.0.0.1", _proxyPort); + Stream stream = new NetworkStream(socket, ownsSocket: true); + try + { + if (_useTls) + { + var sslStream = CreateProxySslStream(stream); + await sslStream.AuthenticateAsClientAsync("127.0.0.1"); + stream = sslStream; + } + + var request = Encoding.ASCII.GetBytes(BuildConnectRequest(context.Host, context.Port)); + await stream.WriteAsync(request, 0, request.Length, cancellationToken); + EnsureConnectSucceeded(await ReadConnectResponseAsync(stream, cancellationToken)); + return stream; + } + catch + { +#if NETFRAMEWORK + stream.Dispose(); +#else + await stream.DisposeAsync(); +#endif + throw; + } + } + + private SslStream CreateProxySslStream(Stream inner) => + new(inner, leaveInnerStreamOpen: false, + (sender, cert, chain, errors) => ValidateAgainstCa(LoadCertificate(cert.Export(X509ContentType.Cert)), _caCertificate)); + + private static string BuildConnectRequest(string host, int port) => + $"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n"; + + private static void EnsureConnectSucceeded(string response) + { + if (!response.StartsWith("HTTP/1.1 200", StringComparison.Ordinal)) + { + throw new IOException($"Unexpected proxy CONNECT response: {response}"); + } + } + + // Reads the CONNECT response headers up to and including the terminating CRLFCRLF, + // one byte at a time so no bytes of the tunnelled TLS stream are consumed. + private static string ReadConnectResponse(Stream stream) + { + var builder = new StringBuilder(); + int b; + while ((b = stream.ReadByte()) != -1) + { + builder.Append((char)b); + if (EndsWithHeaderTerminator(builder)) + { + break; + } + } + + return builder.ToString(); + } + + private static async Task ReadConnectResponseAsync(Stream stream, CancellationToken cancellationToken) + { + var builder = new StringBuilder(); + var buffer = new byte[1]; + while (await stream.ReadAsync(buffer, 0, 1, cancellationToken) != 0) + { + builder.Append((char)buffer[0]); + if (EndsWithHeaderTerminator(builder)) + { + break; + } + } + + return builder.ToString(); + } + + private static bool EndsWithHeaderTerminator(StringBuilder builder) + { + var n = builder.Length; + return n >= 4 && + builder[n - 4] == '\r' && builder[n - 3] == '\n' && builder[n - 2] == '\r' && builder[n - 1] == '\n'; + } + } + + private sealed class FailingKmsConnector : IKmsConnector + { + private readonly string _message; + + public FailingKmsConnector(string message) + { + _message = message; + } + + public Stream Connect(KmsConnectionContext context, CancellationToken cancellationToken) => + throw new InvalidOperationException(_message); + + public Task ConnectAsync(KmsConnectionContext context, CancellationToken cancellationToken) => + throw new InvalidOperationException(_message); + } + + // Fails with a network error on its first invocation, then delegates to the wrapped connector. + // Exercises the driver's KMS retry re-invoking kmsConnectCallback after a transient failure. + private sealed class RetryOnceKmsConnector : IKmsConnector + { + private readonly IKmsConnector _inner; + private int _invocationCount; + + public RetryOnceKmsConnector(IKmsConnector inner) + { + _inner = inner; + } + + public int InvocationCount => _invocationCount; + + public Stream Connect(KmsConnectionContext context, CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _invocationCount) == 1) + { + throw new IOException("Simulated transient network error."); + } + + return _inner.Connect(context, cancellationToken); + } + + public async Task ConnectAsync(KmsConnectionContext context, CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _invocationCount) == 1) + { + throw new IOException("Simulated transient network error."); + } + + return await _inner.ConnectAsync(context, cancellationToken); + } + } } public static class ClientEncryptionOptionsReflector