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
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//-----------------------------------------------------------------------
// <copyright file="DotNettyCertificateValidationSpec.cs" company="Akka.NET Project">
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

using System;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using Akka.Actor;
using Akka.Configuration;
using Akka.TestKit;
using Xunit;
using Xunit.Abstractions;

namespace Akka.Remote.Tests.Transport
{
/// <summary>
/// Tests that SSL certificate validation happens at startup, not during runtime.
/// This ensures fail-fast behavior when certificates are misconfigured.
/// </summary>
public class DotNettyCertificateValidationSpec : AkkaSpec
{
private const string ValidCertPath = "Resources/akka-validcert.pfx";
private const string Password = "password";
private static readonly string NoKeyCertPath = Path.Combine("Resources", "validation-no-key.cer");

public DotNettyCertificateValidationSpec(ITestOutputHelper output) : base(ConfigurationFactory.Empty, output)
{
}

private static Config CreateConfig(bool enableSsl, string certPath, string certPassword)
{
var baseConfig = ConfigurationFactory.ParseString(@"akka {
loglevel = DEBUG
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
remote.dot-netty.tcp {
port = 0
hostname = ""127.0.0.1""
enable-ssl = " + (enableSsl ? "on" : "off") + @"
log-transport = off
}
}");

if (!enableSsl || string.IsNullOrEmpty(certPath))
return baseConfig;

var escapedPath = certPath.Replace("\\", "\\\\");
var ssl = $@"akka.remote.dot-netty.tcp.ssl {{
suppress-validation = on
certificate {{
path = ""{escapedPath}""
password = ""{certPassword ?? string.Empty}""
}}
}}";
return baseConfig.WithFallback(ssl);
}

private static void CreateCertificateWithoutPrivateKey()
{
var fullCert = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.Exportable);
var publicKeyBytes = fullCert.Export(X509ContentType.Cert);
var dir = Path.GetDirectoryName(NoKeyCertPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllBytes(NoKeyCertPath, publicKeyBytes);
}

[Fact]
public void Server_should_fail_at_startup_with_certificate_without_private_key()
{
CreateCertificateWithoutPrivateKey();

try
{
// Server with cert that has no private key should FAIL TO START
var serverConfig = CreateConfig(true, NoKeyCertPath, null);

// This should throw an exception during ActorSystem.Create (wrapped in AggregateException)
var aggregateEx = Assert.Throws<AggregateException>(() =>
{
using var server = ActorSystem.Create("ServerSystem", serverConfig);
});

// Unwrap the inner exception
var innerEx = aggregateEx.InnerException ?? aggregateEx;
while (innerEx is AggregateException agg && agg.InnerException != null)
innerEx = agg.InnerException;

// Should be ConfigurationException about private key
Assert.IsType<ConfigurationException>(innerEx);
Assert.Contains("private key", innerEx.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
try
{
if (File.Exists(NoKeyCertPath))
File.Delete(NoKeyCertPath);
}
catch { /* ignore */ }
}
}

[Fact]
public void Server_should_start_successfully_with_valid_certificate()
{
// Server with valid cert should start normally
var serverConfig = CreateConfig(true, ValidCertPath, Password);

using var server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server);

// Server should be running
Assert.False(server.WhenTerminated.IsCompleted);
}

[Fact]
public void Server_should_start_successfully_without_ssl()
{
// Server without SSL should start normally
var serverConfig = CreateConfig(false, null, null);

using var server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server);

// Server should be running
Assert.False(server.WhenTerminated.IsCompleted);
}
}
}
117 changes: 14 additions & 103 deletions src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs
Copy link
Member Author

Choose a reason for hiding this comment

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

This spec is either somewhat redundant or unnecessary given the fail fast implementation - so it's been simplified.

Original file line number Diff line number Diff line change
Expand Up @@ -68,129 +68,40 @@ private static void CreateCertificateWithoutPrivateKey()


[Fact]
public async Task Tls_handshake_failure_should_be_logged_and_shutdown_server()
public async Task Server_should_fail_at_startup_with_certificate_without_private_key()
{
CreateCertificateWithoutPrivateKey();

ActorSystem server = null;
ActorSystem client = null;

try
{
// Start TLS server with a cert that has no private key
// Server with cert that has no private key should FAIL TO START
var serverConfig = CreateConfig(true, NoKeyCertPath, null, suppressValidation: true);

server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server, "[SERVER] ");

// Server started - add an echo actor and subscribe to errors
server.ActorOf(Props.Create(() => new EchoActor()), "echo");

var errorProbe = CreateTestProbe(server);
server.EventStream.Subscribe(errorProbe.Ref, typeof(Event.Error));

// Start client with valid TLS cert
var clientConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: true);
client = ActorSystem.Create("ClientSystem", clientConfig);
InitializeLogger(client, "[CLIENT] ");

var serverAddress = RARP.For(server).Provider.DefaultAddress;
var echoPath = new RootActorPath(serverAddress) / "user" / "echo";
var echoSel = client.ActorSelection(echoPath);

// Trigger association attempt
var probe = CreateTestProbe(client);
echoSel.Tell("ping", probe.Ref);

// Expect server to log TLS handshake failure promptly
var err = errorProbe.ExpectMsg<Event.Error>(TimeSpan.FromSeconds(10));
var msg = err.ToString();
Assert.Contains("TLS handshake failed", msg, StringComparison.OrdinalIgnoreCase);

// Server should shutdown due to TLS failure
await AwaitAssertAsync(async () =>
// ActorSystem.Create should throw during startup due to certificate validation
var aggregateEx = Assert.Throws<AggregateException>(() =>
{
Assert.True(server.WhenTerminated.IsCompleted);
await Task.CompletedTask;
}, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100));
}
finally
{
if (client != null)
Shutdown(client, TimeSpan.FromSeconds(10));
if (server != null)
Shutdown(server, TimeSpan.FromSeconds(10));
try
{
if (File.Exists(NoKeyCertPath))
File.Delete(NoKeyCertPath);
} catch { /* ignore */ }
}
await Task.CompletedTask;
}
using var server = ActorSystem.Create("ServerSystem", serverConfig);
});

[Fact]
public async Task Server_side_tls_handshake_failure_should_shutdown_server()
{
CreateCertificateWithoutPrivateKey();
// Unwrap to find the ConfigurationException
var innerEx = aggregateEx.InnerException ?? aggregateEx;
while (innerEx is AggregateException agg && agg.InnerException != null)
innerEx = agg.InnerException;

ActorSystem server = null;
ActorSystem client = null;

try
{
// Server with invalid server cert (no private key) -> server TLS handshake fails
var serverConfig = CreateConfig(true, NoKeyCertPath, null, suppressValidation: true);
server = ActorSystem.Create("ServerSystem", serverConfig);
InitializeLogger(server, "[SERVER] ");

// Client with valid cert
var clientConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: true);
client = ActorSystem.Create("ClientSystem", clientConfig);
InitializeLogger(client, "[CLIENT] ");

// Echo actor on server and client
var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
var clientEcho = client.ActorOf(Props.Create(() => new EchoActor()), "echo");

var serverAddr = RARP.For(server).Provider.DefaultAddress;
var clientAddr = RARP.For(client).Provider.DefaultAddress;

var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";
var clientEchoPath = new RootActorPath(clientAddr) / "user" / "echo";

// Subscribe to server errors to ensure TLS handshake failure is observed
var serverErrorProbe = CreateTestProbe(server);
server.EventStream.Subscribe(serverErrorProbe.Ref, typeof(Event.Error));

// Trigger inbound handshake failure on server: client tries to talk to server
var clientProbe = CreateTestProbe(client);
client.ActorSelection(serverEchoPath).Tell("ping", clientProbe.Ref);

// Expect server to log TLS handshake failure promptly
var err = await serverErrorProbe.ExpectMsgAsync<Event.Error>(TimeSpan.FromSeconds(10));
Assert.Contains("TLS handshake failed", err.ToString(), StringComparison.OrdinalIgnoreCase);

// Server should shutdown due to TLS failure
await AwaitAssertAsync(async () =>
{
Assert.True(server.WhenTerminated.IsCompleted);
await Task.CompletedTask;
}, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100));
// Should be ConfigurationException about private key
Assert.IsType<ConfigurationException>(innerEx);
Assert.Contains("private key", innerEx.Message, StringComparison.OrdinalIgnoreCase);
}
finally
{
if (client != null)
Shutdown(client, TimeSpan.FromSeconds(10));
if (server != null)
Shutdown(server, TimeSpan.FromSeconds(10));
try
{
if (File.Exists(NoKeyCertPath))
File.Delete(NoKeyCertPath);
}
catch { /* ignore */ }
}
await Task.CompletedTask;
}

[Fact]
Expand Down
7 changes: 7 additions & 0 deletions src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ protected async Task<IChannel> NewServer(EndPoint listenAddress)

public override async Task<(Address, TaskCompletionSource<IAssociationEventListener>)> Listen()
{
// Validate SSL certificate before starting server
// This ensures fail-fast behavior if private key is inaccessible
if (Settings.EnableSsl)
{
Settings.Ssl.ValidateCertificate();
Copy link
Member Author

Choose a reason for hiding this comment

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

Validate the private key during server binding, which should trigger shutdown of the ActorSystem if it fails

}

EndPoint listenAddress;
if (IPAddress.TryParse(Settings.Hostname, out var ip))
listenAddress = new IPEndPoint(ip, Settings.Port);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,52 @@ public SslSettings(X509Certificate2 certificate, bool suppressValidation)
SuppressValidation = suppressValidation;
}

/// <summary>
/// Validates that the SSL certificate has an accessible private key.
/// Should be called before starting the server to ensure proper TLS configuration.
/// </summary>
/// <exception cref="ConfigurationException">
/// Thrown when certificate lacks private key or application cannot access it.
/// </exception>
public void ValidateCertificate()
{
if (Certificate == null)
return; // No SSL configured

if (!Certificate.HasPrivateKey)
{
throw new ConfigurationException(
"SSL certificate does not have a private key. " +
"Ensure certificate is installed with private key permissions.");
}

// Actually test private key access (not just presence)
// SslStream supports both RSA and ECDSA keys - check both types
try
{
using (var rsaKey = Certificate.GetRSAPrivateKey())
using (var ecdsaKey = Certificate.GetECDsaPrivateKey())
{
// Certificate must have either RSA or ECDSA private key accessible
if (rsaKey == null && ecdsaKey == null)
{
throw new ConfigurationException(
"Cannot access private key for SSL certificate. " +
"Certificate has private key but application lacks permissions to access it. " +
"Verify application has permissions to the certificate's private key.");
}
// Successfully accessed private key - validation passed
}
}
catch (System.Security.Cryptography.CryptographicException ex)
{
throw new ConfigurationException(
"SSL certificate private key exists but cannot be accessed. " +
"Verify application user has permissions to the private key in certificate store. " +
$"Error: {ex.Message}", ex);
}
}

private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation)
{
using var store = new X509Store(storeName, storeLocation);
Expand Down
Loading