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

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; }
}
}
168 changes: 167 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 @@ -182,6 +187,7 @@ public async Task Mutual_TLS_should_fail_when_client_has_no_certificate()
InitializeLogger(client, "[CLIENT] ");

// Should fail to connect because server requires client certificate
// Enhanced error message "no client certificate provided" will be logged to server logs
await Assert.ThrowsAsync<AskTimeoutException>(async () =>
{
await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(3));
Expand Down Expand Up @@ -259,6 +265,7 @@ public async Task Mutual_TLS_should_fail_when_client_has_different_valid_certifi
InitializeLogger(client, "[CLIENT] ");

// Connection should fail due to certificate mismatch
// Enhanced error message with certificate validation details will be logged to server logs
await Assert.ThrowsAsync<AskTimeoutException>(async () =>
{
await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(3));
Expand All @@ -273,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()
{
// 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)")]
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
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,11 @@ public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_p

var realException = GetInnerMostException<CryptographicException>(aggregateException);
Assert.NotNull(realException);
// TODO: this error message is not correct, but wanted to keep this assertion here in case someone else
// wants to fix it in the future.
//Assert.Equal("The specified network password is not correct.", realException.Message);
// NOTE: The error message for incorrect certificate password comes from the .NET Framework
// during X509Certificate2 construction, not from our code. The exact message is platform-dependent
// (e.g., "The specified network password is not correct" on Windows, different on Linux).
// We cannot improve this message as it's not generated by our TLS handshake code.
// Enhanced error messages are provided during TLS handshake failures (see DotNettyTlsHandshakeFailureSpec).
}

[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ public async Task Client_side_tls_handshake_failure_should_shutdown_client()
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";

// Trigger TLS handshake failure during association
// The enhanced error message will be logged, but we can't easily assert on it
// in a multi-system test without using the TestKit's Sys
client.ActorSelection(serverEchoPath).Tell("hello");

// Client should shutdown due to TLS failure
Expand Down
Loading
Loading