This is an automated email from the ASF dual-hosted git repository.

ptupitsyn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 8cab5a5337 IGNITE-18884 .NET: Add TLS support (#1752)
8cab5a5337 is described below

commit 8cab5a533721220db34626d75106fd17465806b0
Author: Pavel Tupitsyn <ptupit...@apache.org>
AuthorDate: Tue Mar 7 11:15:16 2023 +0300

    IGNITE-18884 .NET: Add TLS support (#1752)
    
    **Public API Changes**
    
    * Add `IgniteClientConfiguration.SslStreamFactory` - same API as in 2.x
      * Allow the factory to return `null` to disable SSL
    * Change `IIgniteClient.GetConnections` to return a list of 
`IConnectionInfo` (`IClusterNode` + `ISslInfo`)
    
    **Other Changes**
    
    * Fix `ClientHandlerModule` to initialize `SslContext` once on startup:
      * Improves performance (no need to initialize on every client connection)
      * Fails node startup in case of SSL misconfiguration
    * Use 4 nodes in `PlatformTestNodeRunner`: 2 without SSL, 1 with SSL, 1 
with SSL and client authentication.
---
 .../client/handler/ItSslClientHandlerTest.java     |  18 +-
 .../ignite/client/handler/ClientHandlerModule.java |   7 +-
 .../Apache.Ignite.Tests/Apache.Ignite.Tests.csproj |   9 +
 .../Apache.Ignite.Tests/ClientSocketTests.cs       |  11 +-
 .../Compute/ComputeClusterAwarenessTests.cs        |   2 +-
 .../Apache.Ignite.Tests/Compute/ComputeTests.cs    |  21 ++-
 .../dotnet/Apache.Ignite.Tests/JavaServer.cs       |   7 +
 .../dotnet/Apache.Ignite.Tests/MultiClusterTest.cs |   2 +-
 .../dotnet/Apache.Ignite.Tests/SslTests.cs         | 189 +++++++++++++++++++++
 .../dotnet/Apache.Ignite.Tests/keystore.pfx        | Bin 0 -> 4670 bytes
 .../dotnet/Apache.Ignite.Tests/truststore.pfx      | Bin 0 -> 1814 bytes
 .../dotnet/Apache.Ignite/IIgniteClient.cs          |   2 +-
 .../ConnectionContext.cs => ISslStreamFactory.cs}  |  28 +--
 .../Apache.Ignite/IgniteClientConfiguration.cs     |  10 ++
 .../Apache.Ignite/Internal/ClientFailoverSocket.cs |  10 +-
 .../dotnet/Apache.Ignite/Internal/ClientSocket.cs  |  73 ++++++--
 .../Apache.Ignite/Internal/ConnectionContext.cs    |   8 +-
 .../Apache.Ignite/Internal/IgniteClientInternal.cs |   4 +-
 .../ConnectionInfo.cs}                             |  17 +-
 .../{ConnectionContext.cs => Network/SslInfo.cs}   |  25 ++-
 .../IConnectionInfo.cs}                            |  21 ++-
 .../dotnet/Apache.Ignite/Network/ISslInfo.cs       |  58 +++++++
 .../dotnet/Apache.Ignite/SslStreamFactory.cs       | 105 ++++++++++++
 modules/platforms/dotnet/DEVNOTES.md               |   2 +
 .../runner/app/PlatformTestNodeRunner.java         |  73 +++++++-
 .../src/integrationTest/resources/ssl/client.pfx   | Bin 0 -> 2717 bytes
 .../src/integrationTest/resources/ssl/server.jks   | Bin 0 -> 2392 bytes
 .../src/integrationTest/resources/ssl/trust.jks    | Bin 0 -> 1199 bytes
 28 files changed, 609 insertions(+), 93 deletions(-)

diff --git 
a/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItSslClientHandlerTest.java
 
b/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItSslClientHandlerTest.java
index 585167418d..b341350c36 100644
--- 
a/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItSslClientHandlerTest.java
+++ 
b/modules/client-handler/src/integrationTest/java/org/apache/ignite/client/handler/ItSslClientHandlerTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.ignite.client.handler;
 
+import static 
org.apache.ignite.internal.testframework.IgniteTestUtils.assertThrowsWithCause;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
@@ -34,6 +35,7 @@ import java.security.cert.Certificate;
 import java.security.cert.CertificateException;
 import org.apache.ignite.internal.testframework.WorkDirectory;
 import org.apache.ignite.internal.testframework.WorkDirectoryExtension;
+import org.apache.ignite.lang.IgniteException;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
@@ -68,7 +70,7 @@ public class ItSslClientHandlerTest {
             throws KeyStoreException, IOException, NoSuchAlgorithmException, 
CertificateException {
         KeyStore ks = KeyStore.getInstance("PKCS12");
         ks.load(null, null);
-        ks.setKeyEntry("key", cert.key(), null, new 
Certificate[]{cert.cert()});
+        ks.setKeyEntry("key", cert.key(), "changeit".toCharArray(), new 
Certificate[]{cert.cert()});
         try (FileOutputStream fos = new FileOutputStream(keyStorePkcs12Path)) {
             ks.store(fos, "changeit".toCharArray());
         }
@@ -108,6 +110,20 @@ public class ItSslClientHandlerTest {
         assertThrows(SocketException.class, this::performAndCheckMagic);
     }
 
+    @Test
+    @DisplayName("When SSL is configured incorrectly then exception is thrown 
on start")
+    @SuppressWarnings("ThrowableNotThrown")
+    void sslMisconfigured(TestInfo testInfo) {
+        testServer = new TestServer(
+                TestSslConfig.builder()
+                        .keyStorePath(keyStorePkcs12Path)
+                        .keyStorePassword("wrong-password")
+                        .build()
+        );
+
+        assertThrowsWithCause(() -> testServer.start(testInfo), 
IgniteException.class, "keystore password was incorrect");
+    }
+
     private void performAndCheckMagic() throws IOException {
         int serverPort = serverModule.localAddress().getPort();
 
diff --git 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
index 9b20db8306..41e8f20848 100644
--- 
a/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
+++ 
b/modules/client-handler/src/main/java/org/apache/ignite/client/handler/ClientHandlerModule.java
@@ -189,6 +189,9 @@ public class ClientHandlerModule implements IgniteComponent 
{
 
         ServerBootstrap bootstrap = bootstrapFactory.createServerBootstrap();
 
+        // Initialize SslContext once on startup to avoid initialization on 
each connection, and to fail in case of incorrect config.
+        SslContext sslContext = configuration.ssl().enabled() ? 
SslContextProvider.createServerSslContext(configuration.ssl()) : null;
+
         bootstrap.childHandler(new ChannelInitializer<>() {
                     @Override
                     protected void initChannel(Channel ch) {
@@ -200,9 +203,7 @@ public class ClientHandlerModule implements IgniteComponent 
{
                             ch.pipeline().addLast(new IdleChannelHandler());
                         }
 
-                        if (configuration.ssl().enabled()) {
-                            SslContext sslContext =  
SslContextProvider.createServerSslContext(configuration.ssl());
-
+                        if (sslContext != null) {
                             ch.pipeline().addFirst("ssl", 
sslContext.newHandler(ch.alloc()));
                         }
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Apache.Ignite.Tests.csproj 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Apache.Ignite.Tests.csproj
index 688b510889..75f7449b08 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Apache.Ignite.Tests.csproj
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Apache.Ignite.Tests.csproj
@@ -39,4 +39,13 @@
       <ProjectReference Include="..\Apache.Ignite\Apache.Ignite.csproj" />
     </ItemGroup>
 
+    <ItemGroup>
+      <None Update="truststore.pfx">
+        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      </None>
+      <None Update="keystore.pfx">
+        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+      </None>
+    </ItemGroup>
+
 </Project>
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/ClientSocketTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/ClientSocketTests.cs
index 9a53221959..1d873755b8 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/ClientSocketTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/ClientSocketTests.cs
@@ -33,7 +33,7 @@ namespace Apache.Ignite.Tests
         [Test]
         public async Task TestConnectAndSendRequestReturnsResponse()
         {
-            using var socket = await ClientSocket.ConnectAsync(new 
IPEndPoint(IPAddress.Loopback, ServerPort), new(), _ => {});
+            using var socket = await ClientSocket.ConnectAsync(GetEndPoint(), 
new(), _ => {});
 
             using var requestWriter = ProtoCommon.GetMessageWriter();
             requestWriter.MessageWriter.Write("non-existent-table");
@@ -45,7 +45,7 @@ namespace Apache.Ignite.Tests
         [Test]
         public async Task 
TestConnectAndSendRequestWithInvalidOpCodeThrowsError()
         {
-            using var socket = await ClientSocket.ConnectAsync(new 
IPEndPoint(IPAddress.Loopback, ServerPort), new(), _ => {});
+            using var socket = await ClientSocket.ConnectAsync(GetEndPoint(), 
new(), _ => {});
 
             using var requestWriter = ProtoCommon.GetMessageWriter();
             requestWriter.MessageWriter.Write(123);
@@ -59,7 +59,7 @@ namespace Apache.Ignite.Tests
         [Test]
         public async Task TestDisposedSocketThrowsExceptionOnSend()
         {
-            var socket = await ClientSocket.ConnectAsync(new 
IPEndPoint(IPAddress.Loopback, ServerPort), new(), _ => {});
+            var socket = await ClientSocket.ConnectAsync(GetEndPoint(), new(), 
_ => {});
 
             socket.Dispose();
 
@@ -78,7 +78,10 @@ namespace Apache.Ignite.Tests
         [Test]
         public void TestConnectWithoutServerThrowsException()
         {
-            Assert.CatchAsync(async () => await ClientSocket.ConnectAsync(new 
IPEndPoint(IPAddress.Loopback, 569), new(), _ => {}));
+            Assert.CatchAsync(async () => await 
ClientSocket.ConnectAsync(GetEndPoint(569), new(), _ => { }));
         }
+
+        private static SocketEndpoint GetEndPoint(int? serverPort = null) =>
+            new(new(IPAddress.Loopback, serverPort ?? ServerPort), 
string.Empty);
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeClusterAwarenessTests.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeClusterAwarenessTests.cs
index 652dc89ae3..eac861de1b 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeClusterAwarenessTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeClusterAwarenessTests.cs
@@ -76,7 +76,7 @@ namespace Apache.Ignite.Tests.Compute
             Assert.IsEmpty(server2.ClientOps);
             Assert.IsEmpty(server3.ClientOps);
 
-            Assert.AreEqual(server1.Node, client.GetConnections().Single());
+            Assert.AreEqual(server1.Node, 
client.GetConnections().Single().Node);
         }
 
         [Test]
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
index 5a14dcedf7..23fff5ffd6 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
@@ -60,7 +60,7 @@ namespace Apache.Ignite.Tests.Compute
         {
             var res = (await Client.GetClusterNodesAsync()).OrderBy(x => 
x.Name).ToList();
 
-            Assert.AreEqual(2, res.Count);
+            Assert.AreEqual(4, res.Count);
 
             Assert.IsNotEmpty(res[0].Id);
             Assert.IsNotEmpty(res[1].Id);
@@ -88,7 +88,11 @@ namespace Apache.Ignite.Tests.Compute
         {
             var res = await Client.Compute.ExecuteAsync<string>(await 
Client.GetClusterNodesAsync(), NodeNameJob);
 
-            CollectionAssert.Contains(new[] { PlatformTestNodeRunner, 
PlatformTestNodeRunner + "_2" }, res);
+            var expectedNodeNames = Enumerable.Range(1, 4)
+                .Select(x => x == 1 ? PlatformTestNodeRunner : 
PlatformTestNodeRunner + "_" + x)
+                .ToList();
+
+            CollectionAssert.Contains(expectedNodeNames, res);
         }
 
         [Test]
@@ -120,11 +124,15 @@ namespace Apache.Ignite.Tests.Compute
             IDictionary<IClusterNode, Task<string>> taskMap = 
Client.Compute.BroadcastAsync<string>(nodes, NodeNameJob, "123");
             var res1 = await taskMap[nodes[0]];
             var res2 = await taskMap[nodes[1]];
+            var res3 = await taskMap[nodes[2]];
+            var res4 = await taskMap[nodes[3]];
 
-            Assert.AreEqual(2, taskMap.Count);
+            Assert.AreEqual(4, taskMap.Count);
 
             Assert.AreEqual(nodes[0].Name + "123", res1);
             Assert.AreEqual(nodes[1].Name + "123", res2);
+            Assert.AreEqual(nodes[2].Name + "123", res3);
+            Assert.AreEqual(nodes[3].Name + "123", res4);
         }
 
         [Test]
@@ -222,10 +230,11 @@ namespace Apache.Ignite.Tests.Compute
         }
 
         [Test]
-        [TestCase(1, "")]
-        [TestCase(2, "_2")]
-        [TestCase(3, "")]
+        [TestCase(1, "_4")]
         [TestCase(5, "_2")]
+        [TestCase(9, "_3")]
+        [TestCase(10, "")]
+        [TestCase(11, "_2")]
         public async Task TestExecuteColocated(long key, string nodeName)
         {
             var keyTuple = new IgniteTuple { [KeyCol] = key };
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/JavaServer.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/JavaServer.cs
index 67fd865944..16cb79de57 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/JavaServer.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/JavaServer.cs
@@ -32,6 +32,7 @@ namespace Apache.Ignite.Tests
     public sealed class JavaServer : IDisposable
     {
         private const string GradleOptsEnvVar = "IGNITE_DOTNET_GRADLE_OPTS";
+        private const string RequireExternalJavaServerEnvVar = 
"IGNITE_DOTNET_REQUIRE_EXTERNAL_SERVER";
 
         private const int DefaultClientPort = 10942;
 
@@ -67,6 +68,12 @@ namespace Apache.Ignite.Tests
                 return new JavaServer(DefaultClientPort, null);
             }
 
+            if 
(bool.TryParse(Environment.GetEnvironmentVariable(RequireExternalJavaServerEnvVar),
 out var requireExternalServer)
+                && requireExternalServer)
+            {
+                throw new InvalidOperationException($"Java server is not 
started, but {RequireExternalJavaServerEnvVar} is set to true.");
+            }
+
             Log(">>> Java server is not detected, starting...");
 
             var process = CreateProcess();
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/MultiClusterTest.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/MultiClusterTest.cs
index 42848cdce4..9478150dba 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/MultiClusterTest.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/MultiClusterTest.cs
@@ -56,7 +56,7 @@ public class MultiClusterTest
         using var client = await IgniteClient.StartAsync(new 
IgniteClientConfiguration(server1.Endpoint, server2.Endpoint));
         await client.Tables.GetTablesAsync();
 
-        var primaryServer = client.GetConnections().Single().Address.Port == 
server1.Port ? server1 : server2;
+        var primaryServer = client.GetConnections().Single().Node.Address.Port 
== server1.Port ? server1 : server2;
         var secondaryServer = primaryServer == server1 ? server2 : server1;
 
         primaryServer.Dispose();
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/SslTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/SslTests.cs
new file mode 100644
index 0000000000..335733d086
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/SslTests.cs
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+namespace Apache.Ignite.Tests;
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Net.Security;
+using System.Security.Authentication;
+using System.Threading.Tasks;
+using Log;
+using NUnit.Framework;
+
+/// <summary>
+/// SSL tests.
+/// </summary>
+public class SslTests : IgniteTestsBase
+{
+    private const string CertificatePassword = "123456";
+
+    private const string CertificateIssuer =
+        "E=d...@ignite.apache.org, OU=Apache Ignite CA, O=The Apache Software 
Foundation, CN=ignite.apache.org";
+
+    private static readonly string CertificatePath = Path.Combine(
+        TestUtils.RepoRootDir, "modules", "runner", "src", "integrationTest", 
"resources", "ssl", "client.pfx");
+
+    private static string SslEndpoint => "localhost:" + (ServerPort + 2);
+
+    private static string SslEndpointWithClientAuth => "127.0.0.1:" + 
(ServerPort + 3);
+
+    [Test]
+    [SuppressMessage("Security", "CA5398:Avoid hardcoded SslProtocols values", 
Justification = "Tests")]
+    public async Task TestSslWithoutClientAuthentication()
+    {
+        var cfg = new IgniteClientConfiguration
+        {
+            Endpoints = { SslEndpoint },
+            SslStreamFactory = new SslStreamFactory { 
SkipServerCertificateValidation = true }
+        };
+
+        using var client = await IgniteClient.StartAsync(cfg);
+
+        var connection = client.GetConnections().Single();
+        var sslInfo = connection.SslInfo;
+
+        Assert.IsNotNull(sslInfo);
+        Assert.IsFalse(sslInfo!.IsMutuallyAuthenticated);
+        Assert.AreEqual(TlsCipherSuite.TLS_AES_256_GCM_SHA384.ToString(), 
sslInfo.NegotiatedCipherSuiteName);
+        Assert.AreEqual(SslProtocols.Tls13, sslInfo.SslProtocol);
+        Assert.AreEqual("localhost", sslInfo.TargetHostName);
+        Assert.IsNull(sslInfo.LocalCertificate);
+        StringAssert.Contains(CertificateIssuer, 
sslInfo.RemoteCertificate!.Issuer);
+    }
+
+    [Test]
+    public async Task TestSslWithClientAuthentication()
+    {
+        var cfg = new IgniteClientConfiguration
+        {
+            Endpoints = { SslEndpointWithClientAuth },
+            SslStreamFactory = new SslStreamFactory
+            {
+                SkipServerCertificateValidation = true,
+                CertificatePath = CertificatePath,
+                CertificatePassword = CertificatePassword
+            },
+            Logger = new ConsoleLogger { MinLevel = LogLevel.Trace }
+        };
+
+        using var client = await IgniteClient.StartAsync(cfg);
+
+        var connection = client.GetConnections().Single();
+        var sslInfo = connection.SslInfo;
+
+        Assert.IsNotNull(sslInfo);
+        Assert.IsTrue(sslInfo!.IsMutuallyAuthenticated);
+        Assert.AreEqual(TlsCipherSuite.TLS_AES_256_GCM_SHA384.ToString(), 
sslInfo.NegotiatedCipherSuiteName);
+        Assert.AreEqual("127.0.0.1", sslInfo.TargetHostName);
+        StringAssert.Contains(CertificateIssuer, 
sslInfo.RemoteCertificate!.Issuer);
+        StringAssert.Contains(CertificateIssuer, 
sslInfo.LocalCertificate!.Issuer);
+    }
+
+    [Test]
+    [SuppressMessage("Security", "CA5398:Avoid hardcoded SslProtocols values", 
Justification = "Tests.")]
+    public void TestSslOnClientWithoutSslOnServerThrows()
+    {
+        var cfg = GetConfig();
+        cfg.SslStreamFactory = new SslStreamFactory
+        {
+            SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12
+        };
+
+        var ex = Assert.ThrowsAsync<AggregateException>(async () => await 
IgniteClient.StartAsync(cfg));
+        
Assert.IsInstanceOf<IgniteClientConnectionException>(ex?.InnerException);
+    }
+
+    [Test]
+    public void TestSslOnServerWithoutSslOnClientThrows()
+    {
+        var cfg = new IgniteClientConfiguration
+        {
+            Endpoints = { SslEndpoint }
+        };
+
+        var ex = Assert.ThrowsAsync<AggregateException>(async () => await 
IgniteClient.StartAsync(cfg));
+        
Assert.IsInstanceOf<IgniteClientConnectionException>(ex?.InnerException);
+    }
+
+    [Test]
+    public void TestMissingClientCertThrows()
+    {
+        var cfg = new IgniteClientConfiguration
+        {
+            Endpoints = { SslEndpointWithClientAuth },
+            SslStreamFactory = new SslStreamFactory
+            {
+                SkipServerCertificateValidation = true,
+                CheckCertificateRevocation = true
+            }
+        };
+
+        Assert.CatchAsync<Exception>(async () => await 
IgniteClient.StartAsync(cfg));
+    }
+
+    [Test]
+    public async Task TestCustomSslStreamFactory()
+    {
+        var cfg = new IgniteClientConfiguration
+        {
+            Endpoints = { SslEndpoint },
+            SslStreamFactory = new CustomSslStreamFactory()
+        };
+
+        using var client = await IgniteClient.StartAsync(cfg);
+
+        var connection = client.GetConnections().Single();
+        var sslInfo = connection.SslInfo;
+
+        Assert.IsNotNull(sslInfo);
+        Assert.IsFalse(sslInfo!.IsMutuallyAuthenticated);
+    }
+
+    [Test]
+    public async Task TestSslStreamFactoryReturnsNullDisablesSsl()
+    {
+        var cfg = GetConfig();
+        cfg.SslStreamFactory = new NullSslStreamFactory();
+
+        using var client = await IgniteClient.StartAsync(cfg);
+        Assert.IsNull(client.GetConnections().Single().SslInfo);
+    }
+
+    private class NullSslStreamFactory : ISslStreamFactory
+    {
+        public SslStream? Create(Stream stream, string targetHost) => null;
+    }
+
+    private class CustomSslStreamFactory : ISslStreamFactory
+    {
+        public SslStream Create(Stream stream, string targetHost)
+        {
+            var sslStream = new SslStream(
+                innerStream: stream,
+                leaveInnerStreamOpen: false,
+                userCertificateValidationCallback: (_, certificate, _, _) => 
certificate!.Issuer.Contains("ignite"),
+                userCertificateSelectionCallback: null);
+
+            sslStream.AuthenticateAsClient(targetHost, null, 
SslProtocols.None, false);
+
+            return sslStream;
+        }
+    }
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/keystore.pfx 
b/modules/platforms/dotnet/Apache.Ignite.Tests/keystore.pfx
new file mode 100644
index 0000000000..f448d29aba
Binary files /dev/null and 
b/modules/platforms/dotnet/Apache.Ignite.Tests/keystore.pfx differ
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/truststore.pfx 
b/modules/platforms/dotnet/Apache.Ignite.Tests/truststore.pfx
new file mode 100644
index 0000000000..ba842fd614
Binary files /dev/null and 
b/modules/platforms/dotnet/Apache.Ignite.Tests/truststore.pfx differ
diff --git a/modules/platforms/dotnet/Apache.Ignite/IIgniteClient.cs 
b/modules/platforms/dotnet/Apache.Ignite/IIgniteClient.cs
index 2ef60afff4..71de18fc2c 100644
--- a/modules/platforms/dotnet/Apache.Ignite/IIgniteClient.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/IIgniteClient.cs
@@ -37,6 +37,6 @@ namespace Apache.Ignite
         /// Gets active connections.
         /// </summary>
         /// <returns>A list of connected cluster nodes.</returns>
-        IList<IClusterNode> GetConnections();
+        IList<IConnectionInfo> GetConnections();
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs 
b/modules/platforms/dotnet/Apache.Ignite/ISslStreamFactory.cs
similarity index 58%
copy from modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
copy to modules/platforms/dotnet/Apache.Ignite/ISslStreamFactory.cs
index 48e0b7a194..6cd692b9ac 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/ISslStreamFactory.cs
@@ -15,17 +15,25 @@
  * limitations under the License.
  */
 
-namespace Apache.Ignite.Internal
-{
-    using System;
-    using Ignite.Network;
+namespace Apache.Ignite;
+
+using System.IO;
+using System.Net.Security;
 
+/// <summary>
+/// SSL Stream Factory defines how SSL connection is established.
+/// <para />
+/// See <see cref="IgniteClientConfiguration.SslStreamFactory"/>, <see 
cref="SslStreamFactory"/>.
+/// </summary>
+public interface ISslStreamFactory
+{
     /// <summary>
-    /// Socket connection context.
+    /// Creates the SSL stream.
     /// </summary>
-    /// <param name="Version">Protocol version.</param>
-    /// <param name="IdleTimeout">Server idle timeout.</param>
-    /// <param name="ClusterNode">Cluster node.</param>
-    /// <param name="ClusterId">Cluster id.</param>
-    internal record ConnectionContext(ClientProtocolVersion Version, TimeSpan 
IdleTimeout, IClusterNode ClusterNode, Guid ClusterId);
+    /// <param name="stream">The underlying raw stream.</param>
+    /// <param name="targetHost">Target host.</param>
+    /// <returns>
+    /// SSL stream, or null if SSL is not enabled.
+    /// </returns>
+    SslStream? Create(Stream stream, string targetHost);
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/IgniteClientConfiguration.cs 
b/modules/platforms/dotnet/Apache.Ignite/IgniteClientConfiguration.cs
index e428be881e..6ecfe08edc 100644
--- a/modules/platforms/dotnet/Apache.Ignite/IgniteClientConfiguration.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/IgniteClientConfiguration.cs
@@ -86,6 +86,7 @@ namespace Apache.Ignite
             RetryPolicy = other.RetryPolicy;
             HeartbeatInterval = other.HeartbeatInterval;
             ReconnectInterval = other.ReconnectInterval;
+            SslStreamFactory = other.SslStreamFactory;
         }
 
         /// <summary>
@@ -159,5 +160,14 @@ namespace Apache.Ignite
         /// </summary>
         [DefaultValue(typeof(TimeSpan), "00:00:30")]
         public TimeSpan ReconnectInterval { get; set; } = 
DefaultReconnectInterval;
+
+        /// <summary>
+        /// Gets or sets the SSL stream factory.
+        /// <para />
+        /// When not null, secure socket connection will be established.
+        /// <para />
+        /// See <see cref="SslStreamFactory"/>.
+        /// </summary>
+        public ISslStreamFactory? SslStreamFactory { get; set; }
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs
index b2ad854c86..356aed71a5 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs
@@ -29,7 +29,9 @@ namespace Apache.Ignite.Internal
     using System.Threading;
     using System.Threading.Tasks;
     using Buffers;
+    using Ignite.Network;
     using Log;
+    using Network;
     using Proto;
     using Transactions;
 
@@ -226,15 +228,15 @@ namespace Apache.Ignite.Internal
         /// Gets active connections.
         /// </summary>
         /// <returns>Active connections.</returns>
-        public IEnumerable<ConnectionContext> GetConnections()
+        public IList<IConnectionInfo> GetConnections()
         {
-            var res = new List<ConnectionContext>(_endpoints.Count);
+            var res = new List<IConnectionInfo>(_endpoints.Count);
 
             foreach (var endpoint in _endpoints)
             {
                 if (endpoint.Socket is { IsDisposed: false, ConnectionContext: 
{ } ctx })
                 {
-                    res.Add(ctx);
+                    res.Add(new ConnectionInfo(ctx.ClusterNode, ctx.SslInfo));
                 }
             }
 
@@ -409,7 +411,7 @@ namespace Apache.Ignite.Internal
 
             try
             {
-                var socket = await 
ClientSocket.ConnectAsync(endpoint.EndPoint, Configuration, 
OnAssignmentChanged).ConfigureAwait(false);
+                var socket = await ClientSocket.ConnectAsync(endpoint, 
Configuration, OnAssignmentChanged).ConfigureAwait(false);
 
                 if (_clusterId == null)
                 {
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
index 2073291d04..66f9b09e19 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
@@ -22,12 +22,15 @@ namespace Apache.Ignite.Internal
     using System.Collections.Concurrent;
     using System.Diagnostics;
     using System.Diagnostics.CodeAnalysis;
+    using System.IO;
     using System.Linq;
     using System.Net;
+    using System.Net.Security;
     using System.Net.Sockets;
     using System.Threading;
     using System.Threading.Tasks;
     using Buffers;
+    using Ignite.Network;
     using Log;
     using Network;
     using Proto;
@@ -55,7 +58,7 @@ namespace Apache.Ignite.Internal
         private static long _socketId;
 
         /** Underlying stream. */
-        private readonly NetworkStream _stream;
+        private readonly Stream _stream;
 
         /** Current async operations, map from request id. */
         private readonly ConcurrentDictionary<long, 
TaskCompletionSource<PooledBuffer>> _requests = new();
@@ -107,7 +110,7 @@ namespace Apache.Ignite.Internal
         /// <param name="assignmentChangeCallback">Partition assignment change 
callback.</param>
         /// <param name="logger">Logger.</param>
         private ClientSocket(
-            NetworkStream stream,
+            Stream stream,
             IgniteClientConfiguration configuration,
             ConnectionContext connectionContext,
             Action<ClientSocket> assignmentChangeCallback,
@@ -155,7 +158,7 @@ namespace Apache.Ignite.Internal
             "CA2000:Dispose objects before losing scope",
             Justification = "NetworkStream is returned from this method in the 
socket.")]
         public static async Task<ClientSocket> ConnectAsync(
-            IPEndPoint endPoint,
+            SocketEndpoint endPoint,
             IgniteClientConfiguration configuration,
             Action<ClientSocket> assignmentChangeCallback)
         {
@@ -168,16 +171,35 @@ namespace Apache.Ignite.Internal
 
             try
             {
-                await socket.ConnectAsync(endPoint).ConfigureAwait(false);
-                logger?.Debug($"Socket connection established: 
{socket.LocalEndPoint} -> {socket.RemoteEndPoint}, starting handshake...");
+                await 
socket.ConnectAsync(endPoint.EndPoint).ConfigureAwait(false);
 
-                var stream = new NetworkStream(socket, ownsSocket: true);
+                if (logger?.IsEnabled(LogLevel.Debug) == true)
+                {
+                    logger.Debug(
+                        $"Socket connection established: 
{socket.LocalEndPoint} -> {socket.RemoteEndPoint}, starting handshake...");
+                }
+
+                Stream stream = new NetworkStream(socket, ownsSocket: true);
+
+                if (configuration.SslStreamFactory is { } sslStreamFactory &&
+                    sslStreamFactory.Create(stream, endPoint.Host) is { } 
sslStream)
+                {
+                    stream = sslStream;
+
+                    if (logger?.IsEnabled(LogLevel.Debug) == true)
+                    {
+                        logger.Debug($"SSL connection established: 
{sslStream.NegotiatedCipherSuite}");
+                    }
+                }
 
-                var context = await HandshakeAsync(stream, endPoint)
+                var context = await HandshakeAsync(stream, endPoint.EndPoint)
                     .WaitAsync(configuration.SocketTimeout)
                     .ConfigureAwait(false);
 
-                logger?.Debug($"Handshake succeeded: {context}.");
+                if (logger?.IsEnabled(LogLevel.Debug) == true)
+                {
+                    logger.Debug($"Handshake succeeded: {context}.");
+                }
 
                 return new ClientSocket(stream, configuration, context, 
assignmentChangeCallback, logger);
             }
@@ -188,7 +210,10 @@ namespace Apache.Ignite.Internal
                 // ReSharper disable once MethodHasAsyncOverload
                 socket.Dispose();
 
-                throw new 
IgniteClientConnectionException(ErrorGroups.Client.Connection, "Failed to 
connect to endpoint: " + endPoint, e);
+                throw new IgniteClientConnectionException(
+                    ErrorGroups.Client.Connection,
+                    "Failed to connect to endpoint: " + endPoint.EndPoint,
+                    e);
             }
         }
 
@@ -261,7 +286,7 @@ namespace Apache.Ignite.Internal
         /// </summary>
         /// <param name="stream">Network stream.</param>
         /// <param name="endPoint">Endpoint.</param>
-        private static async Task<ConnectionContext> 
HandshakeAsync(NetworkStream stream, IPEndPoint endPoint)
+        private static async Task<ConnectionContext> HandshakeAsync(Stream 
stream, IPEndPoint endPoint)
         {
             await 
stream.WriteAsync(ProtoCommon.MagicBytes).ConfigureAwait(false);
             await WriteHandshakeAsync(stream, 
CurrentProtocolVersion).ConfigureAwait(false);
@@ -271,10 +296,10 @@ namespace Apache.Ignite.Internal
             await CheckMagicBytesAsync(stream).ConfigureAwait(false);
 
             using var response = await ReadResponseAsync(stream, new byte[4], 
CancellationToken.None).ConfigureAwait(false);
-            return ReadHandshakeResponse(response.GetReader(), endPoint);
+            return ReadHandshakeResponse(response.GetReader(), endPoint, 
GetSslInfo(stream));
         }
 
-        private static async ValueTask CheckMagicBytesAsync(NetworkStream 
stream)
+        private static async ValueTask CheckMagicBytesAsync(Stream stream)
         {
             var responseMagic = 
ByteArrayPool.Rent(ProtoCommon.MagicBytes.Length);
 
@@ -298,7 +323,7 @@ namespace Apache.Ignite.Internal
             }
         }
 
-        private static ConnectionContext ReadHandshakeResponse(MsgPackReader 
reader, IPEndPoint endPoint)
+        private static ConnectionContext ReadHandshakeResponse(MsgPackReader 
reader, IPEndPoint endPoint, ISslInfo? sslInfo)
         {
             var serverVer = new ClientProtocolVersion(reader.ReadInt16(), 
reader.ReadInt16(), reader.ReadInt16());
 
@@ -326,7 +351,8 @@ namespace Apache.Ignite.Internal
                 serverVer,
                 TimeSpan.FromMilliseconds(idleTimeoutMs),
                 new ClusterNode(clusterNodeId, clusterNodeName, endPoint),
-                clusterId);
+                clusterId,
+                sslInfo);
         }
 
         private static IgniteException? ReadError(ref MsgPackReader reader)
@@ -346,7 +372,7 @@ namespace Apache.Ignite.Internal
         }
 
         private static async ValueTask<PooledBuffer> ReadResponseAsync(
-            NetworkStream stream,
+            Stream stream,
             byte[] messageSizeBytes,
             CancellationToken cancellationToken)
         {
@@ -369,7 +395,7 @@ namespace Apache.Ignite.Internal
         }
 
         private static async Task<int> ReadMessageSizeAsync(
-            NetworkStream stream,
+            Stream stream,
             byte[] buffer,
             CancellationToken cancellationToken)
         {
@@ -382,7 +408,7 @@ namespace Apache.Ignite.Internal
         }
 
         private static async Task ReceiveBytesAsync(
-            NetworkStream stream,
+            Stream stream,
             byte[] buffer,
             int size,
             CancellationToken cancellationToken)
@@ -406,7 +432,7 @@ namespace Apache.Ignite.Internal
             }
         }
 
-        private static async ValueTask WriteHandshakeAsync(NetworkStream 
stream, ClientProtocolVersion version)
+        private static async ValueTask WriteHandshakeAsync(Stream stream, 
ClientProtocolVersion version)
         {
             using var bufferWriter = new PooledArrayBuffer(prefixSize: 
ProtoCommon.MessagePrefixSize);
             WriteHandshake(version, bufferWriter.MessageWriter);
@@ -483,6 +509,17 @@ namespace Apache.Ignite.Internal
             return recommendedHeartbeatInterval;
         }
 
+        private static ISslInfo? GetSslInfo(Stream stream) =>
+            stream is SslStream sslStream
+                ? new SslInfo(
+                    sslStream.TargetHostName,
+                    sslStream.NegotiatedCipherSuite.ToString(),
+                    sslStream.IsMutuallyAuthenticated,
+                    sslStream.LocalCertificate,
+                    sslStream.RemoteCertificate,
+                    sslStream.SslProtocol)
+                : null;
+
         private async ValueTask SendRequestAsync(PooledArrayBuffer? request, 
ClientOp op, long requestId)
         {
             // Reset heartbeat timer - don't sent heartbeats when connection 
is active anyway.
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
index 48e0b7a194..c785dacb77 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
@@ -27,5 +27,11 @@ namespace Apache.Ignite.Internal
     /// <param name="IdleTimeout">Server idle timeout.</param>
     /// <param name="ClusterNode">Cluster node.</param>
     /// <param name="ClusterId">Cluster id.</param>
-    internal record ConnectionContext(ClientProtocolVersion Version, TimeSpan 
IdleTimeout, IClusterNode ClusterNode, Guid ClusterId);
+    /// <param name="SslInfo">SSL info.</param>
+    internal record ConnectionContext(
+        ClientProtocolVersion Version,
+        TimeSpan IdleTimeout,
+        IClusterNode ClusterNode,
+        Guid ClusterId,
+        ISslInfo? SslInfo);
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/IgniteClientInternal.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/IgniteClientInternal.cs
index 896b2bda84..d71548cf61 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/IgniteClientInternal.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/IgniteClientInternal.cs
@@ -18,7 +18,6 @@
 namespace Apache.Ignite.Internal
 {
     using System.Collections.Generic;
-    using System.Linq;
     using System.Net;
     using System.Threading.Tasks;
     using Ignite.Compute;
@@ -97,8 +96,7 @@ namespace Apache.Ignite.Internal
         }
 
         /// <inheritdoc/>
-        public IList<IClusterNode> GetConnections() =>
-            _socket.GetConnections().Select(ctx => ctx.ClusterNode).ToList();
+        public IList<IConnectionInfo> GetConnections() => 
_socket.GetConnections();
 
         /// <inheritdoc/>
         public void Dispose()
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Network/ConnectionInfo.cs
similarity index 60%
copy from modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
copy to 
modules/platforms/dotnet/Apache.Ignite/Internal/Network/ConnectionInfo.cs
index 48e0b7a194..ec41b99d6d 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Network/ConnectionInfo.cs
@@ -15,17 +15,8 @@
  * limitations under the License.
  */
 
-namespace Apache.Ignite.Internal
-{
-    using System;
-    using Ignite.Network;
+namespace Apache.Ignite.Internal.Network;
 
-    /// <summary>
-    /// Socket connection context.
-    /// </summary>
-    /// <param name="Version">Protocol version.</param>
-    /// <param name="IdleTimeout">Server idle timeout.</param>
-    /// <param name="ClusterNode">Cluster node.</param>
-    /// <param name="ClusterId">Cluster id.</param>
-    internal record ConnectionContext(ClientProtocolVersion Version, TimeSpan 
IdleTimeout, IClusterNode ClusterNode, Guid ClusterId);
-}
+using Ignite.Network;
+
+internal sealed record ConnectionInfo(IClusterNode Node, ISslInfo? SslInfo) : 
IConnectionInfo;
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Network/SslInfo.cs
similarity index 60%
copy from modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
copy to modules/platforms/dotnet/Apache.Ignite/Internal/Network/SslInfo.cs
index 48e0b7a194..0ff0f8e489 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Network/SslInfo.cs
@@ -15,17 +15,16 @@
  * limitations under the License.
  */
 
-namespace Apache.Ignite.Internal
-{
-    using System;
-    using Ignite.Network;
+namespace Apache.Ignite.Internal.Network;
 
-    /// <summary>
-    /// Socket connection context.
-    /// </summary>
-    /// <param name="Version">Protocol version.</param>
-    /// <param name="IdleTimeout">Server idle timeout.</param>
-    /// <param name="ClusterNode">Cluster node.</param>
-    /// <param name="ClusterId">Cluster id.</param>
-    internal record ConnectionContext(ClientProtocolVersion Version, TimeSpan 
IdleTimeout, IClusterNode ClusterNode, Guid ClusterId);
-}
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using Ignite.Network;
+
+internal sealed record SslInfo(
+    string TargetHostName,
+    string NegotiatedCipherSuiteName,
+    bool IsMutuallyAuthenticated,
+    X509Certificate? LocalCertificate,
+    X509Certificate? RemoteCertificate,
+    SslProtocols SslProtocol) : ISslInfo;
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs 
b/modules/platforms/dotnet/Apache.Ignite/Network/IConnectionInfo.cs
similarity index 64%
copy from modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
copy to modules/platforms/dotnet/Apache.Ignite/Network/IConnectionInfo.cs
index 48e0b7a194..2025f43bb4 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Network/IConnectionInfo.cs
@@ -15,17 +15,20 @@
  * limitations under the License.
  */
 
-namespace Apache.Ignite.Internal
+namespace Apache.Ignite.Network;
+
+/// <summary>
+/// Connection info.
+/// </summary>
+public interface IConnectionInfo
 {
-    using System;
-    using Ignite.Network;
+    /// <summary>
+    /// Gets the target node.
+    /// </summary>
+    IClusterNode Node { get; }
 
     /// <summary>
-    /// Socket connection context.
+    /// Gets the SSL info, if SSL is enabled.
     /// </summary>
-    /// <param name="Version">Protocol version.</param>
-    /// <param name="IdleTimeout">Server idle timeout.</param>
-    /// <param name="ClusterNode">Cluster node.</param>
-    /// <param name="ClusterId">Cluster id.</param>
-    internal record ConnectionContext(ClientProtocolVersion Version, TimeSpan 
IdleTimeout, IClusterNode ClusterNode, Guid ClusterId);
+    ISslInfo? SslInfo { get; }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Network/ISslInfo.cs 
b/modules/platforms/dotnet/Apache.Ignite/Network/ISslInfo.cs
new file mode 100644
index 0000000000..65e49a9d9a
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Network/ISslInfo.cs
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+namespace Apache.Ignite.Network;
+
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+
+/// <summary>
+/// SSL info.
+/// </summary>
+public interface ISslInfo
+{
+    /// <summary>
+    /// Gets the cipher suite which was negotiated for this connection.
+    /// </summary>
+    string NegotiatedCipherSuiteName { get; }
+
+    /// <summary>
+    /// Gets the certificate used to authenticate the local endpoint.
+    /// </summary>
+    X509Certificate? LocalCertificate { get; }
+
+    /// <summary>
+    /// Gets the certificate used to authenticate the remote endpoint.
+    /// </summary>
+    X509Certificate? RemoteCertificate { get; }
+
+    /// <summary>
+    /// Gets the name of the server the client is trying to connect to. That 
name is used for server certificate validation.
+    /// It can be a DNS name or an IP address.
+    /// </summary>
+    string TargetHostName { get; }
+
+    /// <summary>
+    /// Gets a value indicating whether both server and client have been 
authenticated.
+    /// </summary>
+    bool IsMutuallyAuthenticated { get; }
+
+    /// <summary>
+    /// Gets the SSL protocol.
+    /// </summary>
+    SslProtocols SslProtocol { get; }
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/SslStreamFactory.cs 
b/modules/platforms/dotnet/Apache.Ignite/SslStreamFactory.cs
new file mode 100644
index 0000000000..02f552260d
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/SslStreamFactory.cs
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+namespace Apache.Ignite;
+
+using System.ComponentModel;
+using System.IO;
+using System.Net.Security;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using Internal.Common;
+
+/// <summary>
+/// Standard SSL stream factory. Can be used with or without client-side 
certificates.
+/// </summary>
+public sealed class SslStreamFactory : ISslStreamFactory
+{
+    /// <summary>
+    /// Default SSL protocols.
+    /// </summary>
+    public const SslProtocols DefaultSslProtocols = SslProtocols.None;
+
+    /// <summary>
+    /// Gets or sets the certificate file path (see <see 
cref="X509Certificate2"/>).
+    /// </summary>
+    public string? CertificatePath { get; set; }
+
+    /// <summary>
+    /// Gets or sets the certificate file password (see <see 
cref="X509Certificate2"/>).
+    /// </summary>
+    public string? CertificatePassword { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether to ignore invalid remote 
(server) certificates.
+    /// This may be useful for testing with self-signed certificates.
+    /// </summary>
+    public bool SkipServerCertificateValidation { get; set; }
+
+    /// <summary>
+    /// Gets or sets a value indicating whether the certificate revocation 
list is checked during authentication.
+    /// </summary>
+    public bool CheckCertificateRevocation { get; set; }
+
+    /// <summary>
+    /// Gets or sets the SSL protocols.
+    /// </summary>
+    [DefaultValue(DefaultSslProtocols)]
+    public SslProtocols SslProtocols { get; set; } = DefaultSslProtocols;
+
+    /// <inheritdoc />
+    public SslStream Create(Stream stream, string targetHost)
+    {
+        IgniteArgumentCheck.NotNull(stream, "stream");
+
+        var sslStream = new SslStream(stream, false, 
ValidateServerCertificate, null);
+
+        var cert = string.IsNullOrEmpty(CertificatePath)
+            ? null
+            : new X509Certificate2(CertificatePath, CertificatePassword);
+
+        var certs = cert == null
+            ? null
+            : new X509CertificateCollection(new X509Certificate[] { cert });
+
+        sslStream.AuthenticateAsClient(targetHost, certs, SslProtocols, 
CheckCertificateRevocation);
+
+        return sslStream;
+    }
+
+    /// <summary>
+    /// Validates the server certificate.
+    /// </summary>
+    private bool ValidateServerCertificate(
+        object sender,
+        X509Certificate? certificate,
+        X509Chain? chain,
+        SslPolicyErrors sslPolicyErrors)
+    {
+        if (SkipServerCertificateValidation)
+        {
+            return true;
+        }
+
+        if (sslPolicyErrors == SslPolicyErrors.None)
+        {
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/modules/platforms/dotnet/DEVNOTES.md 
b/modules/platforms/dotnet/DEVNOTES.md
index ddd5d9a7b6..61d55c75b5 100644
--- a/modules/platforms/dotnet/DEVNOTES.md
+++ b/modules/platforms/dotnet/DEVNOTES.md
@@ -26,6 +26,8 @@ then run .NET tests with `dotnet test` or `dotnet test 
--filter TEST_NAME`. When
 The test node will stop after 30 minutes by default.
 To change this, set `IGNITE_PLATFORM_TEST_NODE_RUNNER_RUN_TIME_MINUTES` 
environment variable.
 
+To ensure that external test node is used, and a new one is never started by 
.NET test code, set `IGNITE_DOTNET_REQUIRE_EXTERNAL_SERVER` to `true`.
+
 ## Static Code Analysis
 
 Static code analysis (Roslyn-based) runs as part of the build and includes 
code style check. Build fails on any warning.
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/runner/app/PlatformTestNodeRunner.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/runner/app/PlatformTestNodeRunner.java
index 808048da9a..4e225550c7 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/runner/app/PlatformTestNodeRunner.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/runner/app/PlatformTestNodeRunner.java
@@ -46,6 +46,7 @@ import 
org.apache.ignite.internal.schema.testutils.builder.SchemaBuilders;
 import org.apache.ignite.internal.schema.testutils.definition.ColumnType;
 import 
org.apache.ignite.internal.schema.testutils.definition.ColumnType.TemporalColumnType;
 import org.apache.ignite.internal.schema.testutils.definition.TableDefinition;
+import org.apache.ignite.internal.ssl.ItSslTest;
 import org.apache.ignite.internal.table.distributed.TableManager;
 import org.apache.ignite.internal.table.impl.DummySchemaManagerImpl;
 import org.apache.ignite.internal.util.IgniteUtils;
@@ -63,6 +64,12 @@ public class PlatformTestNodeRunner {
     /** Test node name 2. */
     private static final String NODE_NAME2 = 
PlatformTestNodeRunner.class.getCanonicalName() + "_2";
 
+    /** Test node name 3. */
+    private static final String NODE_NAME3 = 
PlatformTestNodeRunner.class.getCanonicalName() + "_3";
+
+    /** Test node name 4. */
+    private static final String NODE_NAME4 = 
PlatformTestNodeRunner.class.getCanonicalName() + "_4";
+
     private static final String SCHEMA_NAME = "PUBLIC";
 
     private static final String TABLE_NAME = "TBL1";
@@ -80,23 +87,72 @@ public class PlatformTestNodeRunner {
     /** Nodes bootstrap configuration. */
     private static final Map<String, String> nodesBootstrapCfg = Map.of(
             NODE_NAME, "{\n"
-                    + "  \"clientConnector\":{\"port\": 
10942,\"portRange\":10,\"idleTimeout\":3000,\""
+                    + "  \"clientConnector\":{\"port\": 
10942,\"portRange\":1,\"idleTimeout\":3000,\""
                     + "sendServerExceptionStackTraceToClient\":true},"
                     + "  \"network\": {\n"
                     + "    \"port\":3344,\n"
                     + "    \"nodeFinder\": {\n"
-                    + "      \"netClusterNodes\":[ \"localhost:3344\", 
\"localhost:3345\" ]\n"
+                    + "      \"netClusterNodes\":[ \"localhost:3344\", 
\"localhost:3345\", \"localhost:3346\", \"localhost:3347\" ]\n"
                     + "    }\n"
                     + "  }\n"
                     + "}",
 
             NODE_NAME2, "{\n"
-                    + "  \"clientConnector\":{\"port\": 
10942,\"portRange\":10,\"idleTimeout\":3000,"
+                    + "  \"clientConnector\":{\"port\": 
10943,\"portRange\":1,\"idleTimeout\":3000,"
                     + "\"sendServerExceptionStackTraceToClient\":true},"
                     + "  \"network\": {\n"
                     + "    \"port\":3345,\n"
                     + "    \"nodeFinder\": {\n"
-                    + "      \"netClusterNodes\":[ \"localhost:3344\", 
\"localhost:3345\" ]\n"
+                    + "      \"netClusterNodes\":[ \"localhost:3344\", 
\"localhost:3345\", \"localhost:3346\", \"localhost:3347\" ]\n"
+                    + "    }\n"
+                    + "  }\n"
+                    + "}",
+
+            NODE_NAME3, "{\n"
+                    + "  \"clientConnector\":{"
+                    + "    \"port\": 10944,"
+                    + "    \"portRange\":1,"
+                    + "    \"idleTimeout\":3000,"
+                    + "    \"sendServerExceptionStackTraceToClient\":true, "
+                    + "    \"ssl\": {\n"
+                    + "      enabled: true,\n"
+                    + "      keyStore: {\n"
+                    + "        path: \"KEYSTORE_PATH\",\n"
+                    + "        password: \"SSL_STORE_PASS\"\n"
+                    + "      }\n"
+                    + "    }\n"
+                    + "  },\n"
+                    + "  \"network\": {\n"
+                    + "    \"port\":3346,\n"
+                    + "    \"nodeFinder\": {\n"
+                    + "      \"netClusterNodes\":[ \"localhost:3344\", 
\"localhost:3345\", \"localhost:3346\", \"localhost:3347\" ]\n"
+                    + "    }\n"
+                    + "  }\n"
+                    + "}",
+
+            NODE_NAME4, "{\n"
+                    + "  \"clientConnector\":{"
+                    + "    \"port\": 10945,"
+                    + "    \"portRange\":1,"
+                    + "    \"idleTimeout\":3000,"
+                    + "    \"sendServerExceptionStackTraceToClient\":true, "
+                    + "    \"ssl\": {\n"
+                    + "      enabled: true,\n"
+                    + "      clientAuth: \"require\",\n"
+                    + "      keyStore: {\n"
+                    + "        path: \"KEYSTORE_PATH\",\n"
+                    + "        password: \"SSL_STORE_PASS\"\n"
+                    + "      },\n"
+                    + "      trustStore: {\n"
+                    + "        path: \"TRUSTSTORE_PATH\",\n"
+                    + "        password: \"SSL_STORE_PASS\"\n"
+                    + "      }\n"
+                    + "    }\n"
+                    + "  },\n"
+                    + "  \"network\": {\n"
+                    + "    \"port\":3347,\n"
+                    + "    \"nodeFinder\": {\n"
+                    + "      \"netClusterNodes\":[ \"localhost:3344\", 
\"localhost:3345\", \"localhost:3346\", \"localhost:3347\" ]\n"
                     + "    }\n"
                     + "  }\n"
                     + "}"
@@ -125,10 +181,17 @@ public class PlatformTestNodeRunner {
         IgniteUtils.deleteIfExists(BASE_PATH);
         Files.createDirectories(BASE_PATH);
 
+        var sslPassword = "123456";
+        var trustStorePath = 
ItSslTest.class.getClassLoader().getResource("ssl/trust.jks").getPath();
+        var keyStorePath = 
ItSslTest.class.getClassLoader().getResource("ssl/server.jks").getPath();
+
         List<CompletableFuture<Ignite>> igniteFutures = 
nodesBootstrapCfg.entrySet().stream()
                 .map(e -> {
                     String nodeName = e.getKey();
-                    String config = e.getValue();
+                    String config = e.getValue()
+                            .replace("KEYSTORE_PATH", keyStorePath)
+                            .replace("TRUSTSTORE_PATH", trustStorePath)
+                            .replace("SSL_STORE_PASS", sslPassword);
 
                     return IgnitionManager.start(nodeName, config, 
BASE_PATH.resolve(nodeName));
                 })
diff --git a/modules/runner/src/integrationTest/resources/ssl/client.pfx 
b/modules/runner/src/integrationTest/resources/ssl/client.pfx
new file mode 100644
index 0000000000..29efdce5a4
Binary files /dev/null and 
b/modules/runner/src/integrationTest/resources/ssl/client.pfx differ
diff --git a/modules/runner/src/integrationTest/resources/ssl/server.jks 
b/modules/runner/src/integrationTest/resources/ssl/server.jks
new file mode 100644
index 0000000000..7007256253
Binary files /dev/null and 
b/modules/runner/src/integrationTest/resources/ssl/server.jks differ
diff --git a/modules/runner/src/integrationTest/resources/ssl/trust.jks 
b/modules/runner/src/integrationTest/resources/ssl/trust.jks
new file mode 100644
index 0000000000..e363af2eb8
Binary files /dev/null and 
b/modules/runner/src/integrationTest/resources/ssl/trust.jks differ

Reply via email to