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
308 changes: 276 additions & 32 deletions docs/articles/remoting/security.md
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaned up the docs and mentioned the new settings introduced in this PR, along with where certs are sourced from in the store / how they're used.

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/cSpell.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"Hasher",
"Hipsterize",
"HOCON",
"hostnames",
"journaled",
"Kubernetes",
"lifecycles",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,11 @@ namespace Akka.Remote.Transport.DotNetty
public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup
{
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { }
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { }
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { }
public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; }
public bool RequireMutualAuthentication { get; }
public bool SuppressValidation { get; }
public bool ValidateCertificateHostname { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,11 @@ namespace Akka.Remote.Transport.DotNetty
public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup
{
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { }
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { }
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { }
public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; }
public bool RequireMutualAuthentication { get; }
public bool SuppressValidation { get; }
public bool ValidateCertificateHostname { get; }
}
}
166 changes: 165 additions & 1 deletion src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public DotNettyMutualTlsSpec(ITestOutputHelper output) : base(ConfigurationFacto
{
}

private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null)
private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null, bool? validateCertificateHostname = null)
{
var config = ConfigurationFactory.ParseString($@"
akka {{
Expand All @@ -49,10 +49,15 @@ private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool
return config;

var escapedPath = (certPath ?? ValidCertPath).Replace("\\", "\\\\");
var hostnameValidationConfig = validateCertificateHostname.HasValue
? $"validate-certificate-hostname = {(validateCertificateHostname.Value ? "on" : "off")}"
: "";

var ssl = $@"
akka.remote.dot-netty.tcp.ssl {{
suppress-validation = {(suppressValidation ? "on" : "off")}
require-mutual-authentication = {(requireMutualAuth ? "on" : "off")}
{hostnameValidationConfig}
certificate {{
path = ""{escapedPath}""
password = ""{Password}""
Expand Down Expand Up @@ -275,6 +280,165 @@ await Assert.ThrowsAsync<AskTimeoutException>(async () =>
}
}

[Fact(DisplayName = "Different certificates with hostname validation disabled should connect successfully")]
public async Task Hostname_validation_disabled_should_allow_different_certificates()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a tad difficult for us to robustly test this functionality without doing things like installing custom CAs, so we're doing this all with self-signed certificates with hostname validation and CA validation both disabled.

{
// Per-node certificates should work when hostname validation is disabled
// Note: Using suppressValidation=true to bypass chain validation since test certs are self-signed
// This isolates the hostname validation logic we're testing
ActorSystem server = null;
ActorSystem client = null;

try
{
// Server with one certificate, hostname validation disabled
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ValidCertPath, validateCertificateHostname: false);
server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server, "[SERVER] ");

var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
var serverAddr = RARP.For(server).Provider.DefaultAddress;
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";

// Client with different certificate, hostname validation disabled
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ClientCertPath, validateCertificateHostname: false);
client = ActorSystem.Create("ClientSystem", clientConfig);
InitializeLogger(client, "[CLIENT] ");

// Should successfully connect because hostname validation is disabled
var response = await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(5));
Assert.Equal("hello", response);
}
finally
{
if (client != null)
Shutdown(client, TimeSpan.FromSeconds(10));
if (server != null)
Shutdown(server, TimeSpan.FromSeconds(10));
}
}

[Fact(DisplayName = "Different certificates with hostname validation enabled should fail with name mismatch")]
public async Task Hostname_validation_enabled_should_reject_different_certificates()
{
// When hostname validation is enabled, different certificates should fail with RemoteCertificateNameMismatch
// Note: Using suppressValidation=true to bypass chain validation and test hostname validation specifically
ActorSystem server = null;
ActorSystem client = null;

try
{
// Server with one certificate, hostname validation enabled
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ValidCertPath, validateCertificateHostname: true);
server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server, "[SERVER] ");

var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
var serverAddr = RARP.For(server).Provider.DefaultAddress;
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";

// Client with different certificate, hostname validation enabled
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ClientCertPath, validateCertificateHostname: true);
client = ActorSystem.Create("ClientSystem", clientConfig);
InitializeLogger(client, "[CLIENT] ");

// Should fail because hostname in certificate doesn't match connection target (127.0.0.1)
await Assert.ThrowsAsync<AskTimeoutException>(async () =>
{
await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(3));
});
}
finally
{
if (client != null)
Shutdown(client, TimeSpan.FromSeconds(10));
if (server != null)
Shutdown(server, TimeSpan.FromSeconds(10));
}
}

[Fact(DisplayName = "Same certificate should connect successfully (typical mutual TLS scenario)")]
public async Task Same_certificate_should_connect_in_mutual_tls()
{
// Typical mutual TLS: Both nodes use the same shared certificate
// Hostname validation disabled because we're using IPs/per-node certs
ActorSystem server = null;
ActorSystem client = null;

try
{
// Server with same certificate, hostname validation disabled (typical for mutual TLS)
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ValidCertPath, validateCertificateHostname: false);
server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server, "[SERVER] ");

var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
var serverAddr = RARP.For(server).Provider.DefaultAddress;
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";

// Client with same certificate, hostname validation disabled
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ValidCertPath, validateCertificateHostname: false);
client = ActorSystem.Create("ClientSystem", clientConfig);
InitializeLogger(client, "[CLIENT] ");

// Should successfully connect - typical mutual TLS scenario
var response = await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(5));
Assert.Equal("hello", response);
}
finally
{
if (client != null)
Shutdown(client, TimeSpan.FromSeconds(10));
if (server != null)
Shutdown(server, TimeSpan.FromSeconds(10));
}
}

[Fact(DisplayName = "Hostname validation unspecified should default to disabled (backward compatibility)")]
public async Task Hostname_validation_default_should_be_disabled()
{
// When validate-certificate-hostname is not specified, it should default to false
// Note: Using suppressValidation=true to bypass chain validation and test hostname default behavior
ActorSystem server = null;
ActorSystem client = null;

try
{
// Server without specifying hostname validation (should default to false)
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ValidCertPath, validateCertificateHostname: null);
server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server, "[SERVER] ");

var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
var serverAddr = RARP.For(server).Provider.DefaultAddress;
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";

// Client with different certificate, hostname validation unspecified (should default to false)
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
certPath: ClientCertPath, validateCertificateHostname: null);
client = ActorSystem.Create("ClientSystem", clientConfig);
InitializeLogger(client, "[CLIENT] ");

// Should successfully connect because hostname validation defaults to disabled
var response = await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(5));
Assert.Equal("hello", response);
}
finally
{
if (client != null)
Shutdown(client, TimeSpan.FromSeconds(10));
if (server != null)
Shutdown(server, TimeSpan.FromSeconds(10));
}
}

private sealed class EchoActor : ReceiveActor
{
public EchoActor()
Expand Down
90 changes: 90 additions & 0 deletions src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,96 @@ await Assert.ThrowsAsync<RemoteTransportException>(async () =>
});
}

[Fact(DisplayName = "DotNettySslSetup with 2 parameters should configure effective DotNettyTransportSettings with defaults (RequireMutualAuth=true, ValidateHostname=false)")]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the DotNettySslSetup so this stuff can get incorporated into that configuration path as well

public void Two_parameter_setup_should_configure_transport_settings_with_defaults()
{
var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet);
var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true);

var actorSystemSetup = ActorSystemSetup.Empty
.And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@"
akka {
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
remote.dot-netty.tcp {
port = 0
hostname = ""127.0.0.1""
enable-ssl = true
}
}")))
.And(sslSetup);

using var sys = ActorSystem.Create("test", actorSystemSetup);

// Verify that DotNettyTransportSettings.Create uses the setup correctly
var settings = DotNettyTransportSettings.Create(sys);

Assert.True(settings.EnableSsl);
Assert.Equal(certificate, settings.Ssl.Certificate);
Assert.True(settings.Ssl.SuppressValidation);
Assert.True(settings.Ssl.RequireMutualAuthentication); // default from 2-param constructor
Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 2-param constructor
}

[Fact(DisplayName = "DotNettySslSetup with 3 parameters should configure effective DotNettyTransportSettings with specified RequireMutualAuth and default ValidateHostname=false")]
public void Three_parameter_setup_should_configure_transport_settings()
{
var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet);
var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: false);

var actorSystemSetup = ActorSystemSetup.Empty
.And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@"
akka {
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
remote.dot-netty.tcp {
port = 0
hostname = ""127.0.0.1""
enable-ssl = true
}
}")))
.And(sslSetup);

using var sys = ActorSystem.Create("test", actorSystemSetup);

// Verify that DotNettyTransportSettings.Create uses the setup correctly
var settings = DotNettyTransportSettings.Create(sys);

Assert.True(settings.EnableSsl);
Assert.Equal(certificate, settings.Ssl.Certificate);
Assert.False(settings.Ssl.SuppressValidation);
Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false
Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 3-param constructor
}

[Fact(DisplayName = "DotNettySslSetup with 4 parameters should configure effective DotNettyTransportSettings with all specified values")]
public void Four_parameter_setup_should_configure_transport_settings_with_all_values()
{
var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet);
var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true, requireMutualAuthentication: false, validateCertificateHostname: true);

var actorSystemSetup = ActorSystemSetup.Empty
.And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@"
akka {
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
remote.dot-netty.tcp {
port = 0
hostname = ""127.0.0.1""
enable-ssl = true
}
}")))
.And(sslSetup);

using var sys = ActorSystem.Create("test", actorSystemSetup);

// Verify that DotNettyTransportSettings.Create uses the setup correctly
var settings = DotNettyTransportSettings.Create(sys);

Assert.True(settings.EnableSsl);
Assert.Equal(certificate, settings.Ssl.Certificate);
Assert.True(settings.Ssl.SuppressValidation);
Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false
Assert.True(settings.Ssl.ValidateCertificateHostname); // explicitly set to true
}

#region helper classes / methods

protected override void AfterAll()
Expand Down
12 changes: 12 additions & 0 deletions src/core/Akka.Remote/Configuration/Remote.conf
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,18 @@ akka {
# Set to false only if your environment cannot support client certificate authentication.
# Default: true (secure by default)
require-mutual-authentication = true

# Enable or disable certificate hostname validation during TLS handshake.
# When true: Traditional TLS hostname validation is performed (certificate CN/SAN must match target hostname)
# When false: Only validates certificate chain against CA, ignores hostname mismatches
#
# Set to false for scenarios such as:
# - Mutual TLS with per-node certificates in P2P clusters
# - IP-based connections where certificates use DNS names
# - Service discovery with dynamic addresses
#
# Default: false (disabled for backward compatibility and mutual TLS flexibility)
validate-certificate-hostname = false
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New setting, for validating the hostnames on the provided certificates. This should default to false so IP-based connections can still function correctly, but it can be enabled for zero-trust environments.

}
}

Expand Down
Loading
Loading