Skip to content

Commit

Permalink
Add client certificate chain support and SSL context generation (#454)
Browse files Browse the repository at this point in the history
* Add client certificate chain support and SSL context generation

* Update certificate generation with certificate chain

* Cert fix for tests

* Test fix for .net6.0

* Minor doc updates

* CA cert optional fix

* CA cert optional fix

* removed CA option from client context

* Rename and docs

* rename

* rename

* merge fixes

---------

Co-authored-by: Maxon Crumb <mcrumb@starbucks.com>
Co-authored-by: Ziya Suzen <ziya@suzen.net>
  • Loading branch information
3 people authored Apr 2, 2024
1 parent 3c18544 commit 6bdf41c
Show file tree
Hide file tree
Showing 19 changed files with 754 additions and 201 deletions.
14 changes: 14 additions & 0 deletions src/NATS.Client.Core/Internal/SslStreamConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,25 @@ private SslClientAuthenticationOptions SslClientAuthenticationOptions(NatsUri ur
rcsCb = RcsCbCaCertChain;
}

#if NET8_0_OR_GREATER
SslStreamCertificateContext? streamCertificateContext = null;
if (_tlsCerts?.ClientCerts is { Count: >= 1 })
{
streamCertificateContext = SslStreamCertificateContext.Create(
_tlsCerts.ClientCerts[0],
_tlsCerts.ClientCerts);
}
#endif

var options = new SslClientAuthenticationOptions
{
TargetHost = uri.Host,
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
ClientCertificates = _tlsCerts?.ClientCerts,
#if NET8_0_OR_GREATER
ClientCertificateContext = streamCertificateContext,
CertificateChainPolicy = _tlsOpts.CertificateChainPolicy,
#endif
LocalCertificateSelectionCallback = lcsCb,
RemoteCertificateValidationCallback = rcsCb,
CertificateRevocationCheckMode = _tlsOpts.CertificateRevocationCheckMode,
Expand Down
25 changes: 17 additions & 8 deletions src/NATS.Client.Core/Internal/TlsCerts.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;

namespace NATS.Client.Core.Internal;

Expand Down Expand Up @@ -42,26 +44,33 @@ public static async ValueTask<TlsCerts> FromNatsTlsOptsAsync(NatsTlsOpts tlsOpts
}

// client certificates
var clientCert = tlsOpts switch
var clientCertsProvider = tlsOpts switch
{
{ CertFile: not null, KeyFile: not null } => X509Certificate2.CreateFromPemFile(tlsOpts.CertFile, tlsOpts.KeyFile),
{ LoadClientCert: not null } => await tlsOpts.LoadClientCert().ConfigureAwait(false),
{ CertFile: not null, KeyFile: not null } => NatsTlsOpts.LoadClientCertFromPemFile(tlsOpts.CertFile, tlsOpts.KeyFile),
{ LoadClientCert: not null } => tlsOpts.LoadClientCert,
_ => null,
};

if (clientCert != null)
var clientCerts = clientCertsProvider != null ? await clientCertsProvider().ConfigureAwait(false) : null;

if (clientCerts != null)
{
// On Windows, ephemeral keys/certificates do not work with schannel. e.g. unless stored in certificate store.
// https://github.com/dotnet/runtime/issues/66283#issuecomment-1061014225
// https://github.com/dotnet/runtime/blob/380a4723ea98067c28d54f30e1a652483a6a257a/src/libraries/System.Net.Security/tests/FunctionalTests/TestHelper.cs#L192-L197
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var ephemeral = clientCert;
clientCert = new X509Certificate2(clientCert.Export(X509ContentType.Pfx));
ephemeral.Dispose();
var windowsCerts = new X509Certificate2Collection();
foreach (var ephemeralCert in clientCerts)
{
windowsCerts.Add(new X509Certificate2(ephemeralCert.Export(X509ContentType.Pfx)));
ephemeralCert.Dispose();
}

clientCerts = windowsCerts;
}

tlsCerts.ClientCerts = new X509Certificate2Collection(clientCert);
tlsCerts.ClientCerts = clientCerts;
}

return tlsCerts;
Expand Down
80 changes: 74 additions & 6 deletions src/NATS.Client.Core/NatsTlsOpts.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using NATS.Client.Core.Internal;

Expand Down Expand Up @@ -62,7 +64,12 @@ public sealed record NatsTlsOpts
/// <summary>
/// Callback that loads Client Certificate
/// </summary>
public Func<ValueTask<X509Certificate2>>? LoadClientCert { get; init; }
/// <remarks>
/// Callback may return multiple certificates, in which case the first
/// certificate is used as the client certificate and the rest are used as intermediates.
/// Using intermediate certificates is only supported on targets .NET 8 and above.
/// </remarks>
public Func<ValueTask<X509Certificate2Collection>>? LoadClientCert { get; init; }

/// <summary>
/// String or file path to PEM-encoded X509 CA Certificate
Expand All @@ -81,30 +88,62 @@ public sealed record NatsTlsOpts
/// <value>One of the values in <see cref="T:System.Security.Cryptography.X509Certificates.X509RevocationMode" />. The default is <see langword="NoCheck" />.</value>
public X509RevocationMode CertificateRevocationCheckMode { get; init; }

/// <summary>
/// Gets or sets an optional customized policy for remote certificate
/// validation. If not <see langword="null"/>,
/// <see cref="CertificateRevocationCheckMode"/> and <see cref="SslCertificateTrust"/>
/// are ignored.
/// </summary>
/// <remarks>This option is only available in .NET 8 and above.</remarks>
public X509ChainPolicy? CertificateChainPolicy { get; init; }

/// <summary>TLS mode to use during connection</summary>
public TlsMode Mode { get; init; }

internal bool HasTlsCerts => CertFile != default || KeyFile != default || LoadClientCert != default || CaFile != default || LoadCaCerts != default;

/// <summary>
/// Helper method to load a Client Certificate from a pem-encoded string
/// Helper method to load a client certificate and its key from PEM-encoded texts.
/// </summary>
public static Func<ValueTask<X509Certificate2>> LoadClientCertFromPem(string certPem, string keyPem)
/// <param name="certPem">Text of PEM-encoded certificates</param>
/// <param name="keyPem">Text of PEM-encoded key</param>
/// <returns>Returns a callback that will return a collection of certificates</returns>
/// <remarks>
/// Client certificate string may contain multiple certificates, in which case the first
/// certificate is used as the client certificate and the rest are used as intermediates.
/// Using intermediate certificates is only supported on targets .NET 8 and above.
/// </remarks>
public static Func<ValueTask<X509Certificate2Collection>> LoadClientCertFromPem(string certPem, string keyPem)
{
var clientCert = X509Certificate2.CreateFromPem(certPem, keyPem);
return () => ValueTask.FromResult(clientCert);
var certificateCollection = LoadCertsFromMultiPem(certPem, keyPem);

return () => ValueTask.FromResult(certificateCollection);
}

/// <summary>
/// Helper method to load CA Certificates from a pem-encoded string
/// Helper method to load CA certificates from a PEM-encoded text.
/// </summary>
/// <param name="caPem">Text of PEM-encoded CA certificates</param>
/// <returns>Returns a callback that will return a collection of CA certificates</returns>
public static Func<ValueTask<X509Certificate2Collection>> LoadCaCertsFromPem(string caPem)
{
var caCerts = new X509Certificate2Collection();
caCerts.ImportFromPem(caPem);
return () => ValueTask.FromResult(caCerts);
}

/// <summary>
/// Helper method to load a client certificates and its key from PEM-encoded files
/// </summary>
internal static Func<ValueTask<X509Certificate2Collection>> LoadClientCertFromPemFile(string certPemFile, string keyPemFile)
{
var certPem = File.ReadAllText(certPemFile);
var keyPem = File.ReadAllText(keyPemFile);
var certificateCollection = LoadCertsFromMultiPem(certPem, keyPem);

return () => ValueTask.FromResult(certificateCollection);
}

internal TlsMode EffectiveMode(NatsUri uri) => Mode switch
{
TlsMode.Auto => HasTlsCerts || uri.Uri.Scheme.ToLower() == "tls" ? TlsMode.Require : TlsMode.Prefer,
Expand All @@ -116,4 +155,33 @@ internal bool TryTls(NatsUri uri)
var effectiveMode = EffectiveMode(uri);
return effectiveMode is TlsMode.Require or TlsMode.Prefer;
}

/// <summary>
/// Helper method to load certificates from a PEM-encoded text.
/// </summary>
private static X509Certificate2Collection LoadCertsFromMultiPem(ReadOnlySpan<char> certPem, ReadOnlySpan<char> keyPem)
{
var multiPemCertificateCollection = new X509Certificate2Collection();
var addKey = true;

while (PemEncoding.TryFind(certPem, out var fields))
{
X509Certificate2 certificate;

if (addKey)
{
certificate = X509Certificate2.CreateFromPem(certPem, keyPem);
addKey = false;
}
else
{
certificate = X509Certificate2.CreateFromPem(certPem);
}

multiPemCertificateCollection.Add(certificate);
certPem = certPem[fields.Location.End..];
}

return multiPemCertificateCollection;
}
}
1 change: 1 addition & 0 deletions tests/NATS.Client.Core.Tests/NATS.Client.Core.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
</ItemGroup>

<ItemGroup>
Expand Down
45 changes: 42 additions & 3 deletions tests/NATS.Client.Core.Tests/TlsCertsTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;

namespace NATS.Client.Core.Tests;

Expand Down Expand Up @@ -73,11 +75,48 @@ static async Task ValidateAsync(NatsTlsOpts opts)
}

[Fact]
public async Task Client_connect()
public async Task Load_client_cert_chain_and_key()
{
const string clientCertFile = "resources/certs/chainedclient-cert.pem";
const string clientKeyFile = "resources/certs/chainedclient-key.pem";

await ValidateAsync(new NatsTlsOpts
{
CertFile = clientCertFile,
KeyFile = clientKeyFile,
});
await ValidateAsync(new NatsTlsOpts
{
LoadClientCert = NatsTlsOpts.LoadClientCertFromPem(await File.ReadAllTextAsync(clientCertFile), await File.ReadAllTextAsync(clientKeyFile)),
});

return;

static async Task ValidateAsync(NatsTlsOpts opts)
{
var certs = await TlsCerts.FromNatsTlsOptsAsync(opts);

Assert.NotNull(certs.ClientCerts);
Assert.Equal(3, certs.ClientCerts.Count);

certs.ClientCerts[0].Subject.Should().Be("CN=leafclient");
var encryptValue = certs.ClientCerts[0].GetRSAPublicKey()!.Encrypt(Encoding.UTF8.GetBytes("test123"), RSAEncryptionPadding.OaepSHA1);
var decryptValue = certs.ClientCerts[0].GetRSAPrivateKey()!.Decrypt(encryptValue, RSAEncryptionPadding.OaepSHA1);
Encoding.UTF8.GetString(decryptValue).Should().Be("test123");
certs.ClientCerts[1].Subject.Should().Be("CN=intermediate02");
certs.ClientCerts[2].Subject.Should().Be("CN=intermediate01");
}
}

[SkippableTheory]
[InlineData("resources/certs/client-cert.pem", "resources/certs/client-key.pem", 6)]
[InlineData("resources/certs/chainedclient-cert.pem", "resources/certs/chainedclient-key.pem", 8)]
public async Task Client_connect(string clientCertFile, string clientKeyFile, int minimumFrameworkVersion)
{
var version = int.Parse(Regex.Match(RuntimeInformation.FrameworkDescription, @"(\d+)\.\d").Groups[1].Value);
Skip.IfNot(version >= minimumFrameworkVersion, $"Requires .NET {minimumFrameworkVersion}");

const string caFile = "resources/certs/ca-cert.pem";
const string clientCertFile = "resources/certs/client-cert.pem";
const string clientKeyFile = "resources/certs/client-key.pem";

await using var server = NatsServer.Start(
new NullOutputHelper(),
Expand Down
52 changes: 26 additions & 26 deletions tests/NATS.Client.Core.Tests/resources/certs/ca-cert.pem
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
-----BEGIN CERTIFICATE-----
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
-----END CERTIFICATE-----
Loading

0 comments on commit 6bdf41c

Please sign in to comment.