Skip to content
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
26 changes: 26 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,32 @@ If the domains are `node1.esdb.mycompany.org`, `node2.esdb.mycompany.org` and `n
Server certificates **must** have the internal and external IP addresses (`ReplicationIp` and `NodeIp` respectively) or DNS names as subject alternative names.
:::

#### Node certificate Client Authentication usage

Cluster nodes normally use the same certificate when accepting and initiating node-to-node connections. For HTTPS
cluster traffic, strict node authentication therefore requires both Server Authentication and Client Authentication usages
when an Extended Key Usage extension is present.

Some public certificate authorities issue TLS server certificates without the Client Authentication usage. A node can
accept those certificates from other cluster members only when this compatibility policy is explicitly enabled:

| Format | Syntax |
|:---------------------|:---------------------------------------------------------|
| Command line | `--allow-node-certificate-without-client-auth-eku` |
| YAML | `AllowNodeCertificateWithoutClientAuthEku` |
| Environment variable | `EVENTSTORE_ALLOW_NODE_CERTIFICATE_WITHOUT_CLIENT_AUTH_EKU` |

**Default**: `false`

::: warning
Enable this option only when your certificate authority cannot issue node certificates with both usages. Trusted chain,
validity period, common-name policy, subject alternative names, key usage, and Server Authentication usage remain
required. This option does not relax user certificate authentication.
:::

This setting controls node identity authentication for HTTPS cluster traffic. It does not change legacy secure TCP
replication certificate handling.

#### Trusted root certificates

When getting an incoming connection, the server needs to ensure if the certificate used for the connection can be trusted. For this to work, the server needs to know where trusted root certificates are located.
Expand Down
7 changes: 5 additions & 2 deletions src/EventStore.Common/Utils/CertificateExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,10 @@ private static bool HasClientAuthExtendedKeyUsage(IEnumerable<Oid> extendedKeyUs
}

public static bool IsServerCertificate(this X509Certificate2 certificate, out string failReason)
=> certificate.IsServerCertificate(allowMissingClientAuthEku: false, out failReason);

public static bool IsServerCertificate(this X509Certificate2 certificate, bool allowMissingClientAuthEku,
out string failReason)
{
if (!certificate.TryGetKeyUsages(out var keyUsages, out var hasExtKeyUsagesExtension, out var extKeyUsages, out failReason))
{
Expand All @@ -352,8 +356,7 @@ public static bool IsServerCertificate(this X509Certificate2 certificate, out st
return false;
}

// historically, server certificates also have the clientAuth EKU
if (!HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason))
if (!allowMissingClientAuthEku && !HasClientAuthExtendedKeyUsage(extKeyUsages, out failReason))
{
return false;
}
Expand Down
2 changes: 2 additions & 0 deletions src/EventStore.Core.Tests/Certificates/key_usages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public void certificate_with_server_auth_eku_only()

// historically, server certificates also have the clientAuth EKU
Assert.False(sut.IsServerCertificate(out _));
Assert.True(sut.IsServerCertificate(allowMissingClientAuthEku: true, out _));
Assert.False(sut.IsClientCertificate(out _));
}

Expand All @@ -99,6 +100,7 @@ public void certificate_with_no_key_usage()

Assert.False(sut.IsClientCertificate(out _));
Assert.False(sut.IsServerCertificate(out _));
Assert.False(sut.IsServerCertificate(allowMissingClientAuthEku: true, out _));
}

[Test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
Expand All @@ -16,9 +19,146 @@ public class TestFixtureWithNodeCertificateHttpAuthenticationProvider
{
protected NodeCertificateAuthenticationProvider _provider;

protected void SetUpProvider()
protected void SetUpProvider(bool allowNodeCertificateWithoutClientAuthEku = false)
{
_provider = new NodeCertificateAuthenticationProvider(() => "eventstoredb-node");
_provider = new NodeCertificateAuthenticationProvider(
() => "eventstoredb-node",
allowNodeCertificateWithoutClientAuthEku);
}
}

[TestFixture]
public class when_handling_a_node_certificate_without_the_client_auth_eku :
TestFixtureWithNodeCertificateHttpAuthenticationProvider
{
[TestCase(false, false)]
[TestCase(true, true)]
public async Task follows_the_explicit_compatibility_policy(bool allowNodeCertificateWithoutClientAuthEku,
bool expectedResult)
{
SetUpProvider(allowNodeCertificateWithoutClientAuthEku);
var context = new DefaultHttpContext();
using var certificate = CreateCertificate("eventstoredb-node");
using var presentedCertificate = await PresentCertificateOverTls(certificate);
context.Connection.ClientCertificate = presentedCertificate;

var result = _provider.Authenticate(context, out _);

Assert.AreEqual(expectedResult, result);
}

[Test]
public void does_not_relax_the_node_identity_policy()
{
SetUpProvider(allowNodeCertificateWithoutClientAuthEku: true);
var context = new DefaultHttpContext();
using var certificate = CreateCertificate("another-node");
context.Connection.ClientCertificate = certificate;

var result = _provider.Authenticate(context, out _);

Assert.False(result);
}

[Test]
public void observes_policy_changes_for_new_connections()
{
var allowNodeCertificateWithoutClientAuthEku = false;
_provider = new NodeCertificateAuthenticationProvider(
() => "eventstoredb-node",
() => allowNodeCertificateWithoutClientAuthEku);
using var certificate = CreateCertificate("eventstoredb-node");

var strictContext = new DefaultHttpContext();
strictContext.Connection.ClientCertificate = certificate;
Assert.False(_provider.Authenticate(strictContext, out _));

allowNodeCertificateWithoutClientAuthEku = true;
var compatibleContext = new DefaultHttpContext();
compatibleContext.Connection.ClientCertificate = certificate;
Assert.True(_provider.Authenticate(compatibleContext, out _));
}

private static X509Certificate2 CreateCertificate(string commonName)
{
using var rsa = RSA.Create();
var request = new CertificateRequest(
$"CN={commonName}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("localhost");
request.CertificateExtensions.Add(sanBuilder.Build());
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
critical: false));
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
[new Oid("1.3.6.1.5.5.7.3.1")],
critical: false));

return request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMonths(-1),
DateTimeOffset.UtcNow.AddMonths(1));
}

private static async Task<X509Certificate2> PresentCertificateOverTls(X509Certificate2 clientCertificate)
{
using var serverCertificate = CreateCertificate("localhost");
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();

try
{
var serverTask = Task.Run(async () =>
{
using var serverClient = await listener.AcceptTcpClientAsync();
using var serverStream = new SslStream(
serverClient.GetStream(),
leaveInnerStreamOpen: false,
(_, certificate, chain, errors) =>
{
var result = ClusterVNode<string>.ValidateClientCertificate(
certificate,
chain,
errors,
() => null,
() => new X509Certificate2Collection(clientCertificate));
return result.Item1;
});
await serverStream.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
{
ServerCertificate = serverCertificate,
ClientCertificateRequired = true,
CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
EnabledSslProtocols = SslProtocols.None
});

return X509CertificateLoader.LoadCertificate(
serverStream.RemoteCertificate.Export(X509ContentType.Cert));
});

using var client = new TcpClient();
await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint);
using var clientStream = new SslStream(
client.GetStream(),
leaveInnerStreamOpen: false,
(_, _, _, _) => true,
(_, _, _, _, _) => clientCertificate);
await clientStream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions
{
TargetHost = "localhost",
ClientCertificates = new X509CertificateCollection { clientCertificate },
CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
EnabledSslProtocols = SslProtocols.None
});

return await serverTask.WaitAsync(TimeSpan.FromSeconds(10));
}
finally
{
listener.Stop();
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ public void disable_tls_is_a_valid_parameter()
Assert.Empty(options.Unknown.Options);
}

[Fact]
public void node_certificate_client_auth_eku_validation_is_strict_by_default()
{
var configuration = EventStoreConfiguration.Build(Array.Empty<string>());
var options = ClusterVNodeOptions.FromConfiguration(configuration);

options.Certificate.AllowNodeCertificateWithoutClientAuthEku.Should().BeFalse();
}

[Fact]
public void node_certificate_client_auth_eku_compatibility_is_explicitly_configurable()
{
var options = GetOptions("--allow-node-certificate-without-client-auth-eku true");

options.Certificate.AllowNodeCertificateWithoutClientAuthEku.Should().BeTrue();
Assert.Empty(options.Unknown.Options);
}

[Fact]
public void node_certificate_client_auth_eku_compatibility_can_be_configured_from_the_environment()
{
var configuration = new ConfigurationBuilder()
.AddEventStoreDefaultValues()
.AddEventStoreEnvironmentVariables((
"EVENTSTORE_ALLOW_NODE_CERTIFICATE_WITHOUT_CLIENT_AUTH_EKU",
"true"))
.Build();
var options = ClusterVNodeOptions.FromConfiguration(configuration);

options.Certificate.AllowNodeCertificateWithoutClientAuthEku.Should().BeTrue();
}

[Fact]
public void insecure_disables_tls_and_auth()
{
Expand Down
11 changes: 9 additions & 2 deletions src/EventStore.Core/ClusterVNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ internal MultiQueuedHandler WorkersHandler
private int _systemInitPublished;
private int _shutdownStarted;
private int _reloadingConfig;
private int _allowNodeCertificateWithoutClientAuthEku;
private PosixSignalRegistration _reloadConfigSignalRegistration;
private readonly object _startupTaskGate = new();
private Task _startupTask = null!;
Expand Down Expand Up @@ -1077,8 +1078,10 @@ GossipAdvertiseInfo GetGossipAdvertiseInfo()
if (!options.Application.TlsDisabled())
{
//transport-level authentication providers
httpAuthenticationProviders.Add(
new NodeCertificateAuthenticationProvider(() => _certificateProvider.GetReservedNodeCommonName()));
httpAuthenticationProviders.Add(new NodeCertificateAuthenticationProvider(
getCertificateReservedNodeCommonName: () => _certificateProvider.GetReservedNodeCommonName(),
getAllowNodeCertificateWithoutClientAuthEku: () =>
Volatile.Read(ref _allowNodeCertificateWithoutClientAuthEku) != 0));
}

if (!options.Application.AuthDisabled() && options.Interface.EnableTrustedAuth)
Expand Down Expand Up @@ -2247,6 +2250,10 @@ private void ReloadCertificates(ClusterVNodeOptions options)
{
throw new InvalidConfigurationException("Aborting certificate loading due to verification errors.");
}

Volatile.Write(
ref _allowNodeCertificateWithoutClientAuthEku,
options.Certificate.AllowNodeCertificateWithoutClientAuthEku ? 1 : 0);
}

public override string ToString() =>
Expand Down
5 changes: 5 additions & 0 deletions src/EventStore.Core/Configuration/ClusterVNodeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,11 @@ public record CertificateOptions
[Description(
"The pattern the CN (Common Name) of a connecting EventStoreDB node must match to be authenticated. A wildcard FQDN can be specified if using wildcard certificates or if the CN is not the same on all nodes. Leave empty to automatically use the CN of this node's certificate.")]
public string CertificateReservedNodeCommonName { get; init; } = string.Empty;

[Description(
"Allows an incoming node certificate to omit the Client Authentication extended key usage. " +
"All other node certificate validation remains enabled. Enable only when the certificate authority cannot issue node certificates with both Server Authentication and Client Authentication usages.")]
public bool AllowNodeCertificateWithoutClientAuthEku { get; init; } = false;
}

[Description("Certificate Options (from store)")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@ namespace EventStore.Core.Services.Transport.Http.Authentication
public class NodeCertificateAuthenticationProvider : IHttpAuthenticationProvider
{
private readonly Func<string> _getCertificateReservedNodeCommonName;
private readonly Func<bool> _getAllowNodeCertificateWithoutClientAuthEku;

public string Name => "node-certificate";

public NodeCertificateAuthenticationProvider(Func<string> getCertificateReservedNodeCommonName)
public NodeCertificateAuthenticationProvider(Func<string> getCertificateReservedNodeCommonName,
bool allowNodeCertificateWithoutClientAuthEku = false)
: this(getCertificateReservedNodeCommonName, () => allowNodeCertificateWithoutClientAuthEku)
{
}

public NodeCertificateAuthenticationProvider(Func<string> getCertificateReservedNodeCommonName,
Func<bool> getAllowNodeCertificateWithoutClientAuthEku)
{
_getCertificateReservedNodeCommonName = getCertificateReservedNodeCommonName;
_getAllowNodeCertificateWithoutClientAuthEku = getAllowNodeCertificateWithoutClientAuthEku;
Comment thread
yordis marked this conversation as resolved.
}

public bool Authenticate(HttpContext context, out HttpAuthenticationRequest request)
Expand Down Expand Up @@ -98,7 +107,9 @@ private static bool TrySetDictionaryValue(IDictionary<object, object> dictionary
private bool AuthenticateUncached(HttpContext context, X509Certificate2 clientCertificate)
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "<unknown>";
var isServerCertificate = clientCertificate.IsServerCertificate(out var serverCertReason);
var isServerCertificate = clientCertificate.IsServerCertificate(
_getAllowNodeCertificateWithoutClientAuthEku(),
out var serverCertReason);

var reservedNodeCN = _getCertificateReservedNodeCommonName();
bool hasReservedNodeCN;
Expand Down
Loading