From da251991b94ada40cfb4c856539e98278e5a3b04 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Tue, 12 Dec 2023 20:34:09 +0000 Subject: [PATCH] Read certificates as PEM strings or from files (#283) * Read certificates as PEM strings or from files * move string-based PEM loading to callbacks (#284) Signed-off-by: Caleb Lloyd * update docs Signed-off-by: Caleb Lloyd --------- Signed-off-by: Caleb Lloyd Co-authored-by: Caleb Lloyd <2414837+caleblloyd@users.noreply.github.com> Co-authored-by: Caleb Lloyd --- src/NATS.Client.Core/Internal/TlsCerts.cs | 47 +++++-- src/NATS.Client.Core/NatsConnection.cs | 4 +- src/NATS.Client.Core/NatsTlsOpts.cs | 52 +++++++- tests/NATS.Client.Core.Tests/TlsCertsTest.cs | 120 ++++++++++++++++++ tests/NATS.Client.TestUtilities/NatsServer.cs | 4 +- 5 files changed, 204 insertions(+), 23 deletions(-) create mode 100644 tests/NATS.Client.Core.Tests/TlsCertsTest.cs diff --git a/src/NATS.Client.Core/Internal/TlsCerts.cs b/src/NATS.Client.Core/Internal/TlsCerts.cs index 843c94c69..2cd533740 100644 --- a/src/NATS.Client.Core/Internal/TlsCerts.cs +++ b/src/NATS.Client.Core/Internal/TlsCerts.cs @@ -5,29 +5,52 @@ namespace NATS.Client.Core.Internal; internal class TlsCerts { - public TlsCerts(NatsTlsOpts tlsOpts) + public X509Certificate2Collection? CaCerts { get; private set; } + + public X509Certificate2Collection? ClientCerts { get; private set; } + + public static async ValueTask FromNatsTlsOptsAsync(NatsTlsOpts tlsOpts) { + var tlsCerts = new TlsCerts(); if (tlsOpts.Mode == TlsMode.Disable) { - return; + // no certs when disabled + return tlsCerts; } - if ((tlsOpts.CertFile != default && tlsOpts.KeyFile == default) || - (tlsOpts.KeyFile != default && tlsOpts.CertFile == default)) + // validation + switch (tlsOpts) { + case { CertFile: not null, KeyFile: null } or { KeyFile: not null, CertFile: null }: throw new ArgumentException("NatsTlsOpts.CertFile and NatsTlsOpts.KeyFile must both be set"); + case { CertFile: not null, KeyFile: not null, LoadClientCert: not null }: + throw new ArgumentException("NatsTlsOpts.CertFile/KeyFile and NatsTlsOpts.LoadClientCert cannot both be set"); + case { CaFile: not null, LoadCaCerts: not null }: + throw new ArgumentException("NatsTlsOpts.CaFile and NatsTlsOpts.LoadCaCerts cannot both be set"); } + // ca certificates if (tlsOpts.CaFile != default) { - CaCerts = new X509Certificate2Collection(); - CaCerts.ImportFromPemFile(tlsOpts.CaFile); + var caCerts = new X509Certificate2Collection(); + caCerts.ImportFromPemFile(tlsOpts.CaFile); + tlsCerts.CaCerts = caCerts; + } + else if (tlsOpts.LoadCaCerts != default) + { + tlsCerts.CaCerts = await tlsOpts.LoadCaCerts().ConfigureAwait(false); } - if (tlsOpts.CertFile != default && tlsOpts.KeyFile != default) + // client certificates + var clientCert = tlsOpts switch { - var clientCert = X509Certificate2.CreateFromPemFile(tlsOpts.CertFile, tlsOpts.KeyFile); + { CertFile: not null, KeyFile: not null } => X509Certificate2.CreateFromPemFile(tlsOpts.CertFile, tlsOpts.KeyFile), + { LoadClientCert: not null } => await tlsOpts.LoadClientCert().ConfigureAwait(false), + _ => null, + }; + if (clientCert != 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 @@ -38,11 +61,9 @@ public TlsCerts(NatsTlsOpts tlsOpts) ephemeral.Dispose(); } - ClientCerts = new X509Certificate2Collection(clientCert); + tlsCerts.ClientCerts = new X509Certificate2Collection(clientCert); } - } - public X509Certificate2Collection? CaCerts { get; } - - public X509Certificate2Collection? ClientCerts { get; } + return tlsCerts; + } } diff --git a/src/NATS.Client.Core/NatsConnection.cs b/src/NATS.Client.Core/NatsConnection.cs index 2ed9c5698..1d3e22569 100644 --- a/src/NATS.Client.Core/NatsConnection.cs +++ b/src/NATS.Client.Core/NatsConnection.cs @@ -288,8 +288,8 @@ private async ValueTask InitialConnectAsync() throw new NatsException($"URI {uri} requires TLS but TlsMode is set to Disable"); } - if (Opts.TlsOpts.HasTlsFile) - _tlsCerts = new TlsCerts(Opts.TlsOpts); + if (Opts.TlsOpts.HasTlsCerts) + _tlsCerts = await TlsCerts.FromNatsTlsOptsAsync(Opts.TlsOpts).ConfigureAwait(false); if (!Opts.AuthOpts.IsAnonymous) { diff --git a/src/NATS.Client.Core/NatsTlsOpts.cs b/src/NATS.Client.Core/NatsTlsOpts.cs index 6a386cb9f..586f681a2 100644 --- a/src/NATS.Client.Core/NatsTlsOpts.cs +++ b/src/NATS.Client.Core/NatsTlsOpts.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography.X509Certificates; using NATS.Client.Core.Internal; namespace NATS.Client.Core; @@ -42,26 +43,48 @@ public sealed record NatsTlsOpts { public static readonly NatsTlsOpts Default = new(); - /// Path to PEM-encoded X509 Certificate + /// + /// String or file path to PEM-encoded X509 Certificate + /// + /// + /// Must be used in conjunction with . + /// public string? CertFile { get; init; } - /// Path to PEM-encoded Private Key + /// + /// String or file path to PEM-encoded Private Key + /// + /// /// + /// Must be used in conjunction with . + /// public string? KeyFile { get; init; } - /// Path to PEM-encoded X509 CA Certificate + /// + /// Callback that loads Client Certificate + /// + public Func>? LoadClientCert { get; init; } + + /// + /// String or file path to PEM-encoded X509 CA Certificate + /// public string? CaFile { get; init; } + /// + /// Callback that loads CA Certificates + /// + public Func>? LoadCaCerts { get; init; } + /// When true, skip remote certificate verification and accept any server certificate public bool InsecureSkipVerify { get; init; } /// TLS mode to use during connection public TlsMode Mode { get; init; } - internal bool HasTlsFile => CertFile != default || KeyFile != default || CaFile != default; + internal bool HasTlsCerts => CertFile != default || KeyFile != default || LoadClientCert != default || CaFile != default || LoadCaCerts != default; internal TlsMode EffectiveMode(NatsUri uri) => Mode switch { - TlsMode.Auto => HasTlsFile || uri.Uri.Scheme.ToLower() == "tls" ? TlsMode.Require : TlsMode.Prefer, + TlsMode.Auto => HasTlsCerts || uri.Uri.Scheme.ToLower() == "tls" ? TlsMode.Require : TlsMode.Prefer, _ => Mode, }; @@ -70,4 +93,23 @@ internal bool TryTls(NatsUri uri) var effectiveMode = EffectiveMode(uri); return effectiveMode is TlsMode.Require or TlsMode.Prefer; } + + /// + /// Helper method to load a Client Certificate from a pem-encoded string + /// + public static Func> LoadClientCertFromPem(string certPem, string keyPem) + { + var clientCert = X509Certificate2.CreateFromPem(certPem, keyPem); + return () => ValueTask.FromResult(clientCert); + } + + /// + /// Helper method to load CA Certificates from a pem-encoded string + /// + public static Func> LoadCaCertsFromPem(string caPem) + { + var caCerts = new X509Certificate2Collection(); + caCerts.ImportFromPem(caPem); + return () => ValueTask.FromResult(caCerts); + } } diff --git a/tests/NATS.Client.Core.Tests/TlsCertsTest.cs b/tests/NATS.Client.Core.Tests/TlsCertsTest.cs new file mode 100644 index 000000000..016d60918 --- /dev/null +++ b/tests/NATS.Client.Core.Tests/TlsCertsTest.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace NATS.Client.Core.Tests; + +public class TlsCertsTest +{ + [Fact] + public async Task Load_ca_cert() + { + const string caFile = "resources/certs/ca-cert.pem"; + + // CA cert from file + { + var opts = new NatsTlsOpts { CaFile = caFile }; + var certs = await TlsCerts.FromNatsTlsOptsAsync(opts); + + Assert.NotNull(certs.CaCerts); + Assert.Single(certs.CaCerts); + foreach (var cert in certs.CaCerts) + { + cert.Subject.Should().Be("CN=ca"); + } + } + + // CA cert from PEM string + { + var opts = new NatsTlsOpts { LoadCaCerts = NatsTlsOpts.LoadCaCertsFromPem(await File.ReadAllTextAsync(caFile)) }; + var certs = await TlsCerts.FromNatsTlsOptsAsync(opts); + + Assert.NotNull(certs.CaCerts); + Assert.Single(certs.CaCerts); + foreach (var cert in certs.CaCerts) + { + cert.Subject.Should().Be("CN=ca"); + } + } + } + + [Fact] + public async Task Load_client_cert_and_key() + { + const string clientCertFile = "resources/certs/client-cert.pem"; + const string clientKeyFile = "resources/certs/client-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.Single(certs.ClientCerts); + foreach (var c in certs.ClientCerts) + { + c.Subject.Should().Be("CN=client"); + var encryptValue = c.GetRSAPublicKey()!.Encrypt(Encoding.UTF8.GetBytes("test123"), RSAEncryptionPadding.OaepSHA1); + var decryptValue = c.GetRSAPrivateKey()!.Decrypt(encryptValue, RSAEncryptionPadding.OaepSHA1); + Encoding.UTF8.GetString(decryptValue).Should().Be("test123"); + } + } + } + + [Fact] + public async Task Client_connect() + { + 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(), + new NatsServerOptsBuilder() + .UseTransport(TransportType.Tls, tlsVerify: true) + .Build()); + + // Using files + await Validate(server, new NatsTlsOpts + { + CaFile = caFile, + CertFile = clientCertFile, + KeyFile = clientKeyFile, + }); + + // Using PEM strings + await Validate(server, new NatsTlsOpts + { + LoadCaCerts = NatsTlsOpts.LoadCaCertsFromPem(await File.ReadAllTextAsync(caFile)), + LoadClientCert = NatsTlsOpts.LoadClientCertFromPem(await File.ReadAllTextAsync(clientCertFile), await File.ReadAllTextAsync(clientKeyFile)), + }); + + return; + + static async Task Validate(NatsServer natsServer, NatsTlsOpts opts) + { + // overwrite the entire TLS option, because NatsServer.Start comes with some defaults + var clientOpts = natsServer.ClientOpts(NatsOpts.Default) with + { + TlsOpts = opts, + }; + + await using var nats = new NatsConnection(clientOpts); + + await nats.ConnectAsync(); + var rtt = await nats.PingAsync(); + Assert.True(rtt > TimeSpan.Zero); + } + } +} diff --git a/tests/NATS.Client.TestUtilities/NatsServer.cs b/tests/NATS.Client.TestUtilities/NatsServer.cs index 9b6a8f69a..2cea17b80 100644 --- a/tests/NATS.Client.TestUtilities/NatsServer.cs +++ b/tests/NATS.Client.TestUtilities/NatsServer.cs @@ -361,10 +361,8 @@ public NatsConnectionPool CreatePooledClientConnection(NatsOpts opts) public NatsOpts ClientOpts(NatsOpts opts) { - var tls = opts.TlsOpts ?? NatsTlsOpts.Default; - var natsTlsOpts = Opts.EnableTls - ? tls with + ? opts.TlsOpts with { CertFile = Opts.TlsClientCertFile, KeyFile = Opts.TlsClientKeyFile,