diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index b3b6facd7..0aafeb43a 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -6,6 +6,10 @@ on: docker-tag: required: true type: string + docker-registry: + required: false + type: string + default: ghcr.io/eventstore/eventstore jobs: test: @@ -15,7 +19,9 @@ jobs: matrix: framework: [ net6.0, net7.0, net8.0 ] os: [ ubuntu-latest ] - test: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement ] + build: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement ] + oss: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement ] + ee: [ Plugins ] configuration: [ release ] runs-on: ${{ matrix.os }} name: EventStore.Client.${{ matrix.test }}/${{ matrix.os }}/${{ matrix.framework }}/${{ inputs.docker-tag }} @@ -25,10 +31,17 @@ jobs: - shell: bash run: | git fetch --prune --unshallow + - name: Login to Cloudsmith + if: ${{ inputs.docker-registry != 'ghcr.io/eventstore/eventstore' }} + uses: docker/login-action@v3 + with: + registry: docker.eventstore.com + username: ${{ secrets.CLOUDSMITH_CICD_USER }} + password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} - name: Pull EventStore Image shell: bash run: | - docker pull ghcr.io/eventstore/eventstore:${{ inputs.docker-tag }} + docker pull ${{ inputs.docker-registry }}:${{ inputs.docker-tag }} - name: Install dotnet SDKs uses: actions/setup-dotnet@v3 with: @@ -39,14 +52,27 @@ jobs: - name: Compile shell: bash run: | - dotnet build --configuration ${{ matrix.configuration }} --framework ${{ matrix.framework }} src/EventStore.Client.${{ matrix.test }} - - name: Run Tests + dotnet build --configuration ${{ matrix.configuration }} --framework ${{ matrix.framework }} src/EventStore.Client.${{ matrix.build }} + - name: Run OSS Tests + if: ${{ inputs.docker-registry != '' }} + shell: bash + env: + ES_DOCKER_TAG: ${{ inputs.docker-tag }} + run: | + sudo ./gencert.sh + dotnet test --configuration ${{ matrix.configuration }} --blame \ + --logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" \ + --framework ${{ matrix.framework }} \ + test/EventStore.Client.${{ matrix.oss }}.Tests + - name: Run EE Tests + if: ${{ inputs.docker-registry == '' }} shell: bash env: ES_DOCKER_TAG: ${{ inputs.docker-tag }} + ES_DOCKER_REGISTRY: ${{ inputs.docker-registry }} run: | sudo ./gencert.sh dotnet test --configuration ${{ matrix.configuration }} --blame \ --logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" \ --framework ${{ matrix.framework }} \ - test/EventStore.Client.${{ matrix.test }}.Tests + test/EventStore.Client.${{ matrix.ee }}.Tests diff --git a/.github/workflows/ee.yml b/.github/workflows/ee.yml new file mode 100644 index 000000000..f8703993e --- /dev/null +++ b/.github/workflows/ee.yml @@ -0,0 +1,16 @@ +name: Test EE + +on: + pull_request: + push: + branches: + - master + tags: + - v* + +jobs: + test: + uses: ./.github/workflows/base.yml + with: + docker-tag: 24.2.0-jammy + docker-registry: docker.eventstore.com/eventstore-ee/eventstoredb-commercial diff --git a/EventStore.Client.sln b/EventStore.Client.sln index 51229f72c..03ecf4045 100644 --- a/EventStore.Client.sln +++ b/EventStore.Client.sln @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.UserManag EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.Tests.Common", "test\EventStore.Client.Tests.Common\EventStore.Client.Tests.Common.csproj", "{E326832D-DE52-4DE4-9E54-C800908B75F3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventStore.Client.Plugins.Tests", "test\EventStore.Client.Plugins.Tests\EventStore.Client.Plugins.Tests.csproj", "{7D929D45-F1D9-462B-BE49-84BEC11D5039}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -94,6 +96,10 @@ Global {E326832D-DE52-4DE4-9E54-C800908B75F3}.Debug|x64.Build.0 = Debug|Any CPU {E326832D-DE52-4DE4-9E54-C800908B75F3}.Release|x64.ActiveCfg = Release|Any CPU {E326832D-DE52-4DE4-9E54-C800908B75F3}.Release|x64.Build.0 = Release|Any CPU + {7D929D45-F1D9-462B-BE49-84BEC11D5039}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D929D45-F1D9-462B-BE49-84BEC11D5039}.Debug|x64.Build.0 = Debug|Any CPU + {7D929D45-F1D9-462B-BE49-84BEC11D5039}.Release|x64.ActiveCfg = Release|Any CPU + {7D929D45-F1D9-462B-BE49-84BEC11D5039}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {D3744A86-DD35-4104-AAEE-84B79062C4A2} = {EA59C1CB-16DA-4F68-AF8A-642A969B4CF8} @@ -109,5 +115,6 @@ Global {6CEB731F-72E1-461F-A6B3-54DBF3FD786C} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} {22634CEE-4F7B-4679-A48D-38A2A8580ECA} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} {E326832D-DE52-4DE4-9E54-C800908B75F3} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} + {7D929D45-F1D9-462B-BE49-84BEC11D5039} = {C51F2C69-45A9-4D0D-A708-4FC319D5D340} EndGlobalSection EndGlobal diff --git a/gencert.ps1 b/gencert.ps1 index 3908f57e8..1101cc13d 100644 --- a/gencert.ps1 +++ b/gencert.ps1 @@ -4,18 +4,22 @@ Write-Host ">> Generating certificate..." New-Item -ItemType Directory -Path .\certs -Force # Set permissions for the directory -icacls .\certs /grant:r "$($env:UserName):(OI)(CI)RX" +icacls .\certs /grant:r "$($env:UserName):(OI)(CI)F" # Pull the Docker image -docker pull eventstore/es-gencert-cli:1.0.2 +docker pull ghcr.io/eventstore/es-gencert-cli:1.3 -# 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 +docker run --rm --volume ${PWD}\certs:/tmp ghcr.io/eventstore/es-gencert-cli 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 +docker run --rm --volume ${PWD}\certs:/tmp ghcr.io/eventstore/es-gencert-cli 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 + +# Create admin user +docker run --rm --volume ${PWD}\certs:/tmp ghcr.io/eventstore/es-gencert-cli create-user -username admin -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/user-admin + +# Create an invalid user +docker run --rm --volume ${PWD}\certs:/tmp ghcr.io/eventstore/es-gencert-cli create-user -username invalid -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/user-invalid # Set permissions recursively for the directory -icacls .\certs /grant:r "$($env:UserName):(OI)(CI)RX" +icacls .\certs /grant:r "$($env:UserName):(OI)(CI)F" Import-Certificate -FilePath ".\certs\ca\ca.crt" -CertStoreLocation Cert:\CurrentUser\Root diff --git a/gencert.sh b/gencert.sh index fa640f624..7cd69b56a 100755 --- a/gencert.sh +++ b/gencert.sh @@ -13,11 +13,15 @@ mkdir -p certs chmod 0755 ./certs -docker pull eventstore/es-gencert-cli:1.0.2 +docker pull ghcr.io/eventstore/es-gencert-cli:1.3 -docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) eventstore/es-gencert-cli:1.0.2 create-ca -out /tmp/ca +docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) ghcr.io/eventstore/es-gencert-cli create-ca -out /tmp/ca -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 +docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) ghcr.io/eventstore/es-gencert-cli 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 + +docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) ghcr.io/eventstore/es-gencert-cli create-user -username admin -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/user-admin + +docker run --rm --volume $PWD/certs:/tmp --user $(id -u):$(id -g) ghcr.io/eventstore/es-gencert-cli create-user -username invalid -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/user-invalid chmod -R 0755 ./certs diff --git a/src/EventStore.Client/CertificateUtils.cs b/src/EventStore.Client/CertificateUtils.cs new file mode 100644 index 000000000..1cb5ba3a0 --- /dev/null +++ b/src/EventStore.Client/CertificateUtils.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +#if NET48 +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Security; +#endif + +namespace EventStore.Client; + +/// +/// Utility class for loading certificates and private keys from files. +/// +static class CertificateUtils { + private static RSA LoadKey(string privateKeyPath) { + string[] allLines = File.ReadAllLines(privateKeyPath); + var header = allLines[0].Replace("-", ""); + var privateKeyLines = allLines.Skip(1).Take(allLines.Length - 2); + var privateKey = Convert.FromBase64String(string.Join(string.Empty, privateKeyLines)); + + var rsa = RSA.Create(); + switch (header) { + case "BEGIN PRIVATE KEY": +#if NET + rsa.ImportPkcs8PrivateKey(new ReadOnlySpan(privateKey), out _); +#else + { + var pemReader = new PemReader(new StringReader(string.Join(Environment.NewLine, allLines))); + var keyPair = (AsymmetricCipherKeyPair)pemReader.ReadObject(); + var privateKeyParams = (RsaPrivateCrtKeyParameters)keyPair.Private; + rsa.ImportParameters(DotNetUtilities.ToRSAParameters(privateKeyParams)); + } +#endif + break; + + case "BEGIN RSA PRIVATE KEY": +#if NET + rsa.ImportRSAPrivateKey(new ReadOnlySpan(privateKey), out _); +#else + { + var pemReader = new PemReader(new StringReader(string.Join(Environment.NewLine, allLines))); + object pemObject = pemReader.ReadObject(); + RsaPrivateCrtKeyParameters privateKeyParams; + if (pemObject is RsaPrivateCrtKeyParameters) { + privateKeyParams = (RsaPrivateCrtKeyParameters)pemObject; + } else if (pemObject is AsymmetricCipherKeyPair keyPair) { + privateKeyParams = (RsaPrivateCrtKeyParameters)keyPair.Private; + } else { + throw new NotSupportedException($"Unsupported PEM object type: {pemObject.GetType()}"); + } + + rsa.ImportParameters(DotNetUtilities.ToRSAParameters(privateKeyParams)); + } +#endif + break; + + default: + rsa.Dispose(); + throw new NotSupportedException($"Unsupported private key file format: {header}"); + } + + return rsa; + } + + internal static X509Certificate2 LoadCertificate(string certificatePath) { + return new X509Certificate2(certificatePath); + } + + /// + /// + /// + /// + /// + /// + /// + public static X509Certificate2 LoadFromFile(string certificatePath, string privateKeyPath) { + X509Certificate2? publicCertificate = null; + RSA? rsa = null; + + try { + try { + publicCertificate = LoadCertificate(certificatePath); + } catch (Exception ex) { + throw new Exception($"Failed to load certificate: {ex.Message}"); + } + + try { + rsa = LoadKey(privateKeyPath); + } catch (Exception ex) { + throw new Exception($"Failed to load private key: {ex.Message}"); + } + + using var publicWithPrivate = publicCertificate.CopyWithPrivateKey(rsa); + var certificate = new X509Certificate2(publicWithPrivate.Export(X509ContentType.Pfx)); + + return certificate; + } finally { + publicCertificate?.Dispose(); + rsa?.Dispose(); + } + } +} diff --git a/src/EventStore.Client/ChannelFactory.cs b/src/EventStore.Client/ChannelFactory.cs index b28c3e0bb..3f6be47f7 100644 --- a/src/EventStore.Client/ChannelFactory.cs +++ b/src/EventStore.Client/ChannelFactory.cs @@ -1,6 +1,6 @@ using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using Grpc.Net.Client; -using System.Net.Security; using EndPoint = System.Net.EndPoint; using TChannel = Grpc.Net.Client.GrpcChannel; @@ -25,7 +25,7 @@ public static TChannel CreateChannel(EventStoreClientSettings settings, EndPoint DefaultRequestVersion = new Version(2, 0) }, #else - HttpHandler = CreateHandler(), + HttpHandler = CreateHandler(), #endif LoggerFactory = settings.LoggerFactory, Credentials = settings.ChannelCredentials, @@ -39,20 +39,16 @@ HttpMessageHandler CreateHandler() { return settings.CreateHttpMessageHandler.Invoke(); } - var configureClientCert = settings.ConnectivitySettings is { TlsCaFile: not null, Insecure: false }; + var certificate = settings.ConnectivitySettings.ClientCertificate ?? + settings.ConnectivitySettings.TlsCaFile; + + var configureClientCert = settings.ConnectivitySettings is { Insecure: false } && certificate != null; #if NET var handler = new SocketsHttpHandler { 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; }; - } #else var handler = new WinHttpHandler { TcpKeepAliveEnabled = true, @@ -60,9 +56,20 @@ HttpMessageHandler CreateHandler() { TcpKeepAliveInterval = settings.ConnectivitySettings.KeepAliveInterval, EnableMultipleHttp2Connections = true }; +#endif + if (settings.ConnectivitySettings.Insecure) return handler; +#if NET + if (configureClientCert) { + handler.SslOptions.ClientCertificates = new X509CertificateCollection { certificate! }; + } - if (configureClientCert) - handler.ClientCertificates.Add(settings.ConnectivitySettings.TlsCaFile!); + if (!settings.ConnectivitySettings.TlsVerifyCert) { + handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; + } +#else + if (configureClientCert) { + handler.ClientCertificates.Add(certificate!); + } if (!settings.ConnectivitySettings.TlsVerifyCert) { handler.ServerCertificateValidationCallback = delegate { return true; }; diff --git a/src/EventStore.Client/EventStore.Client.csproj b/src/EventStore.Client/EventStore.Client.csproj index ddf36c6af..094ed87fc 100644 --- a/src/EventStore.Client/EventStore.Client.csproj +++ b/src/EventStore.Client/EventStore.Client.csproj @@ -29,6 +29,7 @@ + diff --git a/src/EventStore.Client/EventStoreClientConnectivitySettings.cs b/src/EventStore.Client/EventStoreClientConnectivitySettings.cs index 57515d270..92890d2f2 100644 --- a/src/EventStore.Client/EventStoreClientConnectivitySettings.cs +++ b/src/EventStore.Client/EventStoreClientConnectivitySettings.cs @@ -107,6 +107,11 @@ public bool Insecure { /// public X509Certificate2? TlsCaFile { get; set; } + /// + /// Client certificate used for user authentication. + /// + public X509Certificate2? ClientCertificate { get; set; } + /// /// The default . /// diff --git a/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs b/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs index ccfd47dbf..253532aab 100644 --- a/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs +++ b/src/EventStore.Client/EventStoreClientSettings.ConnectionString.cs @@ -41,6 +41,8 @@ private static class ConnectionStringParser { private const string ThrowOnAppendFailure = nameof(ThrowOnAppendFailure); private const string KeepAliveInterval = nameof(KeepAliveInterval); private const string KeepAliveTimeout = nameof(KeepAliveTimeout); + private const string CertPath = nameof(CertPath); + private const string CertKeyPath = nameof(CertKeyPath); private const string UriSchemeDiscover = "esdb+discover"; @@ -62,6 +64,8 @@ private static class ConnectionStringParser { { ThrowOnAppendFailure, typeof(bool) }, { KeepAliveInterval, typeof(int) }, { KeepAliveTimeout, typeof(int) }, + { CertPath, typeof(string)}, + { CertKeyPath, typeof(string)}, }; public static EventStoreClientSettings Parse(string connectionString) { @@ -80,14 +84,9 @@ 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 endIndex = connectionString.Length; + var slashIndex = connectionString.IndexOf(Slash, currentIndex, StringComparison.Ordinal); + var questionMarkIndex = connectionString.IndexOf(QuestionMark, currentIndex, StringComparison.Ordinal); + var endIndex = connectionString.Length; //for simpler substring operations: if (slashIndex == -1) slashIndex = int.MaxValue; @@ -228,25 +227,39 @@ private static EventStoreClientSettings CreateSettings( } } + var certPathSet = typedOptions.TryGetValue(CertPath, out var certPath); + var certKeyPathSet = typedOptions.TryGetValue(CertKeyPath, out var certKeyPath); + + if (certPathSet ^ certKeyPathSet) + throw new InvalidClientCertificateException( + $"Invalid certificate settings. {nameof(CertPath)} and {nameof(CertKeyPath)} must both be set" + ); + + if (certPathSet && certKeyPathSet) { + try { + settings.ConnectivitySettings.ClientCertificate = + CertificateUtils.LoadFromFile((string)certPath!, (string)certKeyPath!); + } catch (Exception ex) { + throw new InvalidClientCertificateException($"Invalid certificate settings. {ex.Message}"); + } + } + settings.CreateHttpMessageHandler = CreateDefaultHandler; return settings; HttpMessageHandler CreateDefaultHandler() { - var configureClientCert = settings.ConnectivitySettings is { TlsCaFile: not null, Insecure: false }; + var certificate = settings.ConnectivitySettings.ClientCertificate ?? + settings.ConnectivitySettings.TlsCaFile; + + var configureClientCert = settings.ConnectivitySettings is { Insecure: false } && certificate != null; + #if NET var handler = new SocketsHttpHandler { 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; }; - } #else var handler = new WinHttpHandler { TcpKeepAliveEnabled = true, @@ -254,9 +267,20 @@ HttpMessageHandler CreateDefaultHandler() { TcpKeepAliveInterval = settings.ConnectivitySettings.KeepAliveInterval, EnableMultipleHttp2Connections = true }; +#endif + if (settings.ConnectivitySettings.Insecure) return handler; +#if NET + if (configureClientCert) { + handler.SslOptions.ClientCertificates = [certificate!]; + } - if (configureClientCert) - handler.ClientCertificates.Add(settings.ConnectivitySettings.TlsCaFile!); + if (!settings.ConnectivitySettings.TlsVerifyCert) { + handler.SslOptions.RemoteCertificateValidationCallback = delegate { return true; }; + } +#else + if (configureClientCert) { + handler.ClientCertificates.Add(certificate!); + } if (!settings.ConnectivitySettings.TlsVerifyCert) { handler.ServerCertificateValidationCallback = delegate { return true; }; diff --git a/src/EventStore.Client/HttpFallback.cs b/src/EventStore.Client/HttpFallback.cs index 294242cb0..44a72cf7e 100644 --- a/src/EventStore.Client/HttpFallback.cs +++ b/src/EventStore.Client/HttpFallback.cs @@ -1,6 +1,7 @@ using System; using System.Net; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -12,10 +13,10 @@ internal class HttpFallback : IDisposable { private readonly UserCredentials? _defaultCredentials; private readonly string _addressScheme; - internal HttpFallback (EventStoreClientSettings settings) { - _addressScheme = settings.ConnectivitySettings.ResolvedAddressOrDefault.Scheme; - _defaultCredentials = settings.DefaultCredentials; - + internal HttpFallback(EventStoreClientSettings settings) { + _addressScheme = settings.ConnectivitySettings.ResolvedAddressOrDefault.Scheme; + _defaultCredentials = settings.DefaultCredentials; + var handler = new HttpClientHandler(); if (!settings.ConnectivitySettings.Insecure) { handler.ClientCertificateOptions = ClientCertificateOption.Manual; @@ -23,17 +24,20 @@ internal HttpFallback (EventStoreClientSettings settings) { if (settings.ConnectivitySettings.TlsCaFile != null) handler.ClientCertificates.Add(settings.ConnectivitySettings.TlsCaFile); + if (settings.ConnectivitySettings.ClientCertificate != null) + handler.ClientCertificates.Add(settings.ConnectivitySettings.ClientCertificate); + if (!settings.ConnectivitySettings.TlsVerifyCert) - handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; + handler.ServerCertificateCustomValidationCallback = delegate { return true; }; } _httpClient = new HttpClient(handler); if (settings.DefaultDeadline.HasValue) { _httpClient.Timeout = settings.DefaultDeadline.Value; } - + _jsonSettings = new JsonSerializerOptions { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; } diff --git a/test/EventStore.Client.Plugins.Tests/EventStore.Client.Plugins.Tests.csproj b/test/EventStore.Client.Plugins.Tests/EventStore.Client.Plugins.Tests.csproj new file mode 100644 index 000000000..ee7d48512 --- /dev/null +++ b/test/EventStore.Client.Plugins.Tests/EventStore.Client.Plugins.Tests.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/EventStore.Client.Plugins.Tests/client_certificate.cs b/test/EventStore.Client.Plugins.Tests/client_certificate.cs new file mode 100644 index 000000000..b1d220c0e --- /dev/null +++ b/test/EventStore.Client.Plugins.Tests/client_certificate.cs @@ -0,0 +1,88 @@ +namespace EventStore.Client.Plugins.Tests; + +[Trait("Category", "Target:Plugins")] +[Trait("Category", "Type:UserCertificate")] +public class client_certificate(ITestOutputHelper output, EventStoreFixture fixture) + : EventStoreTests(output, fixture) { + public static IEnumerable TlsCertPaths => + new List { + new object[] { Path.Combine("certs", "ca", "ca.crt") }, + new object[] { Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs", "ca", "ca.crt") }, + }; + + public static IEnumerable AdminClientCertPaths => + new List { + new object[] { + Path.Combine(Environment.CurrentDirectory, "certs", "user-admin", "user-admin.crt"), + Path.Combine(Environment.CurrentDirectory, "certs", "user-admin", "user-admin.key") + }, + new object[] { + Path.Combine("certs", "user-admin", "user-admin.crt"), + Path.Combine("certs", "user-admin", "user-admin.key") + } + }; + + public static IEnumerable BadClientCertPaths => + new List { + new object[] { + Path.Combine("certs", "user-invalid", "user-invalid.crt"), + Path.Combine("certs", "user-invalid", "user-invalid.key") + }, + new object[] { + Path.Combine(Environment.CurrentDirectory, "certs", "user-invalid", "user-invalid.crt"), + Path.Combine(Environment.CurrentDirectory, "certs", "user-invalid", "user-invalid.key") + } + }; + + [Theory] + [MemberData(nameof(TlsCertPaths))] + private async Task append_with_different_tls_cert_path(string certificateFilePath) { + await AppendWithCertificate($"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&tlsCAFile={certificateFilePath}"); + } + + [Theory] + [MemberData(nameof(AdminClientCertPaths))] + private async Task append_with_admin_client_certificate(string certPath, string certKeyPath) { + await AppendWithCertificate($"esdb://localhost:2113/?tls=true&tlsVerifyCert=true&certPath={certPath}&certKeyPath={certKeyPath}"); + } + + [Theory] + [MemberData(nameof(BadClientCertPaths))] + private async Task append_with_bad_client_certificate(string certPath, string certKeyPath) { + await AssertAppendFailsWithCertificate($"esdb://localhost:2113/?tls=true&tlsVerifyCert=true&certPath={certPath}&certKeyPath={certKeyPath}", typeof(NotAuthenticatedException)); + } + + [Theory] + [MemberData(nameof(BadClientCertPaths))] + private async Task user_credentials_takes_precedence_over_client_certificates(string certPath, string certKeyPath) { + await AppendWithCertificate($"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&certPath={certPath}&certKeyPath={certKeyPath}"); + } + + private async Task AppendWithCertificate(string connectionString) { + var settings = EventStoreClientSettings.Create(connectionString); + var client = new EventStoreClient(settings); + + var appendResult = await client.AppendToStreamAsync( + Fixture.GetStreamName(), + StreamState.Any, + Fixture.CreateTestEvents(1) + ); + + appendResult.ShouldNotBeNull(); + + await client.DisposeAsync(); + } + + private async Task AssertAppendFailsWithCertificate(string connectionString, Type expectedExceptionType) { + var settings = EventStoreClientSettings.Create(connectionString); + var client = new EventStoreClient(settings); + + await client.AppendToStreamAsync( + Fixture.GetStreamName(), + StreamState.Any, + Fixture.CreateTestEvents(1) + ).ShouldThrowAsync(expectedExceptionType); + + await client.DisposeAsync(); + } +} diff --git a/test/EventStore.Client.Tests.Common/Fixtures/EventStoreTestNode.cs b/test/EventStore.Client.Tests.Common/Fixtures/EventStoreTestNode.cs index 11f247691..7f1da49d0 100644 --- a/test/EventStore.Client.Tests.Common/Fixtures/EventStoreTestNode.cs +++ b/test/EventStore.Client.Tests.Common/Fixtures/EventStoreTestNode.cs @@ -33,7 +33,7 @@ public static EventStoreFixtureOptions DefaultOptions() { var defaultEnvironment = new Dictionary(GlobalEnvironment.Variables) { ["EVENTSTORE_MEM_DB"] = "true", - ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024).ToString(), + ["EVENTSTORE_CHUNK_SIZE"] = (1024 * 1024 * 1024).ToString(), ["EVENTSTORE_CERTIFICATE_FILE"] = "/etc/eventstore/certs/node/node.crt", ["EVENTSTORE_CERTIFICATE_PRIVATE_KEY_FILE"] = "/etc/eventstore/certs/node/node.key", ["EVENTSTORE_STREAM_EXISTENCE_FILTER_SIZE"] = "10000", @@ -42,7 +42,12 @@ public static EventStoreFixtureOptions DefaultOptions() { ["EVENTSTORE_DISABLE_LOG_FILE"] = "true", ["EVENTSTORE_ADVERTISE_HTTP_PORT_TO_CLIENT_AS"] = $"{NetworkPortProvider.DefaultEsdbPort}" }; - + + if (GlobalEnvironment.DockerImage.Contains("commercial")) { + defaultEnvironment["EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH"] = "/etc/eventstore/certs/ca"; + defaultEnvironment["EventStore__Plugins__UserCertificates__Enabled"] = "true"; + } + // TODO SS: must find a way to enable parallel tests on CI. It works locally. if (port != NetworkPortProvider.DefaultEsdbPort) { if (GlobalEnvironment.Variables.TryGetValue("ES_DOCKER_TAG", out var tag) && tag == "ci") diff --git a/test/EventStore.Client.Tests/ConnectionStringTests.cs b/test/EventStore.Client.Tests/ConnectionStringTests.cs index 3fd6480b1..869100540 100644 --- a/test/EventStore.Client.Tests/ConnectionStringTests.cs +++ b/test/EventStore.Client.Tests/ConnectionStringTests.cs @@ -148,14 +148,14 @@ public void tls_verify_cert(bool tlsVerifyCert) { #endif - public static IEnumerable InvalidClientCertificates() { + public static IEnumerable InvalidTlsCertificates() { 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) { + [MemberData(nameof(InvalidTlsCertificates))] + public void connection_string_with_invalid_tls_certificate_should_throw(string clientCertificatePath) { Assert.Throws( () => EventStoreClientSettings.Create( $"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&tlsCAFile={clientCertificatePath}" @@ -163,6 +163,24 @@ public void connection_string_with_invalid_client_certificate_should_throw(strin ); } + public static IEnumerable InvalidClientCertificates() { + var invalidPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "path", "not", "found"); + yield return [invalidPath, null]; + yield return [null, invalidPath]; + yield return [null, null]; + yield return [invalidPath, invalidPath]; + } + + [Theory] + [MemberData(nameof(InvalidClientCertificates))] + public void connection_string_with_invalid_client_certificate_should_throw(string certPath, string certKeyPath) { + Assert.Throws( + () => EventStoreClientSettings.Create( + $"esdb://admin:changeit@localhost:2113/?tls=true&tlsVerifyCert=true&certPath={certPath}&certKeyPath={certKeyPath}" + ) + ); + } + [Fact] public void infinite_grpc_timeouts() { var result = EventStoreClientSettings.Create("esdb://localhost:2113?keepAliveInterval=-1&keepAliveTimeout=-1");