diff --git a/docs/articles/remoting/security.md b/docs/articles/remoting/security.md index 19f7a18d50f..14c49e9bb42 100644 --- a/docs/articles/remoting/security.md +++ b/docs/articles/remoting/security.md @@ -5,52 +5,491 @@ title: Network Security # Akka.Remote Security -There are 2 ways you may like to achieve network security when using Akka.Remote: +## Important Context: When You Need TLS -* Transport Layer Security (introduced with Akka.Remote Version 1.2) -* Virtual Private Networks +**Akka.Remote is designed for internal cluster communication and should NOT be exposed to the public internet.** Most Akka.NET deployments run within: -## Akka.Remote with TLS (Transport Layer Security) +* Private networks (VPNs, VPCs) +* Internal data centers +* Kubernetes clusters with network policies +* Behind firewalls with strict ingress rules -The release of Akka.NET version 1.2.0 introduces the default [DotNetty](https://github.com/Azure/DotNetty) transport and the ability to configure [TLS](http://en.wikipedia.org/wiki/Transport_Layer_Security) security across Akka.Remote Actor Systems. In order to use TLS, you must first install a valid SSL certificate on all Akka.Remote hosts that you intend to use TLS. +### When TLS Is Optional -Once you've installed valid SSL certificates, TLS is enabled via your HOCON configuration by setting `enable-ssl = true` and configuring the `ssl` HOCON configuration section like below: +For many deployments, TLS is not strictly necessary: + +* ✅ **Internal networks only** - If your cluster runs entirely within a trusted network boundary +* ✅ **Development/staging environments** - Where data sensitivity is low +* ✅ **Kubernetes with network policies** - Where the container network provides isolation + +### When TLS Is Recommended + +You should enable TLS when: + +* 🔒 **Crossing network boundaries** - Communication between data centers or cloud regions +* 🔒 **Public internet transit** - Any traffic over public networks (even with VPN) +* 🔒 **Compliance requirements** - PCI-DSS, HIPAA, or other regulatory needs +* 🔒 **Defense-in-depth** - Additional security layer even on private networks +* 🔒 **Multi-tenant environments** - Shared infrastructure with other applications + +## Security Layers + +Akka.Remote security operates on three complementary layers: + +1. **Network Isolation** - Using VPNs or private networks to restrict which machines can reach your actor systems +2. **Transport Encryption** - Using TLS to encrypt all communication between nodes +3. **Authentication** - Using mutual TLS to verify the identity of all connecting nodes + +You should use **all three layers** in production for defense-in-depth security. + +## TLS (Transport Layer Security) Overview + +TLS encryption was introduced in Akka.NET v1.2 with the DotNetty transport. It provides: + +✅ **What TLS Protects Against:** + +* Eavesdropping (all messages are encrypted) +* Man-in-the-middle attacks (certificates verify server identity) +* Network packet injection (cryptographic integrity checks) + +❌ **What TLS Does NOT Protect Against:** + +* Misconfigured certificates (see startup validation below) +* Compromised private keys (rotate certificates regularly) +* Application-level authorization (implement this separately) + +## Certificate Validation: Suppress-Validation Setting + +The `suppress-validation` setting controls whether certificate validation is enforced during TLS handshakes. + +### Suppress-Validation = False (RECOMMENDED) + +**What it does:** + +* Validates certificate chain against trusted root CAs +* Checks certificate expiration dates +* Verifies certificate hostname matches connection hostname +* Ensures certificate hasn't been revoked (if CRL/OCSP configured) + +**When to use:** Always in production and any networked environment. + +### Suppress-Validation = True (USE WITH CAUTION) + +**What it does:** + +* Accepts ANY certificate, including: + * Self-signed certificates + * Expired certificates + * Certificates from unknown/untrusted CAs + * Certificates with hostname mismatches + +**When it's acceptable:** + +* Local development on `localhost` only +* Automated testing with self-signed test certificates +* Initial TLS setup/debugging before obtaining proper certificates + +**When it's NOT acceptable:** + +* Any production environment +* Any network-accessible environment (dev, staging, QA) +* Any environment processing sensitive data +* Any multi-tenant environment + +### Self-Signed Certificates: The Right Way + +If you must use self-signed certificates (development/testing): + +#### Option 1: Trust the Self-Signed CA (Better) + +```powershell +# Generate self-signed CA +$ca = New-SelfSignedCertificate -Subject "CN=Dev-CA" -CertStoreLocation Cert:\CurrentUser\My -KeyUsage CertSign + +# Export and import to Trusted Root +Export-Certificate -Cert $ca -FilePath dev-ca.cer +Import-Certificate -FilePath dev-ca.cer -CertStoreLocation Cert:\LocalMachine\Root + +# Generate server cert signed by CA +New-SelfSignedCertificate -Subject "CN=localhost" -Signer $ca -CertStoreLocation Cert:\LocalMachine\My +``` + +**Configuration:** + +```hocon +akka.remote.dot-netty.tcp.ssl { + suppress-validation = false # ✓ Still validates, but trusts your CA + certificate { + use-thumbprint-over-file = true + thumbprint = "server-cert-thumbprint" + } +} +``` + +**Pros:** + +* Maintains validation checks +* Catches expiration/configuration errors +* More realistic test environment + +#### Option 2: Suppress Validation (Quick but Dangerous) + +```hocon +akka.remote.dot-netty.tcp.ssl { + suppress-validation = true # ⚠️ Development ONLY + certificate { + path = "self-signed.pfx" + password = "password" + } +} +``` + +**Pros:** + +* Quick setup +* No certificate installation needed + +**Cons:** + +* Doesn't catch real configuration errors +* False sense of security +* Easy to accidentally deploy to production + +**WARNING:** Never commit `suppress-validation = true` to version control for production configs. Use environment-specific configuration files. + +## Certificate Configuration + +### Option 1: Certificate File (Recommended for Development) + +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false # IMPORTANT: Never use true in production! + certificate { + path = "path/to/certificate.pfx" + password = "certificate-password" + # Optional: Specify key storage flags + flags = [ "exportable" ] + } + } +} +``` + +**When to use:** Development, testing, containerized environments where you can mount certificate files. + +**Pros:** + +* Easy to deploy with containers +* Simple to version control (store path, not certificate) +* Works well with configuration management tools + +**Cons:** + +* Certificate files can be copied if filesystem is compromised +* Requires file system access for certificate deployment + +### Option 2: Windows Certificate Store (Recommended for Production) ```hocon -akka { - loglevel = DEBUG - actor { - provider = remote +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + store-name = "My" + store-location = "local-machine" # or "current-user" + } } - remote { - dot-netty.tcp { - port = 0 - hostname = 127.0.0.1 - enable-ssl = true - log-transport = true - ssl { - suppress-validation = true - certificate { - # valid ssl certificate must be installed on both hosts - path = "" - password = "" - # flags is optional: defaults to "default-flag-set" key storage flag - # other available storage flags: - # exportable | machine-key-set | persist-key-set | user-key-set | user-protected - flags = [ "default-flag-set" ] - } - } +} +``` + +**When to use:** Windows production environments, enterprise deployments with centralized certificate management. + +**Pros:** + +* Leverages Windows ACL for private key protection +* Integrates with enterprise PKI infrastructure +* Supports hardware security modules (HSM) +* Private keys can be marked as non-exportable + +**Cons:** + +* Windows-specific (not portable to Linux) +* Requires administrative access for certificate installation +* More complex initial setup + +**Finding Your Thumbprint:** + +1. Open `certlm.msc` (Local Machine) or `certmgr.msc` (Current User) +2. Navigate to Personal > Certificates +3. Double-click your certificate +4. Go to Details tab +5. Scroll to Thumbprint field +6. Copy the value (remove spaces) + +## Startup Certificate Validation (v1.5.52+) + +**New in Akka.NET v1.5.52:** The transport now validates certificate configuration at startup, preventing runtime failures. + +### What It Validates + +The startup validation verifies: + +* Certificate exists in the specified location +* Certificate has a private key associated +* Application has permissions to access the private key +* Private key is accessible for both RSA and ECDSA algorithms + +This fail-fast validation prevents runtime TLS handshake failures by detecting certificate configuration problems during system initialization. + +### Common Private Key Permission Issues + +**Symptom:** "SSL certificate private key exists but cannot be accessed" + +**Cause:** Application user lacks permissions to the private key file in Windows certificate store. + +**Solution:** Grant private key access to your application user: + +```powershell +# Find the certificate +$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object {$_.Thumbprint -eq "YOUR_THUMBPRINT"} + +# Get private key file location +$keyPath = $cert.PrivateKey.CspKeyContainerInfo.UniqueKeyContainerName +$keyFullPath = "C:\ProgramData\Microsoft\Crypto\RSA\MachineKeys\$keyPath" + +# Grant read permissions +$acl = Get-Acl $keyFullPath +$permission = "DOMAIN\AppUser","Read","Allow" +$accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule $permission +$acl.AddAccessRule($accessRule) +Set-Acl $keyFullPath $acl +``` + +## Mutual TLS Authentication (v1.5.52+) + +**New in Akka.NET v1.5.52:** Support for mutual TLS (mTLS) where both client and server must authenticate with certificates. + +### Standard TLS vs Mutual TLS + +**Standard TLS (Server Authentication Only):** + +```mermaid +sequenceDiagram + participant Client + participant Server + + Client->>Server: Connect (no certificate) + Server->>Client: Send server certificate + Client->>Client: Validate server certificate + Client->>Server: Accept connection + Note over Client,Server: Encrypted communication established +``` + +**Mutual TLS (Client + Server Authentication):** + +```mermaid +sequenceDiagram + participant Client + participant Server + + Client->>Server: Connect with client certificate + Server->>Client: Send server certificate + Client->>Client: Validate server certificate + Server->>Server: Validate client certificate + Client->>Server: Accept connection + Server->>Client: Accept connection + Note over Client,Server: Mutually authenticated encryption established +``` + +### Configuration + +The following example shows how to configure mutual TLS: + +[!code-csharp[MutualTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=MutualTlsConfig)] + +For production with Windows Certificate Store: + +[!code-csharp[WindowsCertStoreConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=WindowsCertStoreConfig)] + +### When to Enable Mutual TLS + +**✅ Enable mutual TLS when:** + +* All nodes are under your control (typical Akka.NET cluster) +* You need defense-in-depth security +* Compliance requires bidirectional authentication (PCI-DSS, HIPAA, etc.) +* You want to prevent misconfigured nodes from joining + +**⚠️ Disable mutual TLS when:** + +* Clients cannot provide certificates (rare in Akka.NET) +* You're using client-server architecture where clients are untrusted +* Backward compatibility with older clients required + +**Default is TRUE for security-by-default posture.** + +### Security Benefits of Mutual TLS + +1. **Prevents Asymmetric Connectivity Issues** + * Without mutual TLS: A node with broken certificate can connect OUT to cluster (client TLS succeeds) + * With mutual TLS: Node cannot connect without working certificate (enforced both ways) + +2. **Defense-in-Depth** + * Startup validation prevents broken servers + * Mutual TLS prevents broken clients + * Both together provide complete protection + +3. **Identity Verification** + * Every node must prove it owns the certificate + * Prevents certificate theft attacks (attacker needs private key) + +## Configuration Examples and Security Analysis + +### ❌ INSECURE: Development/Testing Only + +[!code-csharp[DevTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=DevTlsConfig)] + +**Why this is bad:** + +* `suppress-validation = true` accepts ANY certificate (even self-signed or expired) +* Vulnerable to man-in-the-middle attacks +* No client authentication + +**When to use:** Local development only, never in any environment accessible from network. + +### ✅ GOOD: Standard TLS for Production + +[!code-csharp[StandardTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=StandardTlsConfig)] + +**Security level:** Medium-High + +* Server proves identity to clients +* All traffic encrypted +* Startup validation prevents misconfigurations +* Suitable when mutual TLS is not feasible + +### ✅ BEST: Mutual TLS for Maximum Security + +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false # ✓ Validates all certificates (default when SSL enabled) + require-mutual-authentication = true # ✓ Requires client certs (default when SSL enabled since v1.5.52) + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + store-name = "My" + store-location = "local-machine" } } } ``` -## Akka.Remote with Virtual Private Networks +**Note:** When SSL is enabled, both `suppress-validation = false` and `require-mutual-authentication = true` are the secure defaults (since v1.5.52), so you only need to explicitly set them if overriding. + +**Security level:** Maximum + +* Both client and server prove identity +* All traffic encrypted +* Prevents misconfigured nodes from connecting +* Defense-in-depth security +* Recommended for all production deployments + +## Untrusted Mode + +In addition to TLS, Akka.Remote supports "untrusted mode" which prevents clients from sending system-level messages: + +```hocon +akka.remote { + untrusted-mode = true + + # Whitelist specific actors that can receive remote messages + trusted-selection-paths = [ + "/user/api-handler", + "/user/public-endpoint" + ] +} +``` + +**When to enable:** + +* You're exposing Akka.Remote to untrusted clients +* You want to prevent remote actor creation/supervision +* Defense against malicious remote commands + +**Note:** This does NOT replace TLS encryption. Use both together. + +## Virtual Private Networks (VPNs) + +The best practice for network security is to make the network itself secure. Run Akka.Remote on private networks that require VPN access. + +**Why VPNs matter:** + +* Restricts who can even attempt to connect +* Provides network-level access control +* Adds authentication layer before TLS +* Protects against network scanning/discovery + +### VPN Options -The absolute best practice for securing remote Akka.NET applications today is to make the network around the applications secure - don't use public, open networks! Instead, use a private network to restrict machines that can contact Akka.Remote processes to ones who have your VPN credentials. +**Self-Hosted:** + +* [WireGuard](https://www.wireguard.com/) - Modern, fast, simple to configure +* [OpenVPN](https://openvpn.net/) - Mature, widely supported + +**Cloud Provider VPNs:** + +* [AWS Virtual Private Cloud (VPC)](https://aws.amazon.com/vpc/) +* [Azure Virtual Networks (VNet)](https://azure.microsoft.com/en-us/services/virtual-network/) +* [Google Cloud VPC](https://cloud.google.com/vpc) + +**Managed Solutions:** + +* [Tailscale](https://tailscale.com/) - Zero-config VPN mesh networking +* [ZeroTier](https://www.zerotier.com/) - Software-defined networking + +## Troubleshooting + +### Error: "SSL Certificate Private Key Exists but Cannot Be Accessed" + +**Cause:** Application lacks permissions to private key file. + +**Fix:** Run PowerShell script above to grant permissions. + +### Error: "The Remote Certificate Is Invalid According to the Validation Procedure" + +**Cause:** Certificate validation failed (expired, wrong CA, hostname mismatch). + +**Fix:** + +* Verify certificate is not expired: `Get-ChildItem Cert:\LocalMachine\My` +* Check certificate CN/SAN matches hostname +* For testing only: Set `suppress-validation = true` to identify if it's a validation issue + +### Error: "TLS Handshake Failed" with No Client Certificate + +**Cause:** Server requires mutual TLS but client didn't provide certificate. + +**Fix:** + +* Ensure all nodes have `require-mutual-authentication` set consistently +* Verify client certificate is configured correctly +* Check client application has private key access + +## Additional Resources + +* [Windows Firewall Configuration Best Practices](https://learn.microsoft.com/en-us/windows/security/operating-system-security/network-security/windows-firewall/best-practices-configuring) +* [TLS 1.2 Specification (RFC 5246)](https://datatracker.ietf.org/doc/html/rfc5246) +* [OWASP Transport Layer Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Security_Cheat_Sheet.html) + +--- -Some options for doing this: +**Related:** -* [OpenVPN](https://openvpn.net/) - for "do it yourself" environments; -* [Azure Virtual Networks](http://azure.microsoft.com/en-us/services/virtual-network/) - for Windows Azure customers; and -* [Amazon Virtual Private Cloud (VPC)](http://aws.amazon.com/vpc/) - for Amazon Web Services customers. +* [Akka.Remote Configuration](xref:akka-remote-configuration) +* [DotNetty Transport](https://github.com/Azure/DotNetty) diff --git a/docs/cSpell.json b/docs/cSpell.json index 6a65e8176a9..8d9a09600cb 100644 --- a/docs/cSpell.json +++ b/docs/cSpell.json @@ -70,6 +70,7 @@ "Stannard", "substream", "substreams", + "Tailscale", "testkit", "threadedness", "threadpool", @@ -83,7 +84,8 @@ "userspace", "watchee", "Webcrawler", - "Xunit" + "Xunit", + "ZeroTier" ], "ignoreWords": [ "Hanselminutes", diff --git a/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs b/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs new file mode 100644 index 00000000000..ea2978d85d4 --- /dev/null +++ b/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs @@ -0,0 +1,84 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using Akka.Configuration; + +namespace Akka.Docs.Tests.Configuration +{ + /// + /// TLS configuration examples for Akka.Remote documentation + /// + public class TlsConfigurationSample + { + #region MutualTlsConfig + public static Config MutualTlsConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + require-mutual-authentication = true # Both client and server authenticate + certificate { + path = ""path/to/certificate.pfx"" + password = ""certificate-password"" + } + } + } + "); + #endregion + + #region StandardTlsConfig + public static Config StandardTlsConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + require-mutual-authentication = false # Server authentication only + certificate { + path = ""path/to/certificate.pfx"" + password = ""certificate-password"" + } + } + } + "); + #endregion + + #region WindowsCertStoreConfig + public static Config WindowsCertificateStoreConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + require-mutual-authentication = true + certificate { + use-thumbprint-over-file = true + thumbprint = ""2531c78c51e5041d02564697a88af8bc7a7ce3e3"" + store-name = ""My"" + store-location = ""local-machine"" # or ""current-user"" + } + } + } + "); + #endregion + + #region DevTlsConfig + // WARNING: Development only - never use suppress-validation = true in production! + public static Config DevelopmentTlsConfiguration = ConfigurationFactory.ParseString(@" + akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = true # INSECURE: Accepts any certificate + require-mutual-authentication = false + certificate { + path = ""self-signed-dev-cert.pfx"" + password = ""password"" + } + } + } + "); + #endregion + } +} \ No newline at end of file diff --git a/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj b/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj index 409bcace4bb..e06e3349a45 100644 --- a/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj +++ b/src/core/Akka.Remote.Tests/Akka.Remote.Tests.csproj @@ -19,6 +19,10 @@ PreserveNewest + + PreserveNewest + + PreserveNewest diff --git a/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs b/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs index f13689b943f..305ae82ac95 100644 --- a/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs +++ b/src/core/Akka.Remote.Tests/RemoteConfigSpec.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Akka.Configuration; using Akka.Remote.Transport.DotNetty; using Akka.TestKit; using Akka.Util.Internal; @@ -113,13 +114,37 @@ public void Remoting_should_contain_correct_heliosTCP_values_in_ReferenceConf() Assert.False(s.EnableSsl); } + [Fact] + public void SSL_should_have_secure_defaults_when_enabled() + { + // Simple test - just enable SSL and check the defaults from reference.conf + var certPath = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), "Resources", "akka-validcert.pfx"); + var config = ConfigurationFactory.ParseString($@" + akka.remote.dot-netty.tcp.enable-ssl = true + akka.remote.dot-netty.tcp.ssl.certificate {{ + path = ""{certPath.Replace("\\", "\\\\")}"" + password = ""password"" + }} + ").WithFallback(RARP.For(Sys).Provider.RemoteSettings.Config); + + var c = config.GetConfig("akka.remote.dot-netty.tcp"); + var s = DotNettyTransportSettings.Create(c); + + // Verify SSL is enabled + Assert.True(s.EnableSsl); + + // Verify secure defaults + Assert.True(s.Ssl.RequireMutualAuthentication, "Mutual TLS should be enabled by default"); + Assert.False(s.Ssl.SuppressValidation, "Certificate validation should not be suppressed by default"); + } + [Fact] public void When_remoting_works_in_Mono_ip_enforcement_should_be_defaulted_to_true() { if (!IsMono) return; // skip IF NOT using Mono var c = RARP.For(Sys).Provider.RemoteSettings.Config.GetConfig("akka.remote.dot-netty.tcp"); var s = DotNettyTransportSettings.Create(c); - + Assert.True(s.EnforceIpFamily); } diff --git a/src/core/Akka.Remote.Tests/Resources/akka-client-cert.pfx b/src/core/Akka.Remote.Tests/Resources/akka-client-cert.pfx new file mode 100644 index 00000000000..5eac2433456 Binary files /dev/null and b/src/core/Akka.Remote.Tests/Resources/akka-client-cert.pfx differ diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs new file mode 100644 index 00000000000..f6cab0b592d --- /dev/null +++ b/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs @@ -0,0 +1,284 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.TestKit; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Remote.Tests.Transport +{ + /// + /// Tests mutual TLS authentication enforcement in DotNetty transport. + /// When require-mutual-authentication is enabled, both client and server must + /// present valid certificates with accessible private keys. + /// + public class DotNettyMutualTlsSpec : AkkaSpec + { + private const string ValidCertPath = "Resources/akka-validcert.pfx"; + private const string ClientCertPath = "Resources/akka-client-cert.pfx"; + private const string Password = "password"; + + public DotNettyMutualTlsSpec(ITestOutputHelper output) : base(ConfigurationFactory.Empty, output) + { + } + + private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null) + { + var config = 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) + return config; + + var escapedPath = (certPath ?? ValidCertPath).Replace("\\", "\\\\"); + var ssl = $@" + akka.remote.dot-netty.tcp.ssl {{ + suppress-validation = {(suppressValidation ? "on" : "off")} + require-mutual-authentication = {(requireMutualAuth ? "on" : "off")} + certificate {{ + path = ""{escapedPath}"" + password = ""{Password}"" + }} + }} + "; + return ConfigurationFactory.ParseString(ssl).WithFallback(config); + } + + [Fact] + public async Task Mutual_TLS_should_allow_connection_when_both_nodes_have_valid_certificates() + { + // Both server and client have valid certs, mutual TLS enabled + ActorSystem server = null; + ActorSystem client = null; + + try + { + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Should successfully connect and communicate + var response = await client.ActorSelection(serverEchoPath).Ask("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] + public async Task Mutual_TLS_disabled_should_allow_standard_TLS_connection() + { + // Server has mutual TLS disabled (standard server-only TLS) + ActorSystem server = null; + ActorSystem client = null; + + try + { + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Should successfully connect with standard TLS + var response = await client.ActorSelection(serverEchoPath).Ask("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] + public void System_should_start_successfully_with_mutual_TLS_enabled() + { + // Verify that enabling mutual TLS doesn't break system startup + ActorSystem sys = null; + + try + { + var config = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true); + sys = ActorSystem.Create("TestSystem", config); + InitializeLogger(sys); + + // System should be running + Assert.False(sys.WhenTerminated.IsCompleted); + + // Remote should be initialized + var remoteAddress = RARP.For(sys).Provider.DefaultAddress; + Assert.NotNull(remoteAddress); + } + finally + { + if (sys != null) + Shutdown(sys, TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Mutual_TLS_should_fail_when_client_has_no_certificate() + { + // Server requires mutual TLS, client has SSL enabled but no certificate configured + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with mutual TLS required + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: 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 SSL enabled but mutual TLS disabled (won't send client certificate) + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should fail to connect because server requires client certificate + await Assert.ThrowsAsync(async () => + { + await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(3)); + }); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + [Fact] + public async Task Mutual_TLS_can_be_disabled_for_backward_compatibility() + { + // Test that setting require-mutual-authentication = false allows old behavior + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with mutual TLS explicitly disabled + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + server = ActorSystem.Create("ServerSystem", serverConfig); + InitializeLogger(server, "[SERVER] "); + + // Client with SSL but potentially no valid client cert + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: false, suppressValidation: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo"); + var serverAddr = RARP.For(server).Provider.DefaultAddress; + var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo"; + + // Should successfully connect even with mutual TLS disabled + var response = await client.ActorSelection(serverEchoPath).Ask("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] + public async Task Mutual_TLS_should_fail_when_client_has_different_valid_certificate() + { + // Server and client have different valid certificates - mutual TLS should fail + // because the certificates are not trusted by each other + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with mutual TLS using the original certificate + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: false, + certPath: ValidCertPath); + 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 mutual TLS using a different certificate + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: false, + certPath: ClientCertPath); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Connection should fail due to certificate mismatch + await Assert.ThrowsAsync(async () => + { + await client.ActorSelection(serverEchoPath).Ask("hello", TimeSpan.FromSeconds(3)); + }); + } + finally + { + if (client != null) + Shutdown(client, TimeSpan.FromSeconds(10)); + if (server != null) + Shutdown(server, TimeSpan.FromSeconds(10)); + } + } + + private sealed class EchoActor : ReceiveActor + { + public EchoActor() + { + ReceiveAny(msg => Sender.Tell(msg)); + } + } + } +} diff --git a/src/core/Akka.Remote/Configuration/Remote.conf b/src/core/Akka.Remote/Configuration/Remote.conf index 9ac9c4cb5af..9d67fd62628 100644 --- a/src/core/Akka.Remote/Configuration/Remote.conf +++ b/src/core/Akka.Remote/Configuration/Remote.conf @@ -553,6 +553,18 @@ akka { store-location = "current-user" } suppress-validation = false + + # When enabled, requires mutual TLS authentication where both client and server + # must present valid certificates with accessible private keys during the TLS handshake. + # This provides defense-in-depth security by ensuring symmetric authentication. + # + # When disabled, only server-side authentication is performed, which is + # sufficient when combined with the startup certificate validation that prevents + # servers from starting with inaccessible private keys. + # + # Set to false only if your environment cannot support client certificate authentication. + # Default: true (secure by default) + require-mutual-authentication = true } } diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs index 566321fdcbc..f87eab23520 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs @@ -354,9 +354,41 @@ private void SetClientPipeline(IChannel channel, Address remoteAddress) var certificate = Settings.Ssl.Certificate; var host = certificate.GetNameInfo(X509NameType.DnsName, false); - var tlsHandler = Settings.Ssl.SuppressValidation - ? new TlsHandler(stream => new SslStream(stream, true, (_, _, _, _) => true), new ClientTlsSettings(host)) - : TlsHandler.Client(host, certificate); + IChannelHandler tlsHandler; + + if (Settings.Ssl.SuppressValidation) + { + // Test/dev mode: Accept any server certificate + if (Settings.Ssl.RequireMutualAuthentication) + { + // Provide client cert for mutual TLS + tlsHandler = new TlsHandler( + stream => new SslStream(stream, true, (_, _, _, _) => true, + (_, _, _, _, _) => certificate), + new ClientTlsSettings(host)); + } + else + { + // No client cert needed + tlsHandler = new TlsHandler( + stream => new SslStream(stream, true, (_, _, _, _) => true), + new ClientTlsSettings(host)); + } + } + else + { + // Production mode: Validate server certificate + if (Settings.Ssl.RequireMutualAuthentication) + { + // Provide client cert for mutual TLS + tlsHandler = TlsHandler.Client(host, certificate); + } + else + { + // Standard TLS: Only validate server certificate, no client cert + tlsHandler = TlsHandler.Client(host); + } + } channel.Pipeline.AddFirst("TlsHandler", tlsHandler); } @@ -375,7 +407,46 @@ private void SetServerPipeline(IChannel channel) { if (Settings.EnableSsl) { - channel.Pipeline.AddFirst("TlsHandler", TlsHandler.Server(Settings.Ssl.Certificate)); + IChannelHandler tlsHandler; + + if (Settings.Ssl.RequireMutualAuthentication) + { + // Mutual TLS: Require client certificate authentication + tlsHandler = new TlsHandler( + stream => new SslStream( + stream, + leaveInnerStreamOpen: true, + userCertificateValidationCallback: (sender, certificate, chain, errors) => + { + if (certificate == null) + { + Log.Warning("Mutual TLS: Client connection rejected - no client certificate provided"); + return false; + } + + if (Settings.Ssl.SuppressValidation) + { + // In test/dev mode, accept any client certificate + return true; + } + + if (errors != SslPolicyErrors.None) + { + Log.Warning("Mutual TLS: Client certificate validation failed with errors: {0}", errors); + return false; + } + + return true; + }), + new ServerTlsSettings(Settings.Ssl.Certificate, negotiateClientCertificate: true)); + } + else + { + // Standard TLS: Server authentication only (backward compatible) + tlsHandler = TlsHandler.Server(Settings.Ssl.Certificate); + } + + channel.Pipeline.AddFirst("TlsHandler", tlsHandler); } SetInitialChannelPipeline(channel); diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs index c84bac48360..fa0307bd7b5 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs @@ -269,18 +269,21 @@ private static SslSettings Create(Config config) if (config.IsNullOrEmpty()) throw new ConfigurationException($"Failed to create {typeof(DotNettyTransportSettings)}: DotNetty SSL HOCON config was not found (default path: `akka.remote.dot-netty.tcp.ssl`)"); + var requireMutualAuth = config.GetBoolean("require-mutual-authentication", true); + if (config.GetBoolean("certificate.use-thumprint-over-file") || config.GetBoolean("certificate.use-thumbprint-over-file")) { - var thumbprint = config.GetString("certificate.thumbprint") + var thumbprint = config.GetString("certificate.thumbprint") ?? config.GetString("certificate.thumpbrint"); if (string.IsNullOrWhiteSpace(thumbprint)) throw new Exception("`akka.remote.dot-netty.tcp.ssl.certificate.use-thumbprint-over-file` is set to true but `akka.remote.dot-netty.tcp.ssl.certificate.thumbprint` is null or empty"); - + return new SslSettings(certificateThumbprint: thumbprint, storeName: config.GetString("certificate.store-name"), storeLocation: ParseStoreLocationName(config.GetString("certificate.store-location")), - suppressValidation: config.GetBoolean("suppress-validation")); + suppressValidation: config.GetBoolean("suppress-validation"), + requireMutualAuthentication: requireMutualAuth); } var flagsRaw = config.GetStringList("certificate.flags", new string[] { }); @@ -290,7 +293,8 @@ private static SslSettings Create(Config config) certificatePath: config.GetString("certificate.path"), certificatePassword: config.GetString("certificate.password"), flags: flags, - suppressValidation: config.GetBoolean("suppress-validation")); + suppressValidation: config.GetBoolean("suppress-validation"), + requireMutualAuthentication: requireMutualAuth); } @@ -330,16 +334,33 @@ private static X509KeyStorageFlags ParseKeyStorageFlag(string str) /// public readonly bool SuppressValidation; + /// + /// When true, requires mutual TLS authentication where both client and server + /// must present valid certificates with accessible private keys during the TLS handshake. + /// Provides defense-in-depth security by ensuring symmetric authentication. + /// + public readonly bool RequireMutualAuthentication; + private SslSettings() { Certificate = null; SuppressValidation = false; + RequireMutualAuthentication = false; } + /// + /// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true + /// public SslSettings(X509Certificate2 certificate, bool suppressValidation) + : this(certificate, suppressValidation, true) + { + } + + public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { Certificate = certificate; SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; } /// @@ -388,7 +409,7 @@ public void ValidateCertificate() } } - private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation) + private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation, bool requireMutualAuthentication) { using var store = new X509Store(storeName, storeLocation); store.Open(OpenFlags.ReadOnly); @@ -402,15 +423,17 @@ private SslSettings(string certificateThumbprint, string storeName, StoreLocatio Certificate = find[0]; SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; } - private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation) + private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation, bool requireMutualAuthentication) { if (string.IsNullOrEmpty(certificatePath)) throw new ArgumentNullException(nameof(certificatePath), "Path to SSL certificate was not found (by default it can be found under `akka.remote.dot-netty.tcp.ssl.certificate.path`)"); Certificate = new X509Certificate2(certificatePath, certificatePassword, flags); SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; } } }