diff --git a/docs/articles/remoting/security.md b/docs/articles/remoting/security.md index 14c49e9bb42..4b7484488ed 100644 --- a/docs/articles/remoting/security.md +++ b/docs/articles/remoting/security.md @@ -18,19 +18,19 @@ title: Network Security 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 +* **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 +* **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 @@ -46,42 +46,68 @@ You should use **all three layers** in production for defense-in-depth security. TLS encryption was introduced in Akka.NET v1.2 with the DotNetty transport. It provides: -✅ **What TLS Protects Against:** +**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:** +**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 +## Certificate Validation: Independent Control -The `suppress-validation` setting controls whether certificate validation is enforced during TLS handshakes. +**New in Akka.NET v1.5.52+:** Certificate validation is now split into two independent settings for greater flexibility. -### Suppress-Validation = False (RECOMMENDED) +### Two Types of Validation -**What it does:** +1. **Chain Validation** (`suppress-validation`) - Validates certificate against trusted CAs +2. **Hostname Validation** (`validate-certificate-hostname`) - Validates certificate CN/SAN matches target hostname + +These settings are **independent** and can be configured separately based on your deployment scenario. + +### Chain Validation + +The `suppress-validation` setting controls whether the certificate chain is validated against trusted root CAs. + +**Default Certificate Stores Used:** + +When `suppress-validation = false`, .NET's `SslStream` validates certificates against the operating system's trusted root certificate stores: + +* **Windows**: Uses the [Windows Certificate Store](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/local-machine-and-current-user-certificate-stores) - specifically the `Trusted Root Certification Authorities` store +* **Linux**: Uses the system's CA bundle (typically `/etc/ssl/certs/ca-certificates.crt` or `/etc/pki/tls/certs/ca-bundle.crt`) +* **macOS**: Uses the Keychain Access Trusted Certificates + +The validation process follows [RFC 5280 (X.509 PKI Certificate and CRL Profile)](https://datatracker.ietf.org/doc/html/rfc5280) and [RFC 6125 (Service Identity Verification)](https://datatracker.ietf.org/doc/html/rfc6125). + +#### Enabled (Recommended) + +When `suppress-validation = false` (the default when SSL is enabled): + +**What it validates:** + +* Certificate chain against system trusted root CAs +* Certificate expiration dates +* Certificate hasn't been revoked (if CRL/OCSP configured) + +**Does NOT validate:** -* 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) +* Hostname matching (see Hostname Validation section below) **When to use:** Always in production and any networked environment. -### Suppress-Validation = True (USE WITH CAUTION) +#### Disabled (Use With Caution) -**What it does:** +When `suppress-validation = true`: + +**What it skips:** -* Accepts ANY certificate, including: - * Self-signed certificates - * Expired certificates - * Certificates from unknown/untrusted CAs - * Certificates with hostname mismatches +* Certificate chain validation (accepts self-signed certificates) +* Expiration date checks +* CA trust checks **When it's acceptable:** @@ -96,6 +122,55 @@ The `suppress-validation` setting controls whether certificate validation is enf * Any environment processing sensitive data * Any multi-tenant environment +### Hostname Validation + +**New in v1.5.52+:** The `validate-certificate-hostname` setting controls whether the certificate CN/SAN must match the target hostname. + +**IMPORTANT: This setting defaults to `false` (disabled).** Hostname validation is NOT performed by default to support common Akka.NET deployment patterns like mutual TLS with per-node certificates and IP-based connections. + +#### Disabled (Default) + +When `validate-certificate-hostname = false` (the default): + +**What it does:** + +* Skips hostname validation +* Only validates certificate chain (if `suppress-validation = false`) + +**When to use:** + +* **Mutual TLS with per-node certificates** - Each node has its own unique certificate +* **IP-based connections** - Connecting via IP addresses instead of DNS names +* **Dynamic service discovery** - Hostnames change frequently (Kubernetes, auto-scaling) +* **Internal P2P clusters** - All nodes are trusted and mutually authenticated + +**This is the default** for backward compatibility and to support common Akka.NET cluster patterns. + +#### Enabled + +When `validate-certificate-hostname = true`: + +**What it validates:** + +* Certificate CN (Common Name) or SAN (Subject Alternative Name) must match the target hostname +* Traditional TLS hostname validation as used in HTTPS + +**When to use:** + +* **Client-server architecture** - Clients connecting to known server hostnames +* **Shared certificates** - Same certificate used across multiple nodes +* **DNS-based connections** - Connecting via stable DNS names +* **Maximum security** - Traditional browser-like TLS validation + +### Validation Mode Combinations + +| suppress-validation | validate-certificate-hostname | Use Case | +|---------------------|-------------------------------|----------| +| `false` | `false` | **Common**: Mutual TLS clusters with per-node certs | +| `false` | `true` | **Traditional**: Client-server TLS with DNS names | +| `true` | `false` | **Dev/Test**: Self-signed certs, no hostname checks | +| `true` | `true` | **Test Only**: Self-signed certs WITH hostname validation | + ### Self-Signed Certificates: The Right Way If you must use self-signed certificates (development/testing): @@ -317,14 +392,14 @@ For production with Windows Certificate Store: ### When to Enable Mutual TLS -**✅ Enable mutual TLS when:** +**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:** +**Disable mutual TLS when:** * Clients cannot provide certificates (rare in Akka.NET) * You're using client-server architecture where clients are untrusted @@ -349,7 +424,7 @@ For production with Windows Certificate Store: ## Configuration Examples and Security Analysis -### ❌ INSECURE: Development/Testing Only +### INSECURE: Development/Testing Only [!code-csharp[DevTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=DevTlsConfig)] @@ -361,7 +436,7 @@ For production with Windows Certificate Store: **When to use:** Local development only, never in any environment accessible from network. -### ✅ GOOD: Standard TLS for Production +### GOOD: Standard TLS for Production [!code-csharp[StandardTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=StandardTlsConfig)] @@ -372,14 +447,15 @@ For production with Windows Certificate Store: * Startup validation prevents misconfigurations * Suitable when mutual TLS is not feasible -### ✅ BEST: Mutual TLS for Maximum Security +### 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) + 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) + validate-certificate-hostname = false # DEFAULT: Hostname validation disabled (suitable for P2P with per-node certs) certificate { use-thumbprint-over-file = true thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" @@ -392,6 +468,11 @@ akka.remote.dot-netty.tcp { **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. +**About hostname validation:** + +* Set `validate-certificate-hostname = false` for peer-to-peer clusters with per-node certificates (default) +* Set `validate-certificate-hostname = true` for client-server architectures with DNS-based connections + **Security level:** Maximum * Both client and server prove identity @@ -400,6 +481,34 @@ akka.remote.dot-netty.tcp { * Defense-in-depth security * Recommended for all production deployments +### Configuration with Hostname Validation Enabled + +For client-server architectures where all nodes connect via DNS names and share the same certificate: + +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false + require-mutual-authentication = true + validate-certificate-hostname = true # Enable traditional TLS hostname validation + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + store-name = "My" + store-location = "local-machine" + } + } +} +``` + +**When to use hostname validation:** + +* Your cluster uses stable DNS names (not IPs) +* All nodes share the same certificate (CN matches DNS names) +* You want browser-like TLS validation behavior +* Client-server architecture rather than P2P mesh + ## Untrusted Mode In addition to TLS, Akka.Remote supports "untrusted mode" which prevents clients from sending system-level messages: @@ -481,6 +590,141 @@ The best practice for network security is to make the network itself secure. Run * Verify client certificate is configured correctly * Check client application has private key access +### Error: "RemoteCertificateNameMismatch" - Hostname Validation Failure + +**Full error message:** + +```text +TLS certificate validation failed (full validation): + - Certificate name mismatch + - RemoteCertificateNameMismatch: The hostname being connected to does not match + the hostname(s) on the server certificate. + +Certificate Details: + Subject: CN=node1.example.com + Issuer: CN=My-CA + Valid: 2025-01-01 to 2026-01-01 + +Connection target: 192.168.1.100:4053 +``` + +**Cause:** Certificate CN/SAN doesn't match the target hostname/IP address. + +**Common scenarios:** + +1. **Connecting via IP but certificate has DNS name** + * Connecting to: `192.168.1.100` + * Certificate CN: `node1.example.com` + +2. **Per-node certificates in P2P cluster** + * Node A cert CN: `node-a.cluster.local` + * Node B cert CN: `node-b.cluster.local` + * Each node's certificate doesn't match the other node's hostname + +**Fix:** + +Option 1 (Recommended for P2P clusters): Disable hostname validation + +```hocon +akka.remote.dot-netty.tcp.ssl { + validate-certificate-hostname = false # Allow per-node certs +} +``` + +Option 2: Use certificates with matching CN/SAN + +```bash +# Ensure certificate CN matches connection target +# For IP connections, add IP SAN to certificate: +New-SelfSignedCertificate -Subject "CN=node1" ` + -DnsName "node1", "node1.example.com" ` + -TextExtension @("2.5.29.17={text}IPAddress=192.168.1.100") +``` + +Option 3: Connect via DNS names that match certificate CN + +```hocon +akka.remote.dot-netty.tcp { + hostname = "node1.example.com" # Must match cert CN +} +``` + +### Error: "UntrustedRoot" - Certificate Chain Validation Failure + +**Full error message:** + +```text +TLS/SSL certificate validation failed: + - Certificate chain validation errors + - UntrustedRoot: A certificate chain processed, but terminated in a root + certificate which is not trusted by the trust provider. + +Certificate Details: + Subject: CN=localhost + Issuer: CN=localhost (self-signed) +``` + +**Cause:** Certificate is self-signed or signed by untrusted CA. + +**Fix:** + +Option 1 (Development only): Suppress chain validation + +```hocon +akka.remote.dot-netty.tcp.ssl { + suppress-validation = true # WARNING: Development only! +} +``` + +Option 2 (Recommended): Trust the CA certificate + +```powershell +# Windows: Import CA to Trusted Root store +Import-Certificate -FilePath ca.cer -CertStoreLocation Cert:\LocalMachine\Root + +# Linux: Add to system CA bundle +sudo cp ca.crt /usr/local/share/ca-certificates/ +sudo update-ca-certificates +``` + +### Understanding TLS Error Messages (v1.5.52+) + +Since v1.5.52, TLS handshake failures provide detailed diagnostic information including: + +* **Error category** (chain validation, hostname mismatch, etc.) +* **Specific SSL policy error** with explanation +* **Certificate details** (subject, issuer, validity period) +* **Connection context** (local/remote addresses) +* **Actionable recommendations** + +**Example comprehensive error:** + +```text +TLS handshake failed on channel [127.0.0.1:4053->127.0.0.1:54321](Id=...) + +Detailed TLS Error: + - Certificate chain validation errors + - UntrustedRoot: A certificate chain processed, but terminated in a root + certificate which is not trusted by the trust provider. + - Certificate name mismatch + - RemoteCertificateNameMismatch: The hostname being connected to does not + match the hostname(s) on the server certificate. + +Certificate Information: + Subject: CN=node-test + Issuer: CN=node-test (self-signed) + Serial Number: 1A2B3C4D5E6F + Valid From: 2025-01-01 00:00:00 UTC + Valid To: 2026-01-01 00:00:00 UTC + Thumbprint: 2531c78c51e5041d02564697a88af8bc7a7ce3e3 + +Recommendations: + - For development: Set 'suppress-validation = true' (testing only!) + - For production: Install certificate in trusted root store + - For hostname issues: Set 'validate-certificate-hostname = false' if using + per-node certificates or IP-based connections +``` + ## 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) diff --git a/docs/cSpell.json b/docs/cSpell.json index 8d9a09600cb..706e9451c8b 100644 --- a/docs/cSpell.json +++ b/docs/cSpell.json @@ -36,6 +36,7 @@ "Hasher", "Hipsterize", "HOCON", + "hostnames", "journaled", "Kubernetes", "lifecycles", diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt index bcc6ce10a27..d9e856313c7 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt @@ -863,7 +863,11 @@ namespace Akka.Remote.Transport.DotNetty public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup { public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { } public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; } + public bool RequireMutualAuthentication { get; } public bool SuppressValidation { get; } + public bool ValidateCertificateHostname { get; } } } \ No newline at end of file diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt index 5ae71f05f74..3a5d9a28747 100644 --- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt +++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt @@ -863,7 +863,11 @@ namespace Akka.Remote.Transport.DotNetty public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup { public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { } public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; } + public bool RequireMutualAuthentication { get; } public bool SuppressValidation { get; } + public bool ValidateCertificateHostname { get; } } } \ No newline at end of file diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs index 04634a494ae..6c4dc452b0c 100644 --- a/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/DotNettyMutualTlsSpec.cs @@ -30,7 +30,7 @@ public DotNettyMutualTlsSpec(ITestOutputHelper output) : base(ConfigurationFacto { } - private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null) + private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null, bool? validateCertificateHostname = null) { var config = ConfigurationFactory.ParseString($@" akka {{ @@ -49,10 +49,15 @@ private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool return config; var escapedPath = (certPath ?? ValidCertPath).Replace("\\", "\\\\"); + var hostnameValidationConfig = validateCertificateHostname.HasValue + ? $"validate-certificate-hostname = {(validateCertificateHostname.Value ? "on" : "off")}" + : ""; + var ssl = $@" akka.remote.dot-netty.tcp.ssl {{ suppress-validation = {(suppressValidation ? "on" : "off")} require-mutual-authentication = {(requireMutualAuth ? "on" : "off")} + {hostnameValidationConfig} certificate {{ path = ""{escapedPath}"" password = ""{Password}"" @@ -275,6 +280,165 @@ await Assert.ThrowsAsync(async () => } } + [Fact(DisplayName = "Different certificates with hostname validation disabled should connect successfully")] + public async Task Hostname_validation_disabled_should_allow_different_certificates() + { + // Per-node certificates should work when hostname validation is disabled + // Note: Using suppressValidation=true to bypass chain validation since test certs are self-signed + // This isolates the hostname validation logic we're testing + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with one certificate, hostname validation disabled + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: false); + 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 different certificate, hostname validation disabled + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ClientCertPath, validateCertificateHostname: false); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should successfully connect because hostname validation is 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(DisplayName = "Different certificates with hostname validation enabled should fail with name mismatch")] + public async Task Hostname_validation_enabled_should_reject_different_certificates() + { + // When hostname validation is enabled, different certificates should fail with RemoteCertificateNameMismatch + // Note: Using suppressValidation=true to bypass chain validation and test hostname validation specifically + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with one certificate, hostname validation enabled + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: 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 different certificate, hostname validation enabled + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ClientCertPath, validateCertificateHostname: true); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should fail because hostname in certificate doesn't match connection target (127.0.0.1) + 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(DisplayName = "Same certificate should connect successfully (typical mutual TLS scenario)")] + public async Task Same_certificate_should_connect_in_mutual_tls() + { + // Typical mutual TLS: Both nodes use the same shared certificate + // Hostname validation disabled because we're using IPs/per-node certs + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server with same certificate, hostname validation disabled (typical for mutual TLS) + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: false); + 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 same certificate, hostname validation disabled + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: false); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should successfully connect - typical mutual TLS scenario + 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(DisplayName = "Hostname validation unspecified should default to disabled (backward compatibility)")] + public async Task Hostname_validation_default_should_be_disabled() + { + // When validate-certificate-hostname is not specified, it should default to false + // Note: Using suppressValidation=true to bypass chain validation and test hostname default behavior + ActorSystem server = null; + ActorSystem client = null; + + try + { + // Server without specifying hostname validation (should default to false) + var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ValidCertPath, validateCertificateHostname: null); + 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 different certificate, hostname validation unspecified (should default to false) + var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true, + certPath: ClientCertPath, validateCertificateHostname: null); + client = ActorSystem.Create("ClientSystem", clientConfig); + InitializeLogger(client, "[CLIENT] "); + + // Should successfully connect because hostname validation defaults to 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)); + } + } + private sealed class EchoActor : ReceiveActor { public EchoActor() diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs index e75a5cb9b95..9ba910e7779 100644 --- a/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs @@ -105,6 +105,96 @@ await Assert.ThrowsAsync(async () => }); } + [Fact(DisplayName = "DotNettySslSetup with 2 parameters should configure effective DotNettyTransportSettings with defaults (RequireMutualAuth=true, ValidateHostname=false)")] + public void Two_parameter_setup_should_configure_transport_settings_with_defaults() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@" +akka { + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + } +}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test", actorSystemSetup); + + // Verify that DotNettyTransportSettings.Create uses the setup correctly + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate, settings.Ssl.Certificate); + Assert.True(settings.Ssl.SuppressValidation); + Assert.True(settings.Ssl.RequireMutualAuthentication); // default from 2-param constructor + Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 2-param constructor + } + + [Fact(DisplayName = "DotNettySslSetup with 3 parameters should configure effective DotNettyTransportSettings with specified RequireMutualAuth and default ValidateHostname=false")] + public void Three_parameter_setup_should_configure_transport_settings() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: false); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@" +akka { + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + } +}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test", actorSystemSetup); + + // Verify that DotNettyTransportSettings.Create uses the setup correctly + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate, settings.Ssl.Certificate); + Assert.False(settings.Ssl.SuppressValidation); + Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false + Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 3-param constructor + } + + [Fact(DisplayName = "DotNettySslSetup with 4 parameters should configure effective DotNettyTransportSettings with all specified values")] + public void Four_parameter_setup_should_configure_transport_settings_with_all_values() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true, requireMutualAuthentication: false, validateCertificateHostname: true); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@" +akka { + actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote"" + remote.dot-netty.tcp { + port = 0 + hostname = ""127.0.0.1"" + enable-ssl = true + } +}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test", actorSystemSetup); + + // Verify that DotNettyTransportSettings.Create uses the setup correctly + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate, settings.Ssl.Certificate); + Assert.True(settings.Ssl.SuppressValidation); + Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false + Assert.True(settings.Ssl.ValidateCertificateHostname); // explicitly set to true + } + #region helper classes / methods protected override void AfterAll() diff --git a/src/core/Akka.Remote/Configuration/Remote.conf b/src/core/Akka.Remote/Configuration/Remote.conf index 9d67fd62628..0cc7b1e2fed 100644 --- a/src/core/Akka.Remote/Configuration/Remote.conf +++ b/src/core/Akka.Remote/Configuration/Remote.conf @@ -565,6 +565,18 @@ akka { # Set to false only if your environment cannot support client certificate authentication. # Default: true (secure by default) require-mutual-authentication = true + + # Enable or disable certificate hostname validation during TLS handshake. + # When true: Traditional TLS hostname validation is performed (certificate CN/SAN must match target hostname) + # When false: Only validates certificate chain against CA, ignores hostname mismatches + # + # Set to false for scenarios such as: + # - Mutual TLS with per-node certificates in P2P clusters + # - IP-based connections where certificates use DNS names + # - Service discovery with dynamic addresses + # + # Default: false (disabled for backward compatibility and mutual TLS flexibility) + validate-certificate-hostname = false } } diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs index e533b7a54e5..a6f178c3c7c 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs @@ -10,16 +10,72 @@ namespace Akka.Remote.Transport.DotNetty; +/// +/// Programmatic setup for DotNetty SSL/TLS configuration. +/// Provides a fluent API alternative to HOCON configuration. +/// public sealed class DotNettySslSetup: Setup { + /// + /// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true, ValidateCertificateHostname = false + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation) + : this(certificate, suppressValidation, requireMutualAuthentication: true, validateCertificateHostname: false) + { + } + + /// + /// Constructor for backward compatibility - defaults to ValidateCertificateHostname = false + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) + /// When true, requires mutual TLS authentication (both client and server present certificates) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false) + { + } + + /// + /// Full constructor with all SSL/TLS configuration options + /// + /// X509 certificate used to establish SSL/TLS + /// When true, suppresses certificate chain validation (use only for development/testing) + /// When true, requires mutual TLS authentication (both client and server present certificates) + /// When true, enables hostname validation (certificate CN/SAN must match target hostname) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { Certificate = certificate; SuppressValidation = suppressValidation; + RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; } - + + /// + /// X509 certificate used to establish Secure Socket Layer (SSL) between two remote endpoints. + /// public X509Certificate2 Certificate { get; } + + /// + /// Flag used to suppress certificate validation - use true only when on dev machine or for testing. + /// public bool SuppressValidation { get; } - internal SslSettings Settings => new SslSettings(Certificate, 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 bool RequireMutualAuthentication { get; } + + /// + /// When true, enables traditional TLS hostname validation (certificate CN/SAN must match target hostname). + /// When false, only validates certificate chain against CA, ignores hostname mismatches. + /// Default is false for backward compatibility and to support mutual TLS scenarios with per-node certificates, + /// IP-based connections, or dynamic service discovery. + /// + public bool ValidateCertificateHostname { get; } + + internal SslSettings Settings => new SslSettings(Certificate, SuppressValidation, RequireMutualAuthentication, ValidateCertificateHostname); } diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs index 621f9e57fec..10f859f88a6 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs @@ -352,42 +352,39 @@ private void SetClientPipeline(IChannel channel, Address remoteAddress) if (Settings.EnableSsl) { var certificate = Settings.Ssl.Certificate; - var host = certificate.GetNameInfo(X509NameType.DnsName, false); + // Use the remote address host for TLS validation, not the client's certificate name + var host = remoteAddress.Host; IChannelHandler tlsHandler; - if (Settings.Ssl.SuppressValidation) + // Build validation callback using type-safe factory methods + // These settings are independent and can be combined: + // - suppressValidation: Controls chain/CA validation (for self-signed certs) + // - validateCertificateHostname: Controls hostname matching (for per-node certs, IPs, etc.) + var chainValidation = Settings.Ssl.SuppressValidation + ? ChainValidationMode.IgnoreChainErrors + : ChainValidationMode.ValidateChain; + + var hostnameValidation = Settings.Ssl.ValidateCertificateHostname + ? HostnameValidationMode.ValidateHostname + : HostnameValidationMode.IgnoreHostnameMismatch; + + var validationCallback = TlsValidationCallbacks.Create(chainValidation, hostnameValidation, Log); + + if (Settings.Ssl.RequireMutualAuthentication) { - // 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)); - } + // Provide client cert for mutual TLS + tlsHandler = new TlsHandler( + stream => new SslStream(stream, true, validationCallback, + (_, _, _, _, _) => certificate), + 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); - } + // Standard TLS: Only validate server certificate, no client cert + tlsHandler = new TlsHandler( + stream => new SslStream(stream, true, validationCallback), + new ClientTlsSettings(host)); } channel.Pipeline.AddFirst("TlsHandler", tlsHandler); diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs index 40ff883a58b..be7be695744 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs @@ -8,10 +8,12 @@ using System; using System.Linq; using System.Net; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; using Akka.Actor; using Akka.Configuration; using Akka.Dispatch; +using Akka.Event; using Akka.Util; using DotNetty.Buffers; @@ -270,6 +272,7 @@ private static SslSettings Create(Config config) 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); + var validateCertificateHostname = config.GetBoolean("validate-certificate-hostname", false); if (config.GetBoolean("certificate.use-thumprint-over-file") || config.GetBoolean("certificate.use-thumbprint-over-file")) @@ -283,7 +286,8 @@ private static SslSettings Create(Config config) storeName: config.GetString("certificate.store-name"), storeLocation: ParseStoreLocationName(config.GetString("certificate.store-location")), suppressValidation: config.GetBoolean("suppress-validation"), - requireMutualAuthentication: requireMutualAuth); + requireMutualAuthentication: requireMutualAuth, + validateCertificateHostname: validateCertificateHostname); } var flagsRaw = config.GetStringList("certificate.flags", new string[] { }); @@ -294,7 +298,8 @@ private static SslSettings Create(Config config) certificatePassword: config.GetString("certificate.password"), flags: flags, suppressValidation: config.GetBoolean("suppress-validation"), - requireMutualAuthentication: requireMutualAuth); + requireMutualAuthentication: requireMutualAuth, + validateCertificateHostname: validateCertificateHostname); } @@ -341,26 +346,44 @@ private static X509KeyStorageFlags ParseKeyStorageFlag(string str) /// public readonly bool RequireMutualAuthentication; + /// + /// When true, enables traditional TLS hostname validation (certificate CN/SAN must match target hostname). + /// When false, only validates certificate chain against CA, ignores hostname mismatches. + /// Default is false for backward compatibility and to support mutual TLS scenarios with per-node certificates, + /// IP-based connections, or dynamic service discovery. + /// + public readonly bool ValidateCertificateHostname; + private SslSettings() { Certificate = null; SuppressValidation = false; RequireMutualAuthentication = false; + ValidateCertificateHostname = false; } /// - /// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true + /// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true, ValidateCertificateHostname = false /// public SslSettings(X509Certificate2 certificate, bool suppressValidation) - : this(certificate, suppressValidation, true) + : this(certificate, suppressValidation, requireMutualAuthentication: true, validateCertificateHostname: false) { } + /// + /// Constructor for backward compatibility - defaults to ValidateCertificateHostname = false + /// public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false) + { + } + + public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { Certificate = certificate; SuppressValidation = suppressValidation; RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; } /// @@ -409,7 +432,7 @@ public void ValidateCertificate() } } - private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation, bool requireMutualAuthentication) + private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { using var store = new X509Store(storeName, storeLocation); store.Open(OpenFlags.ReadOnly); @@ -424,9 +447,10 @@ private SslSettings(string certificateThumbprint, string storeName, StoreLocatio Certificate = find[0]; SuppressValidation = suppressValidation; RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; } - private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation, bool requireMutualAuthentication) + private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { 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`)"); @@ -434,7 +458,134 @@ private SslSettings(string certificatePath, string certificatePassword, X509KeyS Certificate = new X509Certificate2(certificatePath, certificatePassword, flags); SuppressValidation = suppressValidation; RequireMutualAuthentication = requireMutualAuthentication; + ValidateCertificateHostname = validateCertificateHostname; + } + } + + /// + /// INTERNAL API + /// + /// Specifies how certificate chain validation should be performed during TLS handshake. + /// Controls whether to validate certificates against the system CA trust store. + /// + internal enum ChainValidationMode + { + /// + /// Validate certificate chain against system CA trust store. + /// Use for production with CA-signed certificates. + /// Certificates must chain to a trusted root CA. + /// + ValidateChain, + + /// + /// Ignore certificate chain validation errors. + /// Use for development/testing with self-signed certificates. + /// WARNING: Allows untrusted certificates - use only in non-production environments. + /// + IgnoreChainErrors + } + + /// + /// INTERNAL API + /// + /// Specifies how hostname validation should be performed during TLS handshake. + /// Controls whether the certificate CN/SAN must match the connection target hostname. + /// + internal enum HostnameValidationMode + { + /// + /// Validate that certificate CN/SAN matches target hostname. + /// Use for traditional client-server TLS with DNS-based connections. + /// Prevents man-in-the-middle attacks by ensuring certificate matches expected server. + /// + ValidateHostname, + + /// + /// Ignore hostname mismatch errors. + /// Use for: Mutual TLS with per-node certificates, IP-based connections, dynamic service discovery. + /// Still validates certificate chain (unless IgnoreChainErrors is also set). + /// + IgnoreHostnameMismatch + } + + /// + /// INTERNAL API + /// + /// Factory for creating TLS certificate validation callbacks with different security policies. + /// Provides type-safe, self-documenting methods for configuring certificate validation behavior. + /// + internal static class TlsValidationCallbacks + { + /// + /// Creates a configurable validation callback that filters SSL policy errors based on validation modes. + /// + /// Controls certificate chain/CA validation + /// Controls hostname matching validation + /// Logger for validation failures + /// Validation callback configured according to parameters + public static RemoteCertificateValidationCallback Create( + ChainValidationMode chainValidation, + HostnameValidationMode hostnameValidation, + ILoggingAdapter log) + { + return (sender, cert, chain, errors) => + { + var filteredErrors = errors; + + // Apply chain validation filter + if (chainValidation == ChainValidationMode.IgnoreChainErrors) + { + filteredErrors &= ~SslPolicyErrors.RemoteCertificateChainErrors; + filteredErrors &= ~SslPolicyErrors.RemoteCertificateNotAvailable; + } + + // Apply hostname validation filter + if (hostnameValidation == HostnameValidationMode.IgnoreHostnameMismatch) + { + filteredErrors &= ~SslPolicyErrors.RemoteCertificateNameMismatch; + } + + if (filteredErrors == SslPolicyErrors.None) + return true; // Certificate is valid after applying configured filters + + // Log detailed error for validation failures + var cert509 = cert as X509Certificate2; + var detailedError = TlsErrorMessageBuilder.BuildSslPolicyErrorMessage( + filteredErrors, cert509, chain); + var mode = chainValidation == ChainValidationMode.IgnoreChainErrors ? "suppress-validation enabled" : + hostnameValidation == HostnameValidationMode.ValidateHostname ? "full validation" : "hostname validation disabled"; + log.Error("TLS certificate validation failed ({0}):\n{1}", mode, detailedError); + return false; + }; } + + /// + /// Creates validation callback for full TLS validation (chain + hostname). + /// Use for traditional client-server TLS with CA-signed certificates and DNS names. + /// + public static RemoteCertificateValidationCallback ValidateFull(ILoggingAdapter log) + => Create(ChainValidationMode.ValidateChain, HostnameValidationMode.ValidateHostname, log); + + /// + /// Creates validation callback that validates chain but ignores hostname mismatches. + /// Use for: Mutual TLS with per-node certificates, IP-based connections, dynamic service discovery. + /// + public static RemoteCertificateValidationCallback ValidateChainOnly(ILoggingAdapter log) + => Create(ChainValidationMode.ValidateChain, HostnameValidationMode.IgnoreHostnameMismatch, log); + + /// + /// Creates validation callback that ignores chain errors but validates hostname. + /// Use for: Testing with self-signed certificates where hostname should still match. + /// + public static RemoteCertificateValidationCallback ValidateHostnameOnly(ILoggingAdapter log) + => Create(ChainValidationMode.IgnoreChainErrors, HostnameValidationMode.ValidateHostname, log); + + /// + /// Creates validation callback that accepts all certificates without validation. + /// FOR TESTING ONLY. WARNING: Disables all security checks including chain, hostname, and expiration. + /// + public static RemoteCertificateValidationCallback AcceptAll() + => (_, _, _, _) => true; } ///