diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml
index c0c57f739..b3b6facd7 100644
--- a/.github/workflows/base.yml
+++ b/.github/workflows/base.yml
@@ -45,7 +45,7 @@ jobs:
env:
ES_DOCKER_TAG: ${{ inputs.docker-tag }}
run: |
- ./gencert.sh
+ sudo ./gencert.sh
dotnet test --configuration ${{ matrix.configuration }} --blame \
--logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" \
--framework ${{ matrix.framework }} \
diff --git a/gencert.ps1 b/gencert.ps1
new file mode 100644
index 000000000..3908f57e8
--- /dev/null
+++ b/gencert.ps1
@@ -0,0 +1,21 @@
+Write-Host ">> Generating certificate..."
+
+# Create directory if it doesn't exist
+New-Item -ItemType Directory -Path .\certs -Force
+
+# Set permissions for the directory
+icacls .\certs /grant:r "$($env:UserName):(OI)(CI)RX"
+
+# Pull the Docker image
+docker pull eventstore/es-gencert-cli:1.0.2
+
+# Create CA certificate
+docker run --rm --volume ${PWD}\certs:/tmp --user (Get-Process -Id $PID).SessionId eventstore/es-gencert-cli:1.0.2 create-ca -out /tmp/ca
+
+# Create node certificate
+docker run --rm --volume ${PWD}\certs:/tmp --user (Get-Process -Id $PID).SessionId eventstore/es-gencert-cli:1.0.2 create-node -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/node -ip-addresses 127.0.0.1 -dns-names localhost
+
+# Set permissions recursively for the directory
+icacls .\certs /grant:r "$($env:UserName):(OI)(CI)RX"
+
+Import-Certificate -FilePath ".\certs\ca\ca.crt" -CertStoreLocation Cert:\CurrentUser\Root
diff --git a/gencert.sh b/gencert.sh
index 87ca02961..fa640f624 100755
--- a/gencert.sh
+++ b/gencert.sh
@@ -1,5 +1,14 @@
#!/usr/bin/env bash
+unameOutput="$(uname -sr)"
+case "${unameOutput}" in
+ Linux*Microsoft*) machine=WSL;;
+ Linux*) machine=Linux;;
+ Darwin*) machine=MacOS;;
+ *) machine="${unameOutput}"
+esac
+
+echo ">> Generating certificate..."
mkdir -p certs
chmod 0755 ./certs
@@ -11,3 +20,15 @@ docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) eventstore/es-
docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) eventstore/es-gencert-cli:1.0.2 create-node -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/node -ip-addresses 127.0.0.1 -dns-names localhost
chmod -R 0755 ./certs
+
+if [ "${machine}" == "MacOS" ]; then
+ echo ">> Installing certificate on ${machine}..."
+ sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/ca/ca.crt
+elif [ "$machine" == "Linux" ] || [ "$machine" == "WSL" ]; then
+ echo ">> Copying certificate..."
+ cp certs/ca/ca.crt /usr/local/share/ca-certificates/eventstore_ca.crt
+ echo ">> Installing certificate on ${machine}..."
+ sudo update-ca-certificates
+else
+ echo ">> Unknown platform. Please install the certificate manually."
+fi
diff --git a/src/EventStore.Client/ChannelFactory.cs b/src/EventStore.Client/ChannelFactory.cs
index 951aaf055..b28c3e0bb 100644
--- a/src/EventStore.Client/ChannelFactory.cs
+++ b/src/EventStore.Client/ChannelFactory.cs
@@ -16,25 +16,30 @@ public static TChannel CreateChannel(EventStoreClientSettings settings, EndPoint
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
}
- return TChannel.ForAddress(address, new GrpcChannelOptions {
+ return TChannel.ForAddress(
+ address,
+ new GrpcChannelOptions {
#if NET
- HttpClient = new HttpClient(CreateHandler(), true) {
- Timeout = System.Threading.Timeout.InfiniteTimeSpan,
- DefaultRequestVersion = new Version(2, 0)
- },
+ HttpClient = new HttpClient(CreateHandler(), true) {
+ Timeout = System.Threading.Timeout.InfiniteTimeSpan,
+ DefaultRequestVersion = new Version(2, 0)
+ },
#else
HttpHandler = CreateHandler(),
#endif
- LoggerFactory = settings.LoggerFactory,
- Credentials = settings.ChannelCredentials,
- DisposeHttpClient = true,
- MaxReceiveMessageSize = MaxReceiveMessageLength
- });
-
+ LoggerFactory = settings.LoggerFactory,
+ Credentials = settings.ChannelCredentials,
+ DisposeHttpClient = true,
+ MaxReceiveMessageSize = MaxReceiveMessageLength
+ }
+ );
+
HttpMessageHandler CreateHandler() {
if (settings.CreateHttpMessageHandler != null) {
return settings.CreateHttpMessageHandler.Invoke();
}
+
+ var configureClientCert = settings.ConnectivitySettings is { TlsCaFile: not null, Insecure: false };
#if NET
var handler = new SocketsHttpHandler {
KeepAlivePingDelay = settings.ConnectivitySettings.KeepAliveInterval,
@@ -42,6 +47,9 @@ HttpMessageHandler CreateHandler() {
EnableMultipleHttp2Connections = true,
};
+ if (configureClientCert)
+ handler.SslOptions.ClientCertificates = [settings.ConnectivitySettings.TlsCaFile!];
+
if (!settings.ConnectivitySettings.TlsVerifyCert) {
handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; };
}
@@ -53,6 +61,9 @@ HttpMessageHandler CreateHandler() {
EnableMultipleHttp2Connections = true
};
+ if (configureClientCert)
+ handler.ClientCertificates.Add(settings.ConnectivitySettings.TlsCaFile!);
+
if (!settings.ConnectivitySettings.TlsVerifyCert) {
handler.ServerCertificateValidationCallback = delegate { return true; };
}
diff --git a/src/EventStore.Client/EventStoreClientConnectivitySettings.cs b/src/EventStore.Client/EventStoreClientConnectivitySettings.cs
index 974d6a075..57515d270 100644
--- a/src/EventStore.Client/EventStoreClientConnectivitySettings.cs
+++ b/src/EventStore.Client/EventStoreClientConnectivitySettings.cs
@@ -1,5 +1,6 @@
using System;
using System.Net;
+using System.Security.Cryptography.X509Certificates;
namespace EventStore.Client {
///
@@ -100,6 +101,12 @@ public bool Insecure {
///
public bool TlsVerifyCert { get; set; } = true;
+ ///
+ /// Path to a certificate file for secure connection. Not required for enabling secure connection. Useful for self-signed certificate
+ /// that are not installed on the system trust store.
+ ///
+ public X509Certificate2? TlsCaFile { get; set; }
+
///
/// The default .
///
diff --git a/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs b/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs
index 3dd51d30d..ccfd47dbf 100644
--- a/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs
+++ b/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs
@@ -3,6 +3,10 @@
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Net.Security;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using Timeout_ = System.Threading.Timeout;
namespace EventStore.Client {
@@ -16,53 +20,56 @@ public static EventStoreClientSettings Create(string connectionString) =>
ConnectionStringParser.Parse(connectionString);
private static class ConnectionStringParser {
- private const string SchemeSeparator = "://";
+ private const string SchemeSeparator = "://";
private const string UserInfoSeparator = "@";
- private const string Colon = ":";
- private const string Slash = "/";
- private const string Comma = ",";
- private const string Ampersand = "&";
- private const string Equal = "=";
- private const string QuestionMark = "?";
-
- private const string Tls = nameof(Tls);
- private const string ConnectionName = nameof(ConnectionName);
- private const string MaxDiscoverAttempts = nameof(MaxDiscoverAttempts);
- private const string DiscoveryInterval = nameof(DiscoveryInterval);
- private const string GossipTimeout = nameof(GossipTimeout);
- private const string NodePreference = nameof(NodePreference);
- private const string TlsVerifyCert = nameof(TlsVerifyCert);
- private const string DefaultDeadline = nameof(DefaultDeadline);
+ private const string Colon = ":";
+ private const string Slash = "/";
+ private const string Comma = ",";
+ private const string Ampersand = "&";
+ private const string Equal = "=";
+ private const string QuestionMark = "?";
+
+ private const string Tls = nameof(Tls);
+ private const string ConnectionName = nameof(ConnectionName);
+ private const string MaxDiscoverAttempts = nameof(MaxDiscoverAttempts);
+ private const string DiscoveryInterval = nameof(DiscoveryInterval);
+ private const string GossipTimeout = nameof(GossipTimeout);
+ private const string NodePreference = nameof(NodePreference);
+ private const string TlsVerifyCert = nameof(TlsVerifyCert);
+ private const string TlsCaFile = nameof(TlsCaFile);
+ private const string DefaultDeadline = nameof(DefaultDeadline);
private const string ThrowOnAppendFailure = nameof(ThrowOnAppendFailure);
- private const string KeepAliveInterval = nameof(KeepAliveInterval);
- private const string KeepAliveTimeout = nameof(KeepAliveTimeout);
+ private const string KeepAliveInterval = nameof(KeepAliveInterval);
+ private const string KeepAliveTimeout = nameof(KeepAliveTimeout);
private const string UriSchemeDiscover = "esdb+discover";
- private static readonly string[] Schemes = {"esdb", UriSchemeDiscover};
- private static readonly int DefaultPort = EventStoreClientConnectivitySettings.Default.ResolvedAddressOrDefault.Port;
- private static readonly bool DefaultUseTls = true;
+ private static readonly string[] Schemes = { "esdb", UriSchemeDiscover };
+ private static readonly int DefaultPort = EventStoreClientConnectivitySettings.Default.ResolvedAddressOrDefault.Port;
+ private static readonly bool DefaultUseTls = true;
private static readonly Dictionary SettingsType =
new(StringComparer.InvariantCultureIgnoreCase) {
- {ConnectionName, typeof(string)},
- {MaxDiscoverAttempts, typeof(int)},
- {DiscoveryInterval, typeof(int)},
- {GossipTimeout, typeof(int)},
- {NodePreference, typeof(string)},
- {Tls, typeof(bool)},
- {TlsVerifyCert, typeof(bool)},
- {DefaultDeadline, typeof(int)},
- {ThrowOnAppendFailure, typeof(bool)},
- {KeepAliveInterval, typeof(int)},
- {KeepAliveTimeout, typeof(int)},
+ { ConnectionName, typeof(string) },
+ { MaxDiscoverAttempts, typeof(int) },
+ { DiscoveryInterval, typeof(int) },
+ { GossipTimeout, typeof(int) },
+ { NodePreference, typeof(string) },
+ { Tls, typeof(bool) },
+ { TlsVerifyCert, typeof(bool) },
+ { TlsCaFile, typeof(string) },
+ { DefaultDeadline, typeof(int) },
+ { ThrowOnAppendFailure, typeof(bool) },
+ { KeepAliveInterval, typeof(int) },
+ { KeepAliveTimeout, typeof(int) },
};
public static EventStoreClientSettings Parse(string connectionString) {
var currentIndex = 0;
- var schemeIndex = connectionString.IndexOf(SchemeSeparator, currentIndex, StringComparison.Ordinal);
+ var schemeIndex = connectionString.IndexOf(SchemeSeparator, currentIndex, StringComparison.Ordinal);
if (schemeIndex == -1)
throw new NoSchemeException();
+
var scheme = ParseScheme(connectionString.Substring(0, schemeIndex));
currentIndex = schemeIndex + SchemeSeparator.Length;
@@ -73,14 +80,17 @@ public static EventStoreClientSettings Parse(string connectionString) {
currentIndex = userInfoIndex + UserInfoSeparator.Length;
}
-
var slashIndex = connectionString.IndexOf(Slash, currentIndex, StringComparison.Ordinal);
- var questionMarkIndex = connectionString.IndexOf(QuestionMark, Math.Max(currentIndex, slashIndex),
- StringComparison.Ordinal);
+ var questionMarkIndex = connectionString.IndexOf(
+ QuestionMark,
+ Math.Max(currentIndex, slashIndex),
+ StringComparison.Ordinal
+ );
+
var endIndex = connectionString.Length;
//for simpler substring operations:
- if (slashIndex == -1) slashIndex = int.MaxValue;
+ if (slashIndex == -1) slashIndex = int.MaxValue;
if (questionMarkIndex == -1) questionMarkIndex = int.MaxValue;
var hostSeparatorIndex = Math.Min(Math.Min(slashIndex, questionMarkIndex), endIndex);
@@ -89,17 +99,20 @@ public static EventStoreClientSettings Parse(string connectionString) {
string path = "";
if (slashIndex != int.MaxValue)
- path = connectionString.Substring(currentIndex,
- Math.Min(questionMarkIndex, endIndex) - currentIndex);
+ path = connectionString.Substring(
+ currentIndex,
+ Math.Min(questionMarkIndex, endIndex) - currentIndex
+ );
if (path != "" && path != "/")
throw new ConnectionStringParseException(
- $"The specified path must be either an empty string or a forward slash (/) but the following path was found instead: '{path}'");
+ $"The specified path must be either an empty string or a forward slash (/) but the following path was found instead: '{path}'"
+ );
var options = new Dictionary();
if (questionMarkIndex != int.MaxValue) {
currentIndex = questionMarkIndex + QuestionMark.Length;
- options = ParseKeyValuePairs(connectionString.Substring(currentIndex));
+ options = ParseKeyValuePairs(connectionString.Substring(currentIndex));
}
return CreateSettings(scheme, userInfo, hosts, options);
@@ -140,19 +153,17 @@ private static EventStoreClientSettings CreateSettings(
if (typedOptions.TryGetValue(ConnectionName, out object? connectionName))
settings.ConnectionName = (string)connectionName;
- var connSettings = settings.ConnectivitySettings;
-
if (typedOptions.TryGetValue(MaxDiscoverAttempts, out object? maxDiscoverAttempts))
- connSettings.MaxDiscoverAttempts = (int)maxDiscoverAttempts;
+ settings.ConnectivitySettings.MaxDiscoverAttempts = (int)maxDiscoverAttempts;
if (typedOptions.TryGetValue(DiscoveryInterval, out object? discoveryInterval))
- connSettings.DiscoveryInterval = TimeSpan.FromMilliseconds((int)discoveryInterval);
+ settings.ConnectivitySettings.DiscoveryInterval = TimeSpan.FromMilliseconds((int)discoveryInterval);
if (typedOptions.TryGetValue(GossipTimeout, out object? gossipTimeout))
- connSettings.GossipTimeout = TimeSpan.FromMilliseconds((int)gossipTimeout);
+ settings.ConnectivitySettings.GossipTimeout = TimeSpan.FromMilliseconds((int)gossipTimeout);
if (typedOptions.TryGetValue(NodePreference, out object? nodePreference)) {
- connSettings.NodePreference = ((string)nodePreference).ToLowerInvariant() switch {
+ settings.ConnectivitySettings.NodePreference = ((string)nodePreference).ToLowerInvariant() switch {
"leader" => EventStore.Client.NodePreference.Leader,
"follower" => EventStore.Client.NodePreference.Follower,
"random" => EventStore.Client.NodePreference.Random,
@@ -188,34 +199,51 @@ private static EventStoreClientSettings CreateSettings(
};
}
- connSettings.Insecure = !useTls;
+ settings.ConnectivitySettings.Insecure = !useTls;
if (hosts.Length == 1 && scheme != UriSchemeDiscover) {
- connSettings.Address = hosts[0].ToUri(useTls);
+ settings.ConnectivitySettings.Address = hosts[0].ToUri(useTls);
} else {
if (hosts.Any(x => x is DnsEndPoint))
- connSettings.DnsGossipSeeds =
+ settings.ConnectivitySettings.DnsGossipSeeds =
Array.ConvertAll(hosts, x => new DnsEndPoint(x.GetHost(), x.GetPort()));
else
- connSettings.IpGossipSeeds = Array.ConvertAll(hosts, x => (IPEndPoint)x);
+ settings.ConnectivitySettings.IpGossipSeeds = Array.ConvertAll(hosts, x => (IPEndPoint)x);
}
if (typedOptions.TryGetValue(TlsVerifyCert, out var tlsVerifyCert)) {
settings.ConnectivitySettings.TlsVerifyCert = (bool)tlsVerifyCert;
}
+ if (typedOptions.TryGetValue(TlsCaFile, out var tlsCaFile)) {
+ var tlsCaFilePath = Path.GetFullPath((string)tlsCaFile);
+ if (!string.IsNullOrEmpty(tlsCaFilePath) && !File.Exists(tlsCaFilePath)) {
+ throw new InvalidClientCertificateException($"Failed to load certificate. File was not found.");
+ }
+
+ try {
+ settings.ConnectivitySettings.TlsCaFile = new X509Certificate2(tlsCaFilePath);
+ } catch (CryptographicException) {
+ throw new InvalidClientCertificateException("Failed to load certificate. Invalid file format.");
+ }
+ }
+
settings.CreateHttpMessageHandler = CreateDefaultHandler;
return settings;
HttpMessageHandler CreateDefaultHandler() {
+ var configureClientCert = settings.ConnectivitySettings is { TlsCaFile: not null, Insecure: false };
#if NET
var handler = new SocketsHttpHandler {
- KeepAlivePingDelay = settings.ConnectivitySettings.KeepAliveInterval,
- KeepAlivePingTimeout = settings.ConnectivitySettings.KeepAliveTimeout,
+ KeepAlivePingDelay = settings.ConnectivitySettings.KeepAliveInterval,
+ KeepAlivePingTimeout = settings.ConnectivitySettings.KeepAliveTimeout,
EnableMultipleHttp2Connections = true,
};
+ if (configureClientCert)
+ handler.SslOptions.ClientCertificates = [settings.ConnectivitySettings.TlsCaFile!];
+
if (!settings.ConnectivitySettings.TlsVerifyCert) {
handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; };
}
@@ -227,6 +255,9 @@ HttpMessageHandler CreateDefaultHandler() {
EnableMultipleHttp2Connections = true
};
+ if (configureClientCert)
+ handler.ClientCertificates.Add(settings.ConnectivitySettings.TlsCaFile!);
+
if (!settings.ConnectivitySettings.TlsVerifyCert) {
handler.ServerCertificateValidationCallback = delegate { return true; };
}
@@ -241,27 +272,31 @@ private static string ParseScheme(string s) =>
private static (string, string) ParseUserInfo(string s) {
var tokens = s.Split(Colon[0]);
if (tokens.Length != 2) throw new InvalidUserCredentialsException(s);
+
return (tokens[0], tokens[1]);
}
private static EndPoint[] ParseHosts(string s) {
var hostsTokens = s.Split(Comma[0]);
- var hosts = new List();
+ var hosts = new List();
foreach (var hostToken in hostsTokens) {
- var hostPortToken = hostToken.Split(Colon[0]);
+ var hostPortToken = hostToken.Split(Colon[0]);
string host;
- int port;
+ int port;
switch (hostPortToken.Length) {
case 1:
host = hostPortToken[0];
port = DefaultPort;
break;
+
case 2: {
host = hostPortToken[0];
if (!int.TryParse(hostPortToken[1], out port))
throw new InvalidHostException(hostToken);
+
break;
}
+
default:
throw new InvalidHostException(hostToken);
}
@@ -281,7 +316,7 @@ private static EndPoint[] ParseHosts(string s) {
}
private static Dictionary ParseKeyValuePairs(string s) {
- var options = new Dictionary(StringComparer.InvariantCultureIgnoreCase);
+ var options = new Dictionary(StringComparer.InvariantCultureIgnoreCase);
var optionsTokens = s.Split(Ampersand[0]);
foreach (var optionToken in optionsTokens) {
var (key, val) = ParseKeyValuePair(optionToken);
diff --git a/src/EventStore.Client/Exceptions/ConnectionString/InvalidClientCertificateException.cs b/src/EventStore.Client/Exceptions/ConnectionString/InvalidClientCertificateException.cs
new file mode 100644
index 000000000..983988f22
--- /dev/null
+++ b/src/EventStore.Client/Exceptions/ConnectionString/InvalidClientCertificateException.cs
@@ -0,0 +1,13 @@
+namespace EventStore.Client {
+ ///
+ /// The exception that is thrown when a certificate is invalid or not found in the EventStoreDB connection string.
+ ///
+ public class InvalidClientCertificateException : ConnectionStringParseException {
+ ///
+ /// Constructs a new .
+ ///
+ ///
+ public InvalidClientCertificateException(string message)
+ : base(message) { }
+ }
+}
diff --git a/test/EventStore.Client.Streams.Tests/Append/append_to_stream_with_tls_ca_file.cs b/test/EventStore.Client.Streams.Tests/Append/append_to_stream_with_tls_ca_file.cs
new file mode 100644
index 000000000..2fdc983ba
--- /dev/null
+++ b/test/EventStore.Client.Streams.Tests/Append/append_to_stream_with_tls_ca_file.cs
@@ -0,0 +1,35 @@
+namespace EventStore.Client.Streams.Tests.Append;
+
+[Trait("Category", "Target:Stream")]
+[Trait("Category", "Operation:Append")]
+public class append_to_stream_with_tls_ca_file(ITestOutputHelper output, EventStoreFixture fixture)
+ : EventStoreTests(output, fixture) {
+ public static IEnumerable