diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettyCertificateValidationSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettyCertificateValidationSpec.cs new file mode 100644 index 00000000000..b4dcf64c630 --- /dev/null +++ b/src/core/Akka.Remote.Tests/Transport/DotNettyCertificateValidationSpec.cs @@ -0,0 +1,132 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +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 +{ + /// + /// Tests that SSL certificate validation happens at startup, not during runtime. + /// This ensures fail-fast behavior when certificates are misconfigured. + /// + 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(() => + { + 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(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); + } + } +} diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs index 9332445cf63..f88647f79d1 100644 --- a/src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs @@ -68,122 +68,32 @@ 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(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(() => { - 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(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(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)) @@ -191,6 +101,7 @@ await AwaitAssertAsync(async () => } catch { /* ignore */ } } + await Task.CompletedTask; } [Fact] diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs index c62da27b042..566321fdcbc 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs @@ -180,6 +180,13 @@ protected async Task NewServer(EndPoint listenAddress) public override async Task<(Address, TaskCompletionSource)> Listen() { + // Validate SSL certificate before starting server + // This ensures fail-fast behavior if private key is inaccessible + if (Settings.EnableSsl) + { + Settings.Ssl.ValidateCertificate(); + } + EndPoint listenAddress; if (IPAddress.TryParse(Settings.Hostname, out var ip)) listenAddress = new IPEndPoint(ip, Settings.Port); diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs index 26135f86c0d..c84bac48360 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs @@ -342,6 +342,52 @@ public SslSettings(X509Certificate2 certificate, bool suppressValidation) SuppressValidation = suppressValidation; } + /// + /// Validates that the SSL certificate has an accessible private key. + /// Should be called before starting the server to ensure proper TLS configuration. + /// + /// + /// Thrown when certificate lacks private key or application cannot access it. + /// + 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);