Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DEV-303 - Support providing an x.509 certificate for user authentication #295

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.test }}.Tests
52 changes: 50 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,30 @@ jobs:
run: |
find samples/ -type f -iname "*.csproj" -print0 | xargs -0L1 dotnet run --framework ${{ matrix.framework }} --project

generate-certificates:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Generate certificates
run: |
mkdir -p certs
docker run --rm --user root --volume "$PWD/certs:/tmp" ghcr.io/eventstore/es-gencert-cli:1.3 create-ca -out /tmp/ca
docker run --rm --user root --volume "$PWD/certs:/tmp" ghcr.io/eventstore/es-gencert-cli:1.3 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 --user root --volume "$PWD/certs:/tmp" ghcr.io/eventstore/es-gencert-cli:1.3 create-user -username admin -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/user-admin
docker run --rm --user root --volume "$PWD/certs:/tmp" ghcr.io/eventstore/es-gencert-cli:1.3 create-user -username invalid -ca-certificate /tmp/ca/ca.crt -ca-key /tmp/ca/ca.key -out /tmp/user-invalid
- name: Set permissions on certificates
run: |
sudo chown -R $USER:$USER certs
sudo chmod -R 755 certs
- name: Upload certificates
uses: actions/upload-artifact@v2
with:
name: certs
path: certs

test:
needs: generate-certificates
timeout-minutes: 20
strategy:
fail-fast: false
Expand All @@ -99,13 +122,38 @@ jobs:
shell: bash
run: |
dotnet build --configuration ${{ matrix.configuration }} --framework ${{ matrix.framework }} src/EventStore.Client
- name: Run Tests
- name: Download certificates
uses: actions/download-artifact@v2
with:
name: certs
path: certs
- name: Import certificates (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
sudo cp certs/ca/ca.crt /usr/local/share/ca-certificates/eventstore_ca.crt
sudo update-ca-certificates
- name: Import certificates (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Import-Certificate -FilePath "certs\ca\ca.crt" -CertStoreLocation "Cert:\LocalMachine\Root"
- name: Run Tests (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
dotnet test --configuration ${{ matrix.configuration }} --blame \
--logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" \
--framework ${{ matrix.framework }} \
test/EventStore.Client.Tests
- name: Run Tests (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
dotnet test --configuration ${{ matrix.configuration }} --blame `
--logger:"GitHubActions;report-warnings=false" --logger:"console;verbosity=normal" `
--framework ${{ matrix.framework }} `
test/EventStore.Client.Tests

publish:
timeout-minutes: 5
Expand Down Expand Up @@ -159,4 +207,4 @@ jobs:
run: |
dotnet nuget list source
dotnet tool restore
find . -name "*.nupkg" | xargs -n1 dotnet nuget push --api-key=${{ secrets.nuget_key }} --source https://api.nuget.org/v3/index.json --skip-duplicate
find . -name "*.nupkg" | xargs -n1 dotnet nuget push --api-key=${{ secrets.nuget_key }} --source https://api.nuget.org/v3/index.json --skip-duplicate
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 .\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 .\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 .\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 .\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
2 changes: 1 addition & 1 deletion samples/secure-with-tls/docker-compose.certs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ services:
network_mode: none

cert-gen:
image: eventstore/es-gencert-cli:1.0.2
image: ghcr.io/eventstore/es-gencert-cli:1.3
container_name: cert-gen
user: "1000:1000"
entrypoint: [ "/bin/sh","-c" ]
Expand Down
107 changes: 107 additions & 0 deletions src/EventStore.Client/Certificates/X509Certificates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member

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;

static class X509Certificates {
// TODO SS: Use .NET 8 X509Certificate2.CreateFromPemFile(certPemFilePath, keyPemFilePath) once the Windows32Exception issue is resolved
public static X509Certificate2 CreateFromPemFile(string certPemFilePath, string keyPemFilePath) {
try {
using var publicCert = new X509Certificate2(certPemFilePath);
using var privateKey = RSA.Create().ImportPrivateKeyFromFile(keyPemFilePath);
using var certificate = publicCert.CopyWithPrivateKey(privateKey);

return new(certificate.Export(X509ContentType.Pfx));
}
catch (Exception ex) {
throw new CryptographicException($"Failed to load private key: {ex.Message}");
}

// Notes:
// using X509Certificate2.CreateFromPemFile(certPemFilePath, keyPemFilePath) would be the ideal choice here,
// but it's currently causing a Win32Exception specifically on Windows. Alternative implementation is used until the issue is resolved.
//
// Error: The SSL connection could not be established, see inner exception. AuthenticationException: Authentication failed because the platform
// does not support ephemeral keys. Win32Exception: No credentials are available in the security package
//
// public static X509Certificate2 CreateFromPemFile(string certPemFilePath, string keyPemFilePath) =>
// X509Certificate2.CreateFromPemFile(certPemFilePath, keyPemFilePath);
}
}

public static class RsaExtensions {
#if NET48
public static RSA ImportPrivateKeyFromFile(this RSA rsa, string privateKeyPath) {
var (content, label) = LoadPemKeyFile(privateKeyPath);

using var reader = new PemReader(new StringReader(string.Join(Environment.NewLine, content)));

var keyParameters = reader.ReadObject() switch {
RsaPrivateCrtKeyParameters parameters => parameters,
AsymmetricCipherKeyPair keyPair => keyPair.Private as RsaPrivateCrtKeyParameters,
_ => throw new NotSupportedException($"Invalid private key format: {label}")
};

rsa.ImportParameters(DotNetUtilities.ToRSAParameters(keyParameters));

return rsa;
}
#else
public static RSA ImportPrivateKeyFromFile(this RSA rsa, string privateKeyPath) {
var (content, label) = LoadPemKeyFile(privateKeyPath);

var privateKey = string.Join(string.Empty, content[1..^1]);
var privateKeyBytes = Convert.FromBase64String(privateKey);

if (label == RsaPemLabels.Pkcs8PrivateKey)
rsa.ImportPkcs8PrivateKey(privateKeyBytes, out _);
else if (label == RsaPemLabels.RSAPrivateKey)
rsa.ImportRSAPrivateKey(privateKeyBytes, out _);

return rsa;
}
#endif

static (string[] Content, string Label) LoadPemKeyFile(string privateKeyPath) {
var content = File.ReadAllLines(privateKeyPath);
var label = RsaPemLabels.ParseKeyLabel(content[0]);

if (RsaPemLabels.IsEncryptedPrivateKey(label))
throw new NotSupportedException("Encrypted private keys are not supported");

return (content, label);
}
}

static class RsaPemLabels {
public const string RSAPrivateKey = "RSA PRIVATE KEY";
public const string Pkcs8PrivateKey = "PRIVATE KEY";
public const string EncryptedPkcs8PrivateKey = "ENCRYPTED PRIVATE KEY";

public static readonly string[] PrivateKeyLabels = [RSAPrivateKey, Pkcs8PrivateKey, EncryptedPkcs8PrivateKey];

public static bool IsPrivateKey(string label) => Array.IndexOf(PrivateKeyLabels, label) != -1;

public static bool IsEncryptedPrivateKey(string label) => label == EncryptedPkcs8PrivateKey;

const string LabelPrefix = "-----BEGIN ";
const string LabelSuffix = "-----";

public static string ParseKeyLabel(string pemFileHeader) {
var label = pemFileHeader.Replace(LabelPrefix, string.Empty).Replace(LabelSuffix, string.Empty);

if (!IsPrivateKey(label))
throw new CryptographicException($"Unknown private key label: {label}");

return label;
}
}
Loading
Loading