This is an automated email from the ASF dual-hosted git repository. HTHou pushed a commit to branch codex/add-mtls-support in repository https://gitbox.apache.org/repos/asf/iotdb-client-csharp.git
commit ba2b3dccb3ada85a095f21d000dae0f0aa4cba52 Author: HTHou <[email protected]> AuthorDate: Fri Jun 26 14:33:56 2026 +0800 add mTLS support --- README.md | 46 +++++- README_ZH.md | 45 +++++- src/Apache.IoTDB.Data/DataReaderExtensions.cs | 16 +- .../IoTDBConnectionStringBuilder.cs | 86 +++++++++- src/Apache.IoTDB/SessionPool.Builder.cs | 28 +++- src/Apache.IoTDB/SessionPool.cs | 180 +++++++++++++++++++-- src/Apache.IoTDB/TableSessionPool.Builder.cs | 24 ++- tests/Apache.IoTDB.Tests/Apache.IoTDB.Tests.csproj | 1 + tests/Apache.IoTDB.Tests/MtlsConfigurationTests.cs | 82 ++++++++++ 9 files changed, 475 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index f395763..1c43890 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,50 @@ Users can quickly get started by referring to the use cases under the Apache-IoT For those who wish to delve deeper into the client's usage and explore more advanced features, the samples directory contains additional code samples. +## TLS and mTLS + +Enable TLS by calling `SetUseSsl(true)`. The C# client uses the .NET certificate model and does not read Java truststores directly. If your certificates were generated with the JDK 17 Java/keytool workflow, `client.keystore` is PKCS#12 by default and can be used directly as the client certificate file; use `ca.crt` directly as the trusted root. + +| keytool artifact | C# client usage | +| --- | --- | +| `ca.crt` | Pass to `SetRootCertificatePath` / `RootCertificatePath` to trust the server certificate | +| `client.keystore` | Contains the client private key and certificate chain; JDK 17 creates PKCS#12 by default, so pass it directly to `SetClientCertificatePath` | +| `client.truststore` | Java client truststore; the C# client uses `ca.crt` instead | +| `server.truststore` | Server-side truststore for trusting client certificates; not a C# client option | + +Only convert the keystore first if you are reusing an older JKS file, or if it was explicitly generated with `-storetype JKS`: + +```bash +$KT -importkeystore \ + -srckeystore client.keystore \ + -srcstorepass $PWD \ + -srcalias client \ + -destkeystore client.p12 \ + -deststoretype PKCS12 \ + -deststorepass $PWD \ + -destkeypass $PWD \ + -destalias client +``` + +C# builder example: + +```csharp +var sessionPool = new SessionPool.Builder() + .SetHost("127.0.0.1") + .SetPort(6667) + .SetUseSsl(true) + .SetRootCertificatePath("tls-certs/ca.crt") + .SetClientCertificatePath("tls-certs/client.keystore") + .SetClientCertificatePassword("IoTDB") + .Build(); +``` + +The ADO.NET connection string supports the same options: + +```text +DataSource=127.0.0.1;Port=6667;UseSsl=True;RootCertificatePath=tls-certs/ca.crt;ClientCertificatePath=tls-certs/client.keystore;ClientCertificatePassword=IoTDB +``` + ## Developer environment requirements for iotdb-client-csharp ``` @@ -101,4 +145,4 @@ dotnet format The CI pipeline will automatically check code formatting on all pull requests. Please ensure your code is properly formatted before submitting a PR. ## Publish your own client on nuget.org -You can find out how to publish from this [doc](./PUBLISH.md). \ No newline at end of file +You can find out how to publish from this [doc](./PUBLISH.md). diff --git a/README_ZH.md b/README_ZH.md index df2f500..1296c20 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -61,6 +61,49 @@ dotnet add package Apache.IoTDB 对于希望深入了解客户端用法并探索更高级特性的用户,samples目录包含了额外的代码示例。 +## TLS 和 mTLS + +通过 `SetUseSsl(true)` 开启 TLS。C# 客户端使用 .NET 的证书模型,不直接读取 Java truststore;如果证书按 JDK 17 的 Java/keytool 文档生成,`client.keystore` 默认就是 PKCS#12,可以直接作为客户端证书文件使用,并直接使用 `ca.crt` 作为信任根。 + +| keytool 产物 | C# 客户端用法 | +| --- | --- | +| `ca.crt` | 传给 `SetRootCertificatePath` / `RootCertificatePath`,用于信任服务端证书 | +| `client.keystore` | 包含客户端私钥和证书链;JDK 17 默认是 PKCS#12,直接传给 `SetClientCertificatePath` | +| `client.truststore` | Java 客户端的 truststore;C# 侧用 `ca.crt`,不需要这个文件 | +| `server.truststore` | 服务端用于信任客户端证书,不是 C# 客户端参数 | + +只有在复用旧版 JDK 生成的 JKS 文件,或显式使用 `-storetype JKS` 生成 keystore 时,才需要先转换为 PKCS#12: + +```bash +$KT -importkeystore \ + -srckeystore client.keystore \ + -srcstorepass $PWD \ + -srcalias client \ + -destkeystore client.p12 \ + -deststoretype PKCS12 \ + -deststorepass $PWD \ + -destkeypass $PWD \ + -destalias client +``` + +C# builder 示例: + +```csharp +var sessionPool = new SessionPool.Builder() + .SetHost("127.0.0.1") + .SetPort(6667) + .SetUseSsl(true) + .SetRootCertificatePath("tls-certs/ca.crt") + .SetClientCertificatePath("tls-certs/client.keystore") + .SetClientCertificatePassword("IoTDB") + .Build(); +``` + +ADO.NET 连接字符串也支持相同配置: + +```text +DataSource=127.0.0.1;Port=6667;UseSsl=True;RootCertificatePath=tls-certs/ca.crt;ClientCertificatePath=tls-certs/client.keystore;ClientCertificatePassword=IoTDB +``` ## iotdb-client-csharp的开发者环境要求 @@ -100,4 +143,4 @@ dotnet format CI 流水线会在所有 Pull Request 上自动检查代码格式。请确保在提交 PR 之前代码格式正确。 ## 在 nuget.org 上发布你自己的客户端 -你可以在这个[文档](./PUBLISH.md)中找到如何发布 \ No newline at end of file +你可以在这个[文档](./PUBLISH.md)中找到如何发布 diff --git a/src/Apache.IoTDB.Data/DataReaderExtensions.cs b/src/Apache.IoTDB.Data/DataReaderExtensions.cs index afb8eb7..ad6e489 100644 --- a/src/Apache.IoTDB.Data/DataReaderExtensions.cs +++ b/src/Apache.IoTDB.Data/DataReaderExtensions.cs @@ -32,7 +32,21 @@ namespace Apache.IoTDB.Data { public static SessionPool CreateSession(this IoTDBConnectionStringBuilder db) { - return new SessionPool(db.DataSource, db.Port, db.Username, db.Password, db.FetchSize, db.ZoneId, db.PoolSize, db.Compression, db.TimeOut); + return new SessionPool.Builder() + .SetHost(db.DataSource) + .SetPort(db.Port) + .SetUsername(db.Username) + .SetPassword(db.Password) + .SetFetchSize(db.FetchSize) + .SetZoneId(db.ZoneId) + .SetPoolSize(db.PoolSize) + .SetEnableRpcCompression(db.Compression) + .SetConnectionTimeoutInMs(db.TimeOut) + .SetUseSsl(db.UseSsl) + .SetClientCertificatePath(db.ClientCertificatePath) + .SetClientCertificatePassword(db.ClientCertificatePassword) + .SetRootCertificatePath(db.RootCertificatePath) + .Build(); } public static List<T> ToObject<T>(this IDataReader dataReader) diff --git a/src/Apache.IoTDB.Data/IoTDBConnectionStringBuilder.cs b/src/Apache.IoTDB.Data/IoTDBConnectionStringBuilder.cs index 74280c9..c6938c8 100644 --- a/src/Apache.IoTDB.Data/IoTDBConnectionStringBuilder.cs +++ b/src/Apache.IoTDB.Data/IoTDBConnectionStringBuilder.cs @@ -44,6 +44,10 @@ namespace Apache.IoTDB.Data private const string PoolSizeKeyword = "PoolSize"; private const string ZoneIdKeyword = "ZoneId"; private const string TimeOutKeyword = "TimeOut"; + private const string UseSslKeyword = "UseSsl"; + private const string ClientCertificatePathKeyword = "ClientCertificatePath"; + private const string ClientCertificatePasswordKeyword = "ClientCertificatePassword"; + private const string RootCertificatePathKeyword = "RootCertificatePath"; private enum Keywords { @@ -55,7 +59,11 @@ namespace Apache.IoTDB.Data Compression, PoolSize, ZoneId, - TimeOut + TimeOut, + UseSsl, + ClientCertificatePath, + ClientCertificatePassword, + RootCertificatePath } private static readonly IReadOnlyList<string> _validKeywords; @@ -70,10 +78,14 @@ namespace Apache.IoTDB.Data private int _port = 6667; private int _poolSize = 8; private int _timeOut = 10000; + private bool _useSsl = false; + private string _clientCertificatePath = null; + private string _clientCertificatePassword = null; + private string _rootCertificatePath = null; static IoTDBConnectionStringBuilder() { - var validKeywords = new string[9]; + var validKeywords = new string[13]; validKeywords[(int)Keywords.DataSource] = DataSourceKeyword; validKeywords[(int)Keywords.Username] = UserNameKeyword; validKeywords[(int)Keywords.Password] = PasswordKeyword; @@ -83,9 +95,13 @@ namespace Apache.IoTDB.Data validKeywords[(int)Keywords.PoolSize] = PoolSizeKeyword; validKeywords[(int)Keywords.ZoneId] = ZoneIdKeyword; validKeywords[(int)Keywords.TimeOut] = TimeOutKeyword; + validKeywords[(int)Keywords.UseSsl] = UseSslKeyword; + validKeywords[(int)Keywords.ClientCertificatePath] = ClientCertificatePathKeyword; + validKeywords[(int)Keywords.ClientCertificatePassword] = ClientCertificatePasswordKeyword; + validKeywords[(int)Keywords.RootCertificatePath] = RootCertificatePathKeyword; _validKeywords = validKeywords; - _keywords = new Dictionary<string, Keywords>(9, StringComparer.OrdinalIgnoreCase) + _keywords = new Dictionary<string, Keywords>(13, StringComparer.OrdinalIgnoreCase) { [DataSourceKeyword] = Keywords.DataSource, [UserNameKeyword] = Keywords.Username, @@ -95,7 +111,11 @@ namespace Apache.IoTDB.Data [CompressionKeyword] = Keywords.Compression, [PoolSizeKeyword] = Keywords.PoolSize, [ZoneIdKeyword] = Keywords.ZoneId, - [TimeOutKeyword] = Keywords.TimeOut + [TimeOutKeyword] = Keywords.TimeOut, + [UseSslKeyword] = Keywords.UseSsl, + [ClientCertificatePathKeyword] = Keywords.ClientCertificatePath, + [ClientCertificatePasswordKeyword] = Keywords.ClientCertificatePassword, + [RootCertificatePathKeyword] = Keywords.RootCertificatePath }; } @@ -165,7 +185,31 @@ namespace Apache.IoTDB.Data public virtual int TimeOut { get => _timeOut; - set => base[PoolSizeKeyword] = _timeOut = value; + set => base[TimeOutKeyword] = _timeOut = value; + } + + public virtual bool UseSsl + { + get => _useSsl; + set => base[UseSslKeyword] = _useSsl = value; + } + + public virtual string ClientCertificatePath + { + get => _clientCertificatePath; + set => base[ClientCertificatePathKeyword] = _clientCertificatePath = value; + } + + public virtual string ClientCertificatePassword + { + get => _clientCertificatePassword; + set => base[ClientCertificatePasswordKeyword] = _clientCertificatePassword = value; + } + + public virtual string RootCertificatePath + { + get => _rootCertificatePath; + set => base[RootCertificatePathKeyword] = _rootCertificatePath = value; } /// <summary> @@ -246,6 +290,18 @@ namespace Apache.IoTDB.Data case Keywords.TimeOut: TimeOut = Convert.ToInt32(value, CultureInfo.InvariantCulture); return; + case Keywords.UseSsl: + UseSsl = Convert.ToBoolean(value, CultureInfo.InvariantCulture); + return; + case Keywords.ClientCertificatePath: + ClientCertificatePath = Convert.ToString(value, CultureInfo.InvariantCulture); + return; + case Keywords.ClientCertificatePassword: + ClientCertificatePassword = Convert.ToString(value, CultureInfo.InvariantCulture); + return; + case Keywords.RootCertificatePath: + RootCertificatePath = Convert.ToString(value, CultureInfo.InvariantCulture); + return; default: Debug.WriteLine(false, "Unexpected keyword: " + keyword); return; @@ -376,6 +432,14 @@ namespace Apache.IoTDB.Data return ZoneId; case Keywords.TimeOut: return TimeOut; + case Keywords.UseSsl: + return UseSsl; + case Keywords.ClientCertificatePath: + return ClientCertificatePath; + case Keywords.ClientCertificatePassword: + return ClientCertificatePassword; + case Keywords.RootCertificatePath: + return RootCertificatePath; default: Debug.Assert(false, "Unexpected keyword: " + index); return null; @@ -418,6 +482,18 @@ namespace Apache.IoTDB.Data case Keywords.TimeOut: _timeOut = 10000;//10sec. return; + case Keywords.UseSsl: + _useSsl = false; + return; + case Keywords.ClientCertificatePath: + _clientCertificatePath = null; + return; + case Keywords.ClientCertificatePassword: + _clientCertificatePassword = null; + return; + case Keywords.RootCertificatePath: + _rootCertificatePath = null; + return; default: Debug.Assert(false, "Unexpected keyword: " + index); return; diff --git a/src/Apache.IoTDB/SessionPool.Builder.cs b/src/Apache.IoTDB/SessionPool.Builder.cs index 9de2874..9daf83d 100644 --- a/src/Apache.IoTDB/SessionPool.Builder.cs +++ b/src/Apache.IoTDB/SessionPool.Builder.cs @@ -35,7 +35,9 @@ public partial class SessionPool private bool _enableRpcCompression = false; private int _connectionTimeoutInMs = 500; private bool _useSsl = false; - private string _certificatePath = null; + private string _clientCertificatePath = null; + private string _clientCertificatePassword = null; + private string _rootCertificatePath = null; private string _sqlDialect = IoTDBConstant.TREE_SQL_DIALECT; private string _database = ""; private List<string> _nodeUrls = new List<string>(); @@ -100,9 +102,21 @@ public partial class SessionPool return this; } - public Builder SetCertificatePath(string certificatePath) + public Builder SetClientCertificatePath(string clientCertificatePath) { - _certificatePath = certificatePath; + _clientCertificatePath = clientCertificatePath; + return this; + } + + public Builder SetClientCertificatePassword(string clientCertificatePassword) + { + _clientCertificatePassword = clientCertificatePassword; + return this; + } + + public Builder SetRootCertificatePath(string rootCertificatePath) + { + _rootCertificatePath = rootCertificatePath; return this; } @@ -136,7 +150,9 @@ public partial class SessionPool _enableRpcCompression = false; _connectionTimeoutInMs = 500; _useSsl = false; - _certificatePath = null; + _clientCertificatePath = null; + _clientCertificatePassword = null; + _rootCertificatePath = null; _sqlDialect = IoTDBConstant.TREE_SQL_DIALECT; _database = ""; } @@ -146,9 +162,9 @@ public partial class SessionPool // if nodeUrls is not empty, use nodeUrls to create session pool if (_nodeUrls.Count > 0) { - return new SessionPool(_nodeUrls, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database); + return new SessionPool(_nodeUrls, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database); } - return new SessionPool(_host, _port, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database); + return new SessionPool(_host, _port, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database); } } } diff --git a/src/Apache.IoTDB/SessionPool.cs b/src/Apache.IoTDB/SessionPool.cs index 1c12d85..31e187b 100644 --- a/src/Apache.IoTDB/SessionPool.cs +++ b/src/Apache.IoTDB/SessionPool.cs @@ -22,7 +22,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Threading; using System.Threading.Tasks; using Apache.IoTDB.DataStructure; @@ -49,7 +51,9 @@ namespace Apache.IoTDB private readonly string _host; private readonly int _port; private readonly bool _useSsl; - private readonly string _certificatePath; + private readonly string _clientCertificatePath; + private readonly string _clientCertificatePassword; + private readonly string _rootCertificatePath; private readonly int _fetchSize; /// <summary> /// _timeout is the amount of time a Session will wait for a send operation to complete successfully. @@ -106,10 +110,11 @@ namespace Apache.IoTDB { } public SessionPool(string host, int port, string username, string password, int fetchSize, string zoneId, int poolSize, bool enableRpcCompression, int timeout) - : this(host, port, username, password, fetchSize, zoneId, poolSize, enableRpcCompression, timeout, false, null, IoTDBConstant.TREE_SQL_DIALECT, "") + : this(host, port, username, password, fetchSize, zoneId, poolSize, enableRpcCompression, timeout, false, null, null, null, IoTDBConstant.TREE_SQL_DIALECT, "") { } - protected internal SessionPool(string host, int port, string username, string password, int fetchSize, string zoneId, int poolSize, bool enableRpcCompression, int timeout, bool useSsl, string certificatePath, string sqlDialect, string database) + + protected internal SessionPool(string host, int port, string username, string password, int fetchSize, string zoneId, int poolSize, bool enableRpcCompression, int timeout, bool useSsl, string clientCertificatePath, string clientCertificatePassword, string rootCertificatePath, string sqlDialect, string database) { _host = host; _port = port; @@ -122,7 +127,9 @@ namespace Apache.IoTDB _enableRpcCompression = enableRpcCompression; _timeout = timeout; _useSsl = useSsl; - _certificatePath = certificatePath; + _clientCertificatePath = clientCertificatePath; + _clientCertificatePassword = clientCertificatePassword; + _rootCertificatePath = rootCertificatePath; _sqlDialect = sqlDialect; _database = database; } @@ -148,11 +155,12 @@ namespace Apache.IoTDB { } public SessionPool(List<string> nodeUrls, string username, string password, int fetchSize, string zoneId, int poolSize, bool enableRpcCompression, int timeout) - : this(nodeUrls, username, password, fetchSize, zoneId, poolSize, enableRpcCompression, timeout, false, null, IoTDBConstant.TREE_SQL_DIALECT, "") + : this(nodeUrls, username, password, fetchSize, zoneId, poolSize, enableRpcCompression, timeout, false, null, null, null, IoTDBConstant.TREE_SQL_DIALECT, "") { } - protected internal SessionPool(List<string> nodeUrls, string username, string password, int fetchSize, string zoneId, int poolSize, bool enableRpcCompression, int timeout, bool useSsl, string certificatePath, string sqlDialect, string database) + + protected internal SessionPool(List<string> nodeUrls, string username, string password, int fetchSize, string zoneId, int poolSize, bool enableRpcCompression, int timeout, bool useSsl, string clientCertificatePath, string clientCertificatePassword, string rootCertificatePath, string sqlDialect, string database) { if (nodeUrls.Count == 0) { @@ -169,7 +177,9 @@ namespace Apache.IoTDB _enableRpcCompression = enableRpcCompression; _timeout = timeout; _useSsl = useSsl; - _certificatePath = certificatePath; + _clientCertificatePath = clientCertificatePath; + _clientCertificatePassword = clientCertificatePassword; + _rootCertificatePath = rootCertificatePath; _sqlDialect = sqlDialect; _database = database; } @@ -277,7 +287,7 @@ namespace Apache.IoTDB { try { - _clients.Add(await CreateAndOpen(_host, _port, _enableRpcCompression, _timeout, _useSsl, _certificatePath, _sqlDialect, _database, cancellationToken)); + _clients.Add(await CreateAndOpen(_host, _port, _enableRpcCompression, _timeout, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database, cancellationToken)); } catch (Exception e) { @@ -297,7 +307,7 @@ namespace Apache.IoTDB var endPoint = _endPoints[endPointIndex]; try { - var client = await CreateAndOpen(endPoint.Ip, endPoint.Port, _enableRpcCompression, _timeout, _useSsl, _certificatePath, _sqlDialect, _database, cancellationToken); + var client = await CreateAndOpen(endPoint.Ip, endPoint.Port, _enableRpcCompression, _timeout, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database, cancellationToken); _clients.Add(client); isConnected = true; startIndex = (endPointIndex + 1) % _endPoints.Count; @@ -333,7 +343,7 @@ namespace Apache.IoTDB { try { - var client = await CreateAndOpen(_host, _port, _enableRpcCompression, _timeout, _useSsl, _certificatePath, _sqlDialect, _database, cancellationToken); + var client = await CreateAndOpen(_host, _port, _enableRpcCompression, _timeout, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database, cancellationToken); return client; } catch (Exception e) @@ -357,7 +367,7 @@ namespace Apache.IoTDB int j = (startIndex + i) % _endPoints.Count; try { - var client = await CreateAndOpen(_endPoints[j].Ip, _endPoints[j].Port, _enableRpcCompression, _timeout, _useSsl, _certificatePath, _sqlDialect, _database, cancellationToken); + var client = await CreateAndOpen(_endPoints[j].Ip, _endPoints[j].Port, _enableRpcCompression, _timeout, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database, cancellationToken); return client; } catch (Exception e) @@ -448,15 +458,19 @@ namespace Apache.IoTDB } } - private async Task<Client> CreateAndOpen(string host, int port, bool enableRpcCompression, int timeout, bool useSsl, string cert, string sqlDialect, string database, CancellationToken cancellationToken = default) + private async Task<Client> CreateAndOpen(string host, int port, bool enableRpcCompression, int timeout, bool useSsl, string clientCertificatePath, string clientCertificatePassword, string rootCertificatePath, string sqlDialect, string database, CancellationToken cancellationToken = default) { TTransport socket; if (useSsl) { + var clientCertificate = LoadClientCertificate(clientCertificatePath, clientCertificatePassword); + var rootCertificates = LoadRootCertificates(rootCertificatePath); + var remoteCertificateValidationCallback = CreateRemoteCertificateValidationCallback(rootCertificates); + var localCertificateSelectionCallback = CreateLocalCertificateSelectionCallback(clientCertificate); socket = IPAddress.TryParse(host, out var ipAddress) - ? new TTlsSocketTransport(ipAddress, port, null, cert) - : new TTlsSocketTransport(host, port, null, timeout, new X509Certificate2(cert)); + ? new TTlsSocketTransport(ipAddress, port, null, timeout, clientCertificate, remoteCertificateValidationCallback, localCertificateSelectionCallback) + : new TTlsSocketTransport(host, port, null, timeout, clientCertificate, remoteCertificateValidationCallback, localCertificateSelectionCallback); } else { @@ -524,6 +538,144 @@ namespace Apache.IoTDB } } + private static X509Certificate2 LoadClientCertificate(string clientCertificatePath, string clientCertificatePassword) + { + if (string.IsNullOrWhiteSpace(clientCertificatePath)) + { + return null; + } + + return clientCertificatePassword == null + ? new X509Certificate2(clientCertificatePath) + : new X509Certificate2(clientCertificatePath, clientCertificatePassword); + } + + private static X509Certificate2Collection LoadRootCertificates(string rootCertificatePath) + { + if (string.IsNullOrWhiteSpace(rootCertificatePath)) + { + return null; + } + + var certificateBytes = File.ReadAllBytes(rootCertificatePath); + var certificates = LoadPemCertificates(certificateBytes); + if (certificates.Count > 0) + { + return certificates; + } + + certificates.Import(certificateBytes); + return certificates; + } + + private static X509Certificate2Collection LoadPemCertificates(byte[] certificateBytes) + { + const string beginCertificate = "-----BEGIN CERTIFICATE-----"; + const string endCertificate = "-----END CERTIFICATE-----"; + + var certificates = new X509Certificate2Collection(); + var certificateText = Encoding.ASCII.GetString(certificateBytes); + var startIndex = certificateText.IndexOf(beginCertificate, StringComparison.Ordinal); + while (startIndex >= 0) + { + startIndex += beginCertificate.Length; + var endIndex = certificateText.IndexOf(endCertificate, startIndex, StringComparison.Ordinal); + if (endIndex < 0) + { + break; + } + + var base64 = certificateText.Substring(startIndex, endIndex - startIndex) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim(); + certificates.Add(new X509Certificate2(Convert.FromBase64String(base64))); + startIndex = certificateText.IndexOf(beginCertificate, endIndex + endCertificate.Length, StringComparison.Ordinal); + } + + return certificates; + } + + private static RemoteCertificateValidationCallback CreateRemoteCertificateValidationCallback(X509Certificate2Collection rootCertificates) + { + if (rootCertificates == null || rootCertificates.Count == 0) + { + return null; + } + + return (sender, certificate, chain, sslPolicyErrors) => + { + if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0 || + (sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0 || + certificate == null) + { + return false; + } + + var serverCertificate = certificate as X509Certificate2 ?? new X509Certificate2(certificate); + try + { + using var customChain = new X509Chain(); + customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + + foreach (var rootCertificate in rootCertificates) + { + customChain.ChainPolicy.ExtraStore.Add(rootCertificate); + } + + if (chain != null) + { + foreach (var chainElement in chain.ChainElements) + { + if (!string.Equals(chainElement.Certificate.Thumbprint, serverCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase)) + { + customChain.ChainPolicy.ExtraStore.Add(chainElement.Certificate); + } + } + } + + return customChain.Build(serverCertificate) && ChainEndsWithTrustedRoot(customChain, rootCertificates); + } + finally + { + if (!ReferenceEquals(serverCertificate, certificate)) + { + serverCertificate.Dispose(); + } + } + }; + } + + private static bool ChainEndsWithTrustedRoot(X509Chain chain, X509Certificate2Collection rootCertificates) + { + if (chain.ChainElements.Count == 0) + { + return false; + } + + var chainRoot = chain.ChainElements[chain.ChainElements.Count - 1].Certificate; + foreach (var rootCertificate in rootCertificates) + { + if (string.Equals(chainRoot.Thumbprint, rootCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static LocalCertificateSelectionCallback CreateLocalCertificateSelectionCallback(X509Certificate2 clientCertificate) + { + if (clientCertificate == null) + { + return null; + } + + return (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => clientCertificate; + } + public async Task<int> CreateDatabase(string dbName) { return await ExecuteClientOperationAsync<int>( diff --git a/src/Apache.IoTDB/TableSessionPool.Builder.cs b/src/Apache.IoTDB/TableSessionPool.Builder.cs index 10e24c8..45222bd 100644 --- a/src/Apache.IoTDB/TableSessionPool.Builder.cs +++ b/src/Apache.IoTDB/TableSessionPool.Builder.cs @@ -38,7 +38,9 @@ public partial class TableSessionPool private bool _enableRpcCompression = false; private int _connectionTimeoutInMs = 500; private bool _useSsl = false; - private string _certificatePath = null; + private string _clientCertificatePath = null; + private string _clientCertificatePassword = null; + private string _rootCertificatePath = null; private string _sqlDialect = IoTDBConstant.TREE_SQL_DIALECT; private string _database = ""; private List<string> _nodeUrls = new List<string>(); @@ -103,9 +105,21 @@ public partial class TableSessionPool return this; } - public Builder SetCertificatePath(string certificatePath) + public Builder SetClientCertificatePath(string clientCertificatePath) { - _certificatePath = certificatePath; + _clientCertificatePath = clientCertificatePath; + return this; + } + + public Builder SetClientCertificatePassword(string clientCertificatePassword) + { + _clientCertificatePassword = clientCertificatePassword; + return this; + } + + public Builder SetRootCertificatePath(string rootCertificatePath) + { + _rootCertificatePath = rootCertificatePath; return this; } @@ -148,11 +162,11 @@ public partial class TableSessionPool // if nodeUrls is not empty, use nodeUrls to create session pool if (_nodeUrls.Count > 0) { - sessionPool = new SessionPool(_nodeUrls, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database); + sessionPool = new SessionPool(_nodeUrls, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database); } else { - sessionPool = new SessionPool(_host, _port, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _certificatePath, _sqlDialect, _database); + sessionPool = new SessionPool(_host, _port, _username, _password, _fetchSize, _zoneId, _poolSize, _enableRpcCompression, _connectionTimeoutInMs, _useSsl, _clientCertificatePath, _clientCertificatePassword, _rootCertificatePath, _sqlDialect, _database); } return new TableSessionPool(sessionPool); } diff --git a/tests/Apache.IoTDB.Tests/Apache.IoTDB.Tests.csproj b/tests/Apache.IoTDB.Tests/Apache.IoTDB.Tests.csproj index 3148e00..e3aa858 100644 --- a/tests/Apache.IoTDB.Tests/Apache.IoTDB.Tests.csproj +++ b/tests/Apache.IoTDB.Tests/Apache.IoTDB.Tests.csproj @@ -14,6 +14,7 @@ <ItemGroup> <ProjectReference Include="..\..\src\Apache.IoTDB\Apache.IoTDB.csproj" /> + <ProjectReference Include="..\..\src\Apache.IoTDB.Data\Apache.IoTDB.Data.csproj" /> </ItemGroup> </Project> diff --git a/tests/Apache.IoTDB.Tests/MtlsConfigurationTests.cs b/tests/Apache.IoTDB.Tests/MtlsConfigurationTests.cs new file mode 100644 index 0000000..62ad8d8 --- /dev/null +++ b/tests/Apache.IoTDB.Tests/MtlsConfigurationTests.cs @@ -0,0 +1,82 @@ +/* + * 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. + */ + +using Apache.IoTDB.Data; +using NUnit.Framework; + +namespace Apache.IoTDB.Tests +{ + [TestFixture] + public class MtlsConfigurationTests + { + [Test] + public void SessionPoolBuilder_AcceptsClientCertificateConfiguration() + { + var sessionPool = new SessionPool.Builder() + .SetHost("localhost") + .SetPort(6667) + .SetUseSsl(true) + .SetClientCertificatePath("/tmp/client.pfx") + .SetClientCertificatePassword("secret") + .SetRootCertificatePath("/tmp/root-ca.pem") + .Build(); + + Assert.That(sessionPool, Is.Not.Null); + } + + [Test] + public void TableSessionPoolBuilder_AcceptsClientCertificateConfiguration() + { + var tableSessionPool = new TableSessionPool.Builder() + .SetHost("localhost") + .SetPort(6667) + .SetUseSsl(true) + .SetClientCertificatePath("/tmp/client.pfx") + .SetClientCertificatePassword("secret") + .SetRootCertificatePath("/tmp/root-ca.pem") + .Build(); + + Assert.That(tableSessionPool, Is.Not.Null); + } + + [Test] + public void ConnectionStringBuilder_ParsesMtlsConfiguration() + { + var builder = new IoTDBConnectionStringBuilder( + "DataSource=localhost;Port=6667;UseSsl=True;ClientCertificatePath=/tmp/client.pfx;ClientCertificatePassword=secret;RootCertificatePath=/tmp/root-ca.pem"); + + Assert.That(builder.UseSsl, Is.True); + Assert.That(builder.ClientCertificatePath, Is.EqualTo("/tmp/client.pfx")); + Assert.That(builder.ClientCertificatePassword, Is.EqualTo("secret")); + Assert.That(builder.RootCertificatePath, Is.EqualTo("/tmp/root-ca.pem")); + } + + [Test] + public void ConnectionStringBuilder_TimeOutSerializesWithTimeOutKeyword() + { + var builder = new IoTDBConnectionStringBuilder + { + TimeOut = 1234 + }; + + Assert.That(builder.ConnectionString, Does.Contain("TimeOut=1234")); + Assert.That(builder.ConnectionString, Does.Not.Contain("PoolSize=1234")); + } + } +}
