Skip to content

Commit

Permalink
Read certificates as PEM strings or from files (#283)
Browse files Browse the repository at this point in the history
* Read certificates as PEM strings or from files

* move string-based PEM loading to callbacks (#284)

Signed-off-by: Caleb Lloyd <caleb@synadia.com>

* update docs

Signed-off-by: Caleb Lloyd <caleb@synadia.com>

---------

Signed-off-by: Caleb Lloyd <caleb@synadia.com>
Co-authored-by: Caleb Lloyd <2414837+caleblloyd@users.noreply.github.com>
Co-authored-by: Caleb Lloyd <caleb@synadia.com>
  • Loading branch information
3 people authored Dec 12, 2023
1 parent aececb9 commit da25199
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 23 deletions.
47 changes: 34 additions & 13 deletions src/NATS.Client.Core/Internal/TlsCerts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TlsCerts> 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
Expand All @@ -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;
}
}
4 changes: 2 additions & 2 deletions src/NATS.Client.Core/NatsConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
52 changes: 47 additions & 5 deletions src/NATS.Client.Core/NatsTlsOpts.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using NATS.Client.Core.Internal;

namespace NATS.Client.Core;
Expand Down Expand Up @@ -42,26 +43,48 @@ public sealed record NatsTlsOpts
{
public static readonly NatsTlsOpts Default = new();

/// <summary>Path to PEM-encoded X509 Certificate</summary>
/// <summary>
/// String or file path to PEM-encoded X509 Certificate
/// </summary>
/// <remarks>
/// Must be used in conjunction with <see cref="KeyFile"/>.
/// </remarks>
public string? CertFile { get; init; }

/// <summary>Path to PEM-encoded Private Key</summary>
/// <summary>
/// String or file path to PEM-encoded Private Key
/// </summary>
/// /// <remarks>
/// Must be used in conjunction with <see cref="CertFile"/>.
/// </remarks>
public string? KeyFile { get; init; }

/// <summary>Path to PEM-encoded X509 CA Certificate</summary>
/// <summary>
/// Callback that loads Client Certificate
/// </summary>
public Func<ValueTask<X509Certificate2>>? LoadClientCert { get; init; }

/// <summary>
/// String or file path to PEM-encoded X509 CA Certificate
/// </summary>
public string? CaFile { get; init; }

/// <summary>
/// Callback that loads CA Certificates
/// </summary>
public Func<ValueTask<X509Certificate2Collection>>? LoadCaCerts { get; init; }

/// <summary>When true, skip remote certificate verification and accept any server certificate</summary>
public bool InsecureSkipVerify { get; init; }

/// <summary>TLS mode to use during connection</summary>
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,
};

Expand All @@ -70,4 +93,23 @@ internal bool TryTls(NatsUri uri)
var effectiveMode = EffectiveMode(uri);
return effectiveMode is TlsMode.Require or TlsMode.Prefer;
}

/// <summary>
/// Helper method to load a Client Certificate from a pem-encoded string
/// </summary>
public static Func<ValueTask<X509Certificate2>> LoadClientCertFromPem(string certPem, string keyPem)
{
var clientCert = X509Certificate2.CreateFromPem(certPem, keyPem);
return () => ValueTask.FromResult(clientCert);
}

/// <summary>
/// Helper method to load CA Certificates from a pem-encoded string
/// </summary>
public static Func<ValueTask<X509Certificate2Collection>> LoadCaCertsFromPem(string caPem)
{
var caCerts = new X509Certificate2Collection();
caCerts.ImportFromPem(caPem);
return () => ValueTask.FromResult(caCerts);
}
}
120 changes: 120 additions & 0 deletions tests/NATS.Client.Core.Tests/TlsCertsTest.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
4 changes: 1 addition & 3 deletions tests/NATS.Client.TestUtilities/NatsServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit da25199

Please sign in to comment.