Skip to content

Commit

Permalink
Add support for authenticating a user with an X.509 cert
Browse files Browse the repository at this point in the history
  • Loading branch information
shaan1337 authored and w1am committed Apr 4, 2024
1 parent b4667b6 commit 0ef5deb
Show file tree
Hide file tree
Showing 14 changed files with 394 additions and 52 deletions.
62 changes: 62 additions & 0 deletions .github/workflows/ee.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Test EE

on:
pull_request:
push:
branches:
- master
tags:
- v*

jobs:
test:
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
framework: [ net6.0, net7.0, net8.0 ]
os: [ ubuntu-latest ]
build: [ Streams, PersistentSubscriptions, Operations, UserManagement, ProjectionManagement ]
test: [ Plugins ]
configuration: [ release ]
runs-on: ${{ matrix.os }}
name: EventStore.Client.${{ matrix.test }}/${{ matrix.os }}/${{ matrix.framework }}/24.2.0-jammy
steps:
- name: Checkout
uses: actions/checkout@v3
- shell: bash
run: |
git fetch --prune --unshallow
- name: Login to Cloudsmith
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 docker.eventstore.com/eventstore-ee/eventstoredb-commercial:24.2.0-jammy
- name: Install dotnet SDKs
uses: actions/setup-dotnet@v3
with:
dotnet-version: |
6.0.x
7.0.x
8.0.x
- name: Compile
shell: bash
run: |
dotnet build --configuration ${{ matrix.configuration }} --framework ${{ matrix.framework }} src/EventStore.Client.${{ matrix.build }}
- name: Run Tests
if: ${{ inputs.docker-registry == '' }}
shell: bash
env:
ES_DOCKER_TAG: 24.2.0-jammy
ES_DOCKER_REGISTRY: docker.eventstore.com/eventstore-ee/eventstoredb-commercial
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.ee }}.Tests
7 changes: 7 additions & 0 deletions EventStore.Client.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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
18 changes: 11 additions & 7 deletions gencert.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 7 additions & 3 deletions gencert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
107 changes: 107 additions & 0 deletions src/EventStore.Client/CertificateUtils.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Utility class for loading certificates and private keys from files.
/// </summary>
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<byte>(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<byte>(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);
}

/// <summary>
///
/// </summary>
/// <param name="certificatePath"></param>
/// <param name="privateKeyPath"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
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();
}
}
}
31 changes: 19 additions & 12 deletions src/EventStore.Client/ChannelFactory.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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,
Expand All @@ -39,30 +39,37 @@ 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,
TcpKeepAliveTime = settings.ConnectivitySettings.KeepAliveTimeout,
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; };
Expand Down
1 change: 1 addition & 0 deletions src/EventStore.Client/EventStore.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<PackageReference Include="System.Net.Http.WinHttpHandler" Version="8.0.0"/>
<PackageReference Include="System.Text.Json" Version="7.0.3"/>
<PackageReference Include="System.Threading.Channels" Version="7.0.0"/>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.3.0" />
</ItemGroup>

<ItemGroup>
Expand Down
5 changes: 5 additions & 0 deletions src/EventStore.Client/EventStoreClientConnectivitySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ public bool Insecure {
/// </summary>
public X509Certificate2? TlsCaFile { get; set; }

/// <summary>
/// Client certificate used for user authentication.
/// </summary>
public X509Certificate2? ClientCertificate { get; set; }

/// <summary>
/// The default <see cref="EventStoreClientConnectivitySettings"/>.
/// </summary>
Expand Down
Loading

0 comments on commit 0ef5deb

Please sign in to comment.