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 CertPaths => + new List { + new object[] { Path.Combine("certs", "ca", "ca.crt") }, + new object[] { Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs", "ca", "ca.crt") }, + }; + + [Theory] + [MemberData(nameof(CertPaths))] + private async Task TestAppendWithCaFile(string certificateFilePath) { + Fixture.Log.Information($"Using certificate: {certificateFilePath}"); + + var connectionString = + $"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&tlsCAFile={certificateFilePath}"; + + var settings = EventStoreClientSettings.Create(connectionString); + + var client = new EventStoreClient(settings); + + var appendResult = await client.AppendToStreamAsync( + "some-stream", + StreamState.Any, + new[] { new EventData(Uuid.NewUuid(), "some-event", default) } + ); + + appendResult.ShouldNotBeNull(); + + await client.DisposeAsync(); + } +} diff --git a/test/EventStore.Client.Tests/ConnectionStringTests.cs b/test/EventStore.Client.Tests/ConnectionStringTests.cs index eaa34ff9c..3fd6480b1 100644 --- a/test/EventStore.Client.Tests/ConnectionStringTests.cs +++ b/test/EventStore.Client.Tests/ConnectionStringTests.cs @@ -1,6 +1,8 @@ using System.Net; using System.Net.Http; using AutoFixture; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; namespace EventStore.Client.Tests; @@ -17,6 +19,8 @@ public class ConnectionStringTests { ) ); + fixture.Register(() => null!); + return Enumerable.Range(0, 3).SelectMany(GetTestCases); IEnumerable GetTestCases(int _) { @@ -144,6 +148,21 @@ public void tls_verify_cert(bool tlsVerifyCert) { #endif + public static IEnumerable InvalidClientCertificates() { + yield return new object?[] { Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "path", "not", "found") }; + yield return new object?[] { Assembly.GetExecutingAssembly().Location }; + } + + [Theory] + [MemberData(nameof(InvalidClientCertificates))] + public void connection_string_with_invalid_client_certificate_should_throw(string clientCertificatePath) { + Assert.Throws( + () => EventStoreClientSettings.Create( + $"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&tlsCAFile={clientCertificatePath}" + ) + ); + } + [Fact] public void infinite_grpc_timeouts() { var result = EventStoreClientSettings.Create("esdb://localhost:2113?keepAliveInterval=-1&keepAliveTimeout=-1");