Skip to content

Commit 062275c

Browse files
committed
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 akkadotnet#538 (BNSF Railway) Fixes akkadotnet#538
1 parent 8d99e83 commit 062275c

File tree

3 files changed

+181
-0
lines changed

3 files changed

+181
-0
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/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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,48 @@ 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+
try
366+
{
367+
using (var rsa = Certificate.GetRSAPrivateKey())
368+
{
369+
if (rsa == null)
370+
{
371+
throw new ConfigurationException(
372+
"Cannot access private key for SSL certificate. " +
373+
"Verify application has permissions to the certificate's private key.");
374+
}
375+
// Successfully accessed private key - validation passed
376+
}
377+
}
378+
catch (System.Security.Cryptography.CryptographicException ex)
379+
{
380+
throw new ConfigurationException(
381+
"SSL certificate private key exists but cannot be accessed. " +
382+
"Verify application user has permissions to the private key in certificate store. " +
383+
$"Error: {ex.Message}", ex);
384+
}
385+
}
386+
345387
private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation)
346388
{
347389
using var store = new X509Store(storeName, storeLocation);

0 commit comments

Comments
 (0)