Skip to content

Commit 5994efc

Browse files
Fix: Validate SSL certificate private key access at server startup (#7847) (#7848)
* Fix: Validate SSL certificate private key access at server startup **Problem**: Akka.Remote server starts successfully even when the application lacks permissions to access the SSL certificate's private key. The server appears healthy but fails when clients attempt to connect, making issues hard to diagnose. **Root Cause**: Certificate loading in DotNettyTransportSettings only validates that the certificate EXISTS in the Windows certificate store, not whether the application can ACCESS the private key. Private key access is checked separately by Windows ACL, which can fail even when Certificate.HasPrivateKey returns true. **Solution**: 1. Add ValidateCertificate() method to SslSettings class that: - Checks Certificate.HasPrivateKey - Actually tests private key access with GetRSAPrivateKey() (not just presence) - Throws ConfigurationException with clear error message on failure 2. Call validation in Listen() method before server socket binds: - Ensures fail-fast behavior at startup - Prevents server from running in broken state - Provides clear error message for administrators 3. Add comprehensive tests: - Server should fail at startup with inaccessible private key - Server should start successfully with valid certificate - Server should start successfully without SSL **Impact**: - Existing misconfigured deployments will now fail at startup (correct behavior) - Clear error messages guide administrators to fix permissions - No breaking changes for correctly configured systems - Related to Freshdesk #538 (BNSF Railway) Fixes #538 * Update DotNettyTlsHandshakeFailureSpec to validate fail-fast behavior **Changes**: 1. Renamed first test to `Server_should_fail_at_startup_with_certificate_without_private_key` - Now validates that server FAILS AT STARTUP with bad certificate - Tests fail-fast behavior instead of runtime TLS handshake failure 2. Removed redundant `Server_side_tls_handshake_failure_should_shutdown_server` test - This test validated the OLD (incorrect) behavior where server starts successfully - Now impossible with fail-fast validation in place - Scenario already covered by the updated first test 3. Kept `Client_side_tls_handshake_failure_should_shutdown_client` unchanged - Still valid - tests client-side validation failure - Not affected by server startup validation **Result**: Tests now validate correct fail-fast behavior at server startup * Add ECDSA private key validation and improve disposal pattern Addresses review feedback from @Arkatufus: **Changes**: 1. Check both RSA and ECDSA private keys - SslStream supports both RSA and ECDSA certificates - GetRSAPrivateKey() returns null for ECDSA certs (and vice versa) - Validation now checks both key types to match TLS handler behavior 2. Use `using` statements for proper disposal - Prevents resource leaks if exception is thrown - Both rsaKey and ecdsaKey are properly disposed - Exception-safe resource management **TLS Handler Relationship**: The TLS handler uses `TlsHandler.Server(Settings.Ssl.Certificate)` which internally extracts either RSA or ECDSA private keys via SslStream. Our validation now matches this behavior by checking both key types. **Behavior**: - RSA certificate: GetRSAPrivateKey() succeeds, GetECDsaPrivateKey() returns null ✅ - ECDSA certificate: GetECDsaPrivateKey() succeeds, GetRSAPrivateKey() returns null ✅ - Neither accessible: Both return null, validation fails with clear error ✅ - Permission denied: CryptographicException caught, clear error message ✅
1 parent 68dbe75 commit 5994efc

File tree

4 files changed

+199
-103
lines changed

4 files changed

+199
-103
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
//-----------------------------------------------------------------------
2+
// <copyright file="DotNettyCertificateValidationSpec.cs" company="Akka.NET Project">
3+
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
4+
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
5+
// </copyright>
6+
//-----------------------------------------------------------------------
7+
8+
using System;
9+
using System.IO;
10+
using System.Security.Cryptography.X509Certificates;
11+
using Akka.Actor;
12+
using Akka.Configuration;
13+
using Akka.TestKit;
14+
using Xunit;
15+
using Xunit.Abstractions;
16+
17+
namespace Akka.Remote.Tests.Transport
18+
{
19+
/// <summary>
20+
/// Tests that SSL certificate validation happens at startup, not during runtime.
21+
/// This ensures fail-fast behavior when certificates are misconfigured.
22+
/// </summary>
23+
public class DotNettyCertificateValidationSpec : AkkaSpec
24+
{
25+
private const string ValidCertPath = "Resources/akka-validcert.pfx";
26+
private const string Password = "password";
27+
private static readonly string NoKeyCertPath = Path.Combine("Resources", "validation-no-key.cer");
28+
29+
public DotNettyCertificateValidationSpec(ITestOutputHelper output) : base(ConfigurationFactory.Empty, output)
30+
{
31+
}
32+
33+
private static Config CreateConfig(bool enableSsl, string certPath, string certPassword)
34+
{
35+
var baseConfig = ConfigurationFactory.ParseString(@"akka {
36+
loglevel = DEBUG
37+
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
38+
remote.dot-netty.tcp {
39+
port = 0
40+
hostname = ""127.0.0.1""
41+
enable-ssl = " + (enableSsl ? "on" : "off") + @"
42+
log-transport = off
43+
}
44+
}");
45+
46+
if (!enableSsl || string.IsNullOrEmpty(certPath))
47+
return baseConfig;
48+
49+
var escapedPath = certPath.Replace("\\", "\\\\");
50+
var ssl = $@"akka.remote.dot-netty.tcp.ssl {{
51+
suppress-validation = on
52+
certificate {{
53+
path = ""{escapedPath}""
54+
password = ""{certPassword ?? string.Empty}""
55+
}}
56+
}}";
57+
return baseConfig.WithFallback(ssl);
58+
}
59+
60+
private static void CreateCertificateWithoutPrivateKey()
61+
{
62+
var fullCert = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.Exportable);
63+
var publicKeyBytes = fullCert.Export(X509ContentType.Cert);
64+
var dir = Path.GetDirectoryName(NoKeyCertPath);
65+
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
66+
Directory.CreateDirectory(dir);
67+
File.WriteAllBytes(NoKeyCertPath, publicKeyBytes);
68+
}
69+
70+
[Fact]
71+
public void Server_should_fail_at_startup_with_certificate_without_private_key()
72+
{
73+
CreateCertificateWithoutPrivateKey();
74+
75+
try
76+
{
77+
// Server with cert that has no private key should FAIL TO START
78+
var serverConfig = CreateConfig(true, NoKeyCertPath, null);
79+
80+
// This should throw an exception during ActorSystem.Create (wrapped in AggregateException)
81+
var aggregateEx = Assert.Throws<AggregateException>(() =>
82+
{
83+
using var server = ActorSystem.Create("ServerSystem", serverConfig);
84+
});
85+
86+
// Unwrap the inner exception
87+
var innerEx = aggregateEx.InnerException ?? aggregateEx;
88+
while (innerEx is AggregateException agg && agg.InnerException != null)
89+
innerEx = agg.InnerException;
90+
91+
// Should be ConfigurationException about private key
92+
Assert.IsType<ConfigurationException>(innerEx);
93+
Assert.Contains("private key", innerEx.Message, StringComparison.OrdinalIgnoreCase);
94+
}
95+
finally
96+
{
97+
try
98+
{
99+
if (File.Exists(NoKeyCertPath))
100+
File.Delete(NoKeyCertPath);
101+
}
102+
catch { /* ignore */ }
103+
}
104+
}
105+
106+
[Fact]
107+
public void Server_should_start_successfully_with_valid_certificate()
108+
{
109+
// Server with valid cert should start normally
110+
var serverConfig = CreateConfig(true, ValidCertPath, Password);
111+
112+
using var server = ActorSystem.Create("ServerSystem", serverConfig);
113+
InitializeLogger(server);
114+
115+
// Server should be running
116+
Assert.False(server.WhenTerminated.IsCompleted);
117+
}
118+
119+
[Fact]
120+
public void Server_should_start_successfully_without_ssl()
121+
{
122+
// Server without SSL should start normally
123+
var serverConfig = CreateConfig(false, null, null);
124+
125+
using var server = ActorSystem.Create("ServerSystem", serverConfig);
126+
InitializeLogger(server);
127+
128+
// Server should be running
129+
Assert.False(server.WhenTerminated.IsCompleted);
130+
}
131+
}
132+
}

src/core/Akka.Remote.Tests/Transport/DotNettyTlsHandshakeFailureSpec.cs

Lines changed: 14 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -68,129 +68,40 @@ private static void CreateCertificateWithoutPrivateKey()
6868

6969

7070
[Fact]
71-
public async Task Tls_handshake_failure_should_be_logged_and_shutdown_server()
71+
public async Task Server_should_fail_at_startup_with_certificate_without_private_key()
7272
{
7373
CreateCertificateWithoutPrivateKey();
7474

75-
ActorSystem server = null;
76-
ActorSystem client = null;
77-
7875
try
7976
{
80-
// Start TLS server with a cert that has no private key
77+
// Server with cert that has no private key should FAIL TO START
8178
var serverConfig = CreateConfig(true, NoKeyCertPath, null, suppressValidation: true);
8279

83-
server = ActorSystem.Create("ServerSystem", serverConfig);
84-
InitializeLogger(server, "[SERVER] ");
85-
86-
// Server started - add an echo actor and subscribe to errors
87-
server.ActorOf(Props.Create(() => new EchoActor()), "echo");
88-
89-
var errorProbe = CreateTestProbe(server);
90-
server.EventStream.Subscribe(errorProbe.Ref, typeof(Event.Error));
91-
92-
// Start client with valid TLS cert
93-
var clientConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: true);
94-
client = ActorSystem.Create("ClientSystem", clientConfig);
95-
InitializeLogger(client, "[CLIENT] ");
96-
97-
var serverAddress = RARP.For(server).Provider.DefaultAddress;
98-
var echoPath = new RootActorPath(serverAddress) / "user" / "echo";
99-
var echoSel = client.ActorSelection(echoPath);
100-
101-
// Trigger association attempt
102-
var probe = CreateTestProbe(client);
103-
echoSel.Tell("ping", probe.Ref);
104-
105-
// Expect server to log TLS handshake failure promptly
106-
var err = errorProbe.ExpectMsg<Event.Error>(TimeSpan.FromSeconds(10));
107-
var msg = err.ToString();
108-
Assert.Contains("TLS handshake failed", msg, StringComparison.OrdinalIgnoreCase);
109-
110-
// Server should shutdown due to TLS failure
111-
await AwaitAssertAsync(async () =>
80+
// ActorSystem.Create should throw during startup due to certificate validation
81+
var aggregateEx = Assert.Throws<AggregateException>(() =>
11282
{
113-
Assert.True(server.WhenTerminated.IsCompleted);
114-
await Task.CompletedTask;
115-
}, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100));
116-
}
117-
finally
118-
{
119-
if (client != null)
120-
Shutdown(client, TimeSpan.FromSeconds(10));
121-
if (server != null)
122-
Shutdown(server, TimeSpan.FromSeconds(10));
123-
try
124-
{
125-
if (File.Exists(NoKeyCertPath))
126-
File.Delete(NoKeyCertPath);
127-
} catch { /* ignore */ }
128-
}
129-
await Task.CompletedTask;
130-
}
83+
using var server = ActorSystem.Create("ServerSystem", serverConfig);
84+
});
13185

132-
[Fact]
133-
public async Task Server_side_tls_handshake_failure_should_shutdown_server()
134-
{
135-
CreateCertificateWithoutPrivateKey();
86+
// Unwrap to find the ConfigurationException
87+
var innerEx = aggregateEx.InnerException ?? aggregateEx;
88+
while (innerEx is AggregateException agg && agg.InnerException != null)
89+
innerEx = agg.InnerException;
13690

137-
ActorSystem server = null;
138-
ActorSystem client = null;
139-
140-
try
141-
{
142-
// Server with invalid server cert (no private key) -> server TLS handshake fails
143-
var serverConfig = CreateConfig(true, NoKeyCertPath, null, suppressValidation: true);
144-
server = ActorSystem.Create("ServerSystem", serverConfig);
145-
InitializeLogger(server, "[SERVER] ");
146-
147-
// Client with valid cert
148-
var clientConfig = CreateConfig(true, ValidCertPath, Password, suppressValidation: true);
149-
client = ActorSystem.Create("ClientSystem", clientConfig);
150-
InitializeLogger(client, "[CLIENT] ");
151-
152-
// Echo actor on server and client
153-
var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
154-
var clientEcho = client.ActorOf(Props.Create(() => new EchoActor()), "echo");
155-
156-
var serverAddr = RARP.For(server).Provider.DefaultAddress;
157-
var clientAddr = RARP.For(client).Provider.DefaultAddress;
158-
159-
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";
160-
var clientEchoPath = new RootActorPath(clientAddr) / "user" / "echo";
161-
162-
// Subscribe to server errors to ensure TLS handshake failure is observed
163-
var serverErrorProbe = CreateTestProbe(server);
164-
server.EventStream.Subscribe(serverErrorProbe.Ref, typeof(Event.Error));
165-
166-
// Trigger inbound handshake failure on server: client tries to talk to server
167-
var clientProbe = CreateTestProbe(client);
168-
client.ActorSelection(serverEchoPath).Tell("ping", clientProbe.Ref);
169-
170-
// Expect server to log TLS handshake failure promptly
171-
var err = await serverErrorProbe.ExpectMsgAsync<Event.Error>(TimeSpan.FromSeconds(10));
172-
Assert.Contains("TLS handshake failed", err.ToString(), StringComparison.OrdinalIgnoreCase);
173-
174-
// Server should shutdown due to TLS failure
175-
await AwaitAssertAsync(async () =>
176-
{
177-
Assert.True(server.WhenTerminated.IsCompleted);
178-
await Task.CompletedTask;
179-
}, TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100));
91+
// Should be ConfigurationException about private key
92+
Assert.IsType<ConfigurationException>(innerEx);
93+
Assert.Contains("private key", innerEx.Message, StringComparison.OrdinalIgnoreCase);
18094
}
18195
finally
18296
{
183-
if (client != null)
184-
Shutdown(client, TimeSpan.FromSeconds(10));
185-
if (server != null)
186-
Shutdown(server, TimeSpan.FromSeconds(10));
18797
try
18898
{
18999
if (File.Exists(NoKeyCertPath))
190100
File.Delete(NoKeyCertPath);
191101
}
192102
catch { /* ignore */ }
193103
}
104+
await Task.CompletedTask;
194105
}
195106

196107
[Fact]

src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ protected async Task<IChannel> NewServer(EndPoint listenAddress)
180180

181181
public override async Task<(Address, TaskCompletionSource<IAssociationEventListener>)> Listen()
182182
{
183+
// Validate SSL certificate before starting server
184+
// This ensures fail-fast behavior if private key is inaccessible
185+
if (Settings.EnableSsl)
186+
{
187+
Settings.Ssl.ValidateCertificate();
188+
}
189+
183190
EndPoint listenAddress;
184191
if (IPAddress.TryParse(Settings.Hostname, out var ip))
185192
listenAddress = new IPEndPoint(ip, Settings.Port);

src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,52 @@ public SslSettings(X509Certificate2 certificate, bool suppressValidation)
342342
SuppressValidation = suppressValidation;
343343
}
344344

345+
/// <summary>
346+
/// Validates that the SSL certificate has an accessible private key.
347+
/// Should be called before starting the server to ensure proper TLS configuration.
348+
/// </summary>
349+
/// <exception cref="ConfigurationException">
350+
/// Thrown when certificate lacks private key or application cannot access it.
351+
/// </exception>
352+
public void ValidateCertificate()
353+
{
354+
if (Certificate == null)
355+
return; // No SSL configured
356+
357+
if (!Certificate.HasPrivateKey)
358+
{
359+
throw new ConfigurationException(
360+
"SSL certificate does not have a private key. " +
361+
"Ensure certificate is installed with private key permissions.");
362+
}
363+
364+
// Actually test private key access (not just presence)
365+
// SslStream supports both RSA and ECDSA keys - check both types
366+
try
367+
{
368+
using (var rsaKey = Certificate.GetRSAPrivateKey())
369+
using (var ecdsaKey = Certificate.GetECDsaPrivateKey())
370+
{
371+
// Certificate must have either RSA or ECDSA private key accessible
372+
if (rsaKey == null && ecdsaKey == null)
373+
{
374+
throw new ConfigurationException(
375+
"Cannot access private key for SSL certificate. " +
376+
"Certificate has private key but application lacks permissions to access it. " +
377+
"Verify application has permissions to the certificate's private key.");
378+
}
379+
// Successfully accessed private key - validation passed
380+
}
381+
}
382+
catch (System.Security.Cryptography.CryptographicException ex)
383+
{
384+
throw new ConfigurationException(
385+
"SSL certificate private key exists but cannot be accessed. " +
386+
"Verify application user has permissions to the private key in certificate store. " +
387+
$"Error: {ex.Message}", ex);
388+
}
389+
}
390+
345391
private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation)
346392
{
347393
using var store = new X509Store(storeName, storeLocation);

0 commit comments

Comments
 (0)