diff --git a/docs/articles/remoting/security.md b/docs/articles/remoting/security.md index 4b7484488ed..c83663a3e07 100644 --- a/docs/articles/remoting/security.md +++ b/docs/articles/remoting/security.md @@ -122,54 +122,82 @@ When `suppress-validation = true`: * Any environment processing sensitive data * Any multi-tenant environment -### Hostname Validation +### Validation Strategies: HOCON vs Programmatic (v1.5.52+) -**New in v1.5.52+:** The `validate-certificate-hostname` setting controls whether the certificate CN/SAN must match the target hostname. +Two independent validation decisions determine your TLS security posture: -**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. +1. **Chain Validation** - Verify certificate against trusted CAs (`suppress-validation`) +2. **Hostname Validation** - Verify certificate CN/SAN matches target (`validate-certificate-hostname`) +3. **Mutual Authentication** - Require both sides authenticate (`require-mutual-authentication`) -#### Disabled (Default) +#### Decision Matrix: Which Combination to Use -When `validate-certificate-hostname = false` (the default): +| Use Case | suppress-validation | validate-hostname | mutual-auth | Config Approach | +|----------|---------------------|-------------------|-------------|-----------------| +| **P2P Cluster (Default)** | `false` | `false` | `true` | HOCON ✓ or Programmatic | +| **Client-Server with Shared Cert** | `false` | `true` | `true` | HOCON ✓ or Programmatic | +| **Development/Testing** | `true` | `false` | `false` | HOCON only | +| **Certificate Pinning** | `false` | `false` | `true` | **Programmatic required** | +| **Custom Subject/Issuer Validation** | `false` | `false` | `true` | **Programmatic required** | + +#### HOCON Configuration Approach -**What it does:** +When `validate-certificate-hostname = false` (the default): * Skips hostname validation * Only validates certificate chain (if `suppress-validation = false`) +* **Best for:** Mutual TLS with per-node certificates, IP-based connections, Kubernetes dynamic discovery -**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 +When `validate-certificate-hostname = true`: -**This is the default** for backward compatibility and to support common Akka.NET cluster patterns. +* Certificate CN (Common Name) or SAN (Subject Alternative Name) must match the target hostname +* Traditional TLS hostname validation as used in HTTPS +* **Best for:** Client-server architectures with shared certificates and stable DNS names -#### Enabled +**HOCON Example - P2P Cluster (Common Default):** -When `validate-certificate-hostname = true`: +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false # Validate CA chain + require-mutual-authentication = true # Both sides authenticate + validate-certificate-hostname = false # Default: Allow per-node certs + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + } + } +} +``` -**What it validates:** +**HOCON Example - Client-Server with Hostname Validation:** -* Certificate CN (Common Name) or SAN (Subject Alternative Name) must match the target hostname -* Traditional TLS hostname validation as used in HTTPS +```hocon +akka.remote.dot-netty.tcp { + enable-ssl = true + ssl { + suppress-validation = false # Validate CA chain + require-mutual-authentication = true # Both sides authenticate + validate-certificate-hostname = true # Hostname must match + certificate { + use-thumbprint-over-file = true + thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" + } + } +} +``` -**When to use:** +#### Programmatic Configuration Approach -* **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 +Use `DotNettySslSetup` with `CertificateValidation` helpers when you need: -### Validation Mode Combinations +* **Certificate pinning** - Accept only specific certificates +* **Subject/Issuer validation** - Custom certificate attribute checks +* **Custom business logic** - Domain-specific validation rules +* **Dynamic validation** - Load rules from runtime sources -| 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 | +See [Programmatic Certificate Validation](#programmatic-certificate-validation-v1555) below for detailed examples. ### Self-Signed Certificates: The Right Way @@ -305,6 +333,80 @@ akka.remote.dot-netty.tcp { 5. Scroll to Thumbprint field 6. Copy the value (remove spaces) +## Programmatic Certificate Validation (v1.5.55+) + +**New in Akka.NET v1.5.55:** Certificate validation can now be configured programmatically using `DotNettySslSetup` with custom validators. This provides fine-grained control over validation logic while maintaining full backward compatibility with HOCON configuration. + +### When to Use Programmatic Configuration + +Use programmatic setup when you need: + +* **Custom validation logic** - Implement domain-specific validation rules +* **Certificate pinning** - Accept only specific certificates by thumbprint +* **Subject/Issuer validation** - Verify certificate attributes +* **Dynamic configuration** - Load validation rules from runtime sources +* **Composable validators** - Combine multiple validation strategies + +### CertificateValidation Helper Factory + +The `CertificateValidation` static class provides 7 helper methods for common validation patterns: + +#### Basic Chain Validation + +[!code-csharp[ProgrammaticMutualTlsSetup](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=ProgrammaticMutualTlsSetup)] + +#### Certificate Pinning by Thumbprint + +Accept only certificates with specific thumbprints. Prevents man-in-the-middle attacks if CA is compromised: + +[!code-csharp[CertificatePinningExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=CertificatePinningExample)] + +#### Custom Validation Logic with ChainPlusThen + +Perform standard chain validation, then apply custom business logic: + +[!code-csharp[CustomValidationLogicExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=CustomValidationLogicExample)] + +#### Hostname Validation + +Enable traditional TLS hostname validation (certificate CN/SAN must match target hostname). Use for client-server architectures with shared certificates: + +[!code-csharp[HostnameValidationExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=HostnameValidationExample)] + +#### Subject DN Validation + +Accept only certificates with specific subject names: + +[!code-csharp[SubjectValidationExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=SubjectValidationExample)] + +### CertificateValidation Helper Methods + +| Method | Purpose | +|--------|---------| +| `ValidateChain()` | CA chain validation with full error details | +| `ValidateHostname()` | Traditional TLS hostname validation (CN/SAN matching) | +| `PinnedCertificate()` | Certificate pinning by thumbprint whitelist | +| `ValidateSubject()` | Subject DN pattern matching (e.g., CN, O, OU) | +| `ValidateIssuer()` | Issuer DN pattern matching | +| `Combine()` | Compose multiple validators (AND logic) | +| `ChainPlusThen()` | Chain validation + custom business logic | + +### Custom Validator Precedence + +When both custom validators and HOCON config are present, custom validators take precedence: + +```csharp +// This validator will be used regardless of HOCON suppress-validation setting +var customValidator = CertificateValidation.ValidateChain(log); +var sslSetup = new DotNettySslSetup( + certificate: cert, + suppressValidation: false, // Ignored when customValidator provided + customValidator: customValidator +); +``` + +This ensures programmatic validation logic always takes priority for explicit security requirements. + ## Startup Certificate Validation (v1.5.52+) **New in Akka.NET v1.5.52:** The transport now validates certificate configuration at startup, preventing runtime failures. @@ -344,11 +446,11 @@ $acl.AddAccessRule($accessRule) Set-Acl $keyFullPath $acl ``` -## Mutual TLS Authentication (v1.5.52+) +## Understanding Mutual TLS (mTLS) vs Standard TLS (v1.5.52+) -**New in Akka.NET v1.5.52:** Support for mutual TLS (mTLS) where both client and server must authenticate with certificates. +Akka.NET supports both standard TLS and mutual TLS (mTLS), configured via the `require-mutual-authentication` setting in the [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) section above. -### Standard TLS vs Mutual TLS +### Visual Comparison **Standard TLS (Server Authentication Only):** @@ -380,38 +482,28 @@ sequenceDiagram 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:** +**Enable mutual TLS (`require-mutual-authentication = true`) when:** -* All nodes are under your control (typical Akka.NET cluster) +* All nodes are under your control (typical Akka.NET cluster) ✓ **Recommended** * 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 (`require-mutual-authentication = false`) 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.** +**Default is TRUE for security-by-default posture** (since v1.5.52). ### 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) + * Without mTLS: A node with broken certificate can connect OUT to cluster (client TLS succeeds) + * With mTLS: Node cannot connect without working certificate (enforced both ways) 2. **Defense-in-Depth** * Startup validation prevents broken servers @@ -422,92 +514,59 @@ For production with Windows Certificate Store: * Every node must prove it owns the certificate * Prevents certificate theft attacks (attacker needs private key) +For configuration examples in both HOCON and programmatic styles, see [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) and [Programmatic Certificate Validation](#programmatic-certificate-validation-v1555) sections above. + ## Configuration Examples and Security Analysis -### INSECURE: Development/Testing Only +This section provides concrete examples of different security configurations and their tradeoffs. -[!code-csharp[DevTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=DevTlsConfig)] +### HOCON Configuration Security Levels -**Why this is bad:** +**Development/Testing Only (INSECURE):** -* `suppress-validation = true` accepts ANY certificate (even self-signed or expired) +[!code-csharp[DevTlsConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=DevTlsConfig)] + +* ⚠️ `suppress-validation = true` accepts ANY certificate (self-signed, expired, invalid chains) * Vulnerable to man-in-the-middle attacks * No client authentication +* **Use only:** Local development, never in networked environments -**When to use:** Local development only, never in any environment accessible from network. - -### GOOD: Standard TLS for Production +**Standard TLS (Medium-High Security):** [!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 +* **Use when:** Mutual TLS is not feasible -### BEST: Mutual TLS for Maximum Security +**Mutual TLS with Windows Certificate Store (Maximum Security - RECOMMENDED):** -```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) - validate-certificate-hostname = false # DEFAULT: Hostname validation disabled (suitable for P2P with per-node certs) - certificate { - use-thumbprint-over-file = true - thumbprint = "2531c78c51e5041d02564697a88af8bc7a7ce3e3" - store-name = "My" - store-location = "local-machine" - } - } -} -``` +[!code-csharp[WindowsCertStoreConfig](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=WindowsCertStoreConfig)] -**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. +* ✓ Both client and server prove identity +* ✓ All traffic encrypted +* ✓ Prevents misconfigured nodes from connecting +* ✓ Private keys protected by Windows ACL +* **Use when:** Production Akka.NET clusters (default recommended configuration) -**About hostname validation:** +**Mutual TLS for P2P Clusters with Per-Node Certificates:** -* 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 +Refer to the [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) section for HOCON example showing P2P cluster setup. -**Security level:** Maximum +**Client-Server with Hostname Validation:** -* Both client and server prove identity -* All traffic encrypted -* Prevents misconfigured nodes from connecting -* Defense-in-depth security -* Recommended for all production deployments +Refer to the [Validation Strategies](#validation-strategies-hocon-vs-programmatic-v1552) section for HOCON example with hostname validation enabled. -### Configuration with Hostname Validation Enabled +### Programmatic Configuration Security Levels -For client-server architectures where all nodes connect via DNS names and share the same certificate: +For certificate pinning, subject/issuer validation, or custom logic, use programmatic setup: -```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" - } - } -} -``` +[!code-csharp[ProgrammaticMutualTlsSetup](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=ProgrammaticMutualTlsSetup)] -**When to use hostname validation:** +[!code-csharp[CertificatePinningExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=CertificatePinningExample)] -* 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 +See [Programmatic Certificate Validation](#programmatic-certificate-validation-v1555) section for more examples. ## Untrusted Mode 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 d9e856313c7..aae52811ad8 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 @@ -860,12 +860,34 @@ namespace Akka.Remote.Transport } namespace Akka.Remote.Transport.DotNetty { + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class static CertificateValidation + { + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ChainPlusThen([System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 1, + 2, + 2, + 1})] System.Func customCheck, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback Combine(params Akka.Remote.Transport.DotNetty.CertificateValidationCallback[] validators) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback PinnedCertificate(params string[] allowedThumbprints) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateChain([System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + [return: System.Runtime.CompilerServices.NullableAttribute(1)] + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateHostname(string expectedHostname = null, Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateIssuer(string expectedIssuerPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateSubject(string expectedSubjectPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + } + public delegate bool CertificateValidationCallback([System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, [System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Chain chain, string remotePeer, System.Net.Security.SslPolicyErrors errors, Akka.Event.ILoggingAdapter log); + [System.Runtime.CompilerServices.NullableAttribute(0)] 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 DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; } + [System.Runtime.CompilerServices.NullableAttribute(2)] + public Akka.Remote.Transport.DotNetty.CertificateValidationCallback CustomValidator { get; } public bool RequireMutualAuthentication { get; } public bool SuppressValidation { get; } public bool ValidateCertificateHostname { get; } 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 3a5d9a28747..cb1f4ab80ca 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 @@ -860,12 +860,34 @@ namespace Akka.Remote.Transport } namespace Akka.Remote.Transport.DotNetty { + [System.Runtime.CompilerServices.NullableAttribute(0)] + public class static CertificateValidation + { + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ChainPlusThen([System.Runtime.CompilerServices.NullableAttribute(new byte[] { + 1, + 2, + 2, + 1})] System.Func customCheck, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback Combine(params Akka.Remote.Transport.DotNetty.CertificateValidationCallback[] validators) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback PinnedCertificate(params string[] allowedThumbprints) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateChain([System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + [return: System.Runtime.CompilerServices.NullableAttribute(1)] + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateHostname(string expectedHostname = null, Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateIssuer(string expectedIssuerPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + public static Akka.Remote.Transport.DotNetty.CertificateValidationCallback ValidateSubject(string expectedSubjectPattern, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Event.ILoggingAdapter log = null) { } + } + public delegate bool CertificateValidationCallback([System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, [System.Runtime.CompilerServices.NullableAttribute(2)] System.Security.Cryptography.X509Certificates.X509Chain chain, string remotePeer, System.Net.Security.SslPolicyErrors errors, Akka.Event.ILoggingAdapter log); + [System.Runtime.CompilerServices.NullableAttribute(0)] 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 DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } + public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, [System.Runtime.CompilerServices.NullableAttribute(2)] Akka.Remote.Transport.DotNetty.CertificateValidationCallback customValidator) { } public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; } + [System.Runtime.CompilerServices.NullableAttribute(2)] + public Akka.Remote.Transport.DotNetty.CertificateValidationCallback CustomValidator { get; } public bool RequireMutualAuthentication { get; } public bool SuppressValidation { get; } public bool ValidateCertificateHostname { get; } diff --git a/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs b/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs index ea2978d85d4..551644e9c07 100644 --- a/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs +++ b/src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs @@ -5,7 +5,10 @@ // //----------------------------------------------------------------------- +using System.Security.Cryptography.X509Certificates; +using Akka.Actor.Setup; using Akka.Configuration; +using Akka.Remote.Transport.DotNetty; namespace Akka.Docs.Tests.Configuration { @@ -80,5 +83,133 @@ public class TlsConfigurationSample } "); #endregion + + #region ProgrammaticMutualTlsSetup + /// + /// Example of programmatic mutual TLS setup using DotNettySslSetup with custom validation. + /// This allows full programmatic control over certificate validation logic. + /// + public static void ProgrammaticMutualTlsSetup() + { + // Load or obtain your certificate + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Create custom validator combining multiple validation strategies + var customValidator = CertificateValidation.Combine( + // Validate the certificate chain + CertificateValidation.ValidateChain(), + // Also pin against known thumbprints for additional security + CertificateValidation.PinnedCertificate(certificate.Thumbprint) + ); + + // Setup SSL with custom validator taking precedence over HOCON config + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: customValidator + ); + } + #endregion + + #region CertificatePinningExample + /// + /// Example of certificate pinning - only accept certificates with specific thumbprints. + /// Useful for preventing man-in-the-middle attacks with compromised CAs. + /// + public static void CertificatePinningSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Allow only specific certificates by thumbprint + var validator = CertificateValidation.PinnedCertificate( + "2531c78c51e5041d02564697a88af8bc7a7ce3e3", // Production cert + "abc123def456789ghi012jkl345mno678pqr901stu" // Backup cert + ); + + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: validator + ); + } + #endregion + + #region CustomValidationLogicExample + /// + /// Example of custom certificate validation logic combined with standard validation. + /// Allows complete control over what certificates are accepted. + /// + public static void CustomValidationLogicSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Start with standard chain validation, then add custom logic + var validator = CertificateValidation.ChainPlusThen( + // Custom validation - check certificate subject matches expected peer + (cert, chain, peer) => + { + // Accept only certificates from authorized-peer + if (cert?.Subject != null && cert.Subject.Contains("CN=authorized-peer")) + { + return true; // Accept this certificate + } + return false; // Reject all others + } + ); + + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: validator + ); + } + #endregion + + #region HostnameValidationExample + /// + /// Example of enabling traditional hostname validation for client-server architectures. + /// Use when all nodes share the same certificate with matching CN/SAN. + /// + public static void HostnameValidationSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Enable both chain validation and hostname validation + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + validateCertificateHostname: true // Enable traditional TLS hostname validation + ); + } + #endregion + + #region SubjectValidationExample + /// + /// Example of subject DN validation - only accept certificates with specific subject names. + /// Useful for verifying peer identity based on certificate subject. + /// Supports wildcards: "CN=Akka-Node-*" matches "CN=Akka-Node-001" + /// + public static void SubjectValidationSetup() + { + var certificate = new X509Certificate2("path/to/certificate.pfx", "password"); + + // Accept certificates matching the subject pattern + // Wildcards are supported: CN=Akka-Node-* matches CN=Akka-Node-001 + var validator = CertificateValidation.ValidateSubject( + "CN=Akka-Node-*" // Pattern to match + ); + + var sslSetup = new DotNettySslSetup( + certificate: certificate, + suppressValidation: false, + requireMutualAuthentication: true, + customValidator: validator + ); + } + #endregion } } \ No newline at end of file diff --git a/src/core/Akka.Remote.Tests/Transport/CertificateValidationHelpersSpec.cs b/src/core/Akka.Remote.Tests/Transport/CertificateValidationHelpersSpec.cs new file mode 100644 index 00000000000..dfeea03d1f2 --- /dev/null +++ b/src/core/Akka.Remote.Tests/Transport/CertificateValidationHelpersSpec.cs @@ -0,0 +1,246 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Akka.Event; +using Akka.Remote.Transport.DotNetty; +using Akka.TestKit; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Remote.Tests.Transport +{ + /// + /// Unit tests for CertificateValidation helper methods to ensure proper edge case handling + /// + public class CertificateValidationHelpersSpec : AkkaSpec + { + private const string ValidCertPath = "Resources/akka-validcert.pfx"; + private const string Password = "password"; + private readonly ILoggingAdapter _log; + + public CertificateValidationHelpersSpec(ITestOutputHelper output) : base(output) + { + _log = Logging.GetLogger(Sys, typeof(CertificateValidationHelpersSpec)); + } + + #region PinnedCertificate Tests + + [Fact(DisplayName = "PinnedCertificate should reject null certificate")] + public void PinnedCertificate_should_reject_null_certificate() + { + // Arrange + var validator = CertificateValidation.PinnedCertificate("ABCD1234"); + + // Act & Assert + EventFilter.Error(contains: "certificate is null").ExpectOne(() => + { + var result = validator(null, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + // Note: X509Certificate2 always has a thumbprint when properly constructed, + // so we can't test the empty thumbprint case directly. The null check in + // PinnedCertificate is defensive programming for edge cases. + + [Fact(DisplayName = "PinnedCertificate should throw if no thumbprints provided")] + public void PinnedCertificate_should_throw_if_no_thumbprints_provided() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.PinnedCertificate()); + Assert.Throws(() => CertificateValidation.PinnedCertificate(null)); + Assert.Throws(() => CertificateValidation.PinnedCertificate(new string[0])); + } + + [Fact(DisplayName = "PinnedCertificate should throw if only empty/whitespace thumbprints provided")] + public void PinnedCertificate_should_throw_if_only_empty_thumbprints_provided() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.PinnedCertificate("")); + Assert.Throws(() => CertificateValidation.PinnedCertificate("", " ", null)); + Assert.Throws(() => CertificateValidation.PinnedCertificate(" ", "\t", "\n")); + } + + [Fact(DisplayName = "PinnedCertificate should filter out empty thumbprints and use valid ones")] + public void PinnedCertificate_should_filter_empty_thumbprints() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var thumbprint = cert.Thumbprint; + + // Include some empty/null values that should be filtered out + var validator = CertificateValidation.PinnedCertificate("", thumbprint, null, " ", thumbprint.ToLower()); + + // Act + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + + // Assert + Assert.True(result); // Should accept because valid thumbprint is in the list + } + + [Fact(DisplayName = "PinnedCertificate should be case-insensitive for thumbprints")] + public void PinnedCertificate_should_be_case_insensitive() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var thumbprint = cert.Thumbprint; + + // Test with lowercase thumbprint in allowed list + var validator = CertificateValidation.PinnedCertificate(thumbprint.ToLower()); + + // Act + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + + // Assert + Assert.True(result); // Should accept due to case-insensitive comparison + } + + [Fact(DisplayName = "PinnedCertificate should accept certificate with matching thumbprint from multiple allowed")] + public void PinnedCertificate_should_accept_from_multiple_allowed() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var thumbprint = cert.Thumbprint; + + var validator = CertificateValidation.PinnedCertificate( + "1111111111111111111111111111111111111111", + thumbprint, + "2222222222222222222222222222222222222222"); + + // Act + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + + // Assert + Assert.True(result); + } + + [Fact(DisplayName = "PinnedCertificate should reject certificate with non-matching thumbprint")] + public void PinnedCertificate_should_reject_non_matching_thumbprint() + { + // Arrange + var cert = new X509Certificate2(ValidCertPath, Password); + var validator = CertificateValidation.PinnedCertificate( + "1111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222"); + + // Act & Assert + EventFilter.Error(contains: "not in allowed list").ExpectOne(() => + { + var result = validator(cert, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + #endregion + + #region ValidateSubject Tests + + [Fact(DisplayName = "ValidateSubject should reject null certificate")] + public void ValidateSubject_should_reject_null_certificate() + { + // Arrange + var validator = CertificateValidation.ValidateSubject("CN=TestSubject"); + + // Act & Assert + EventFilter.Error(contains: "certificate is null").ExpectOne(() => + { + var result = validator(null, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + [Fact(DisplayName = "ValidateSubject should throw if pattern is null or empty")] + public void ValidateSubject_should_throw_if_pattern_null_or_empty() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.ValidateSubject(null)); + Assert.Throws(() => CertificateValidation.ValidateSubject("")); + Assert.Throws(() => CertificateValidation.ValidateSubject(" ")); + } + + #endregion + + #region ValidateIssuer Tests + + [Fact(DisplayName = "ValidateIssuer should reject null certificate")] + public void ValidateIssuer_should_reject_null_certificate() + { + // Arrange + var validator = CertificateValidation.ValidateIssuer("CN=TestIssuer"); + + // Act & Assert + EventFilter.Error(contains: "certificate is null").ExpectOne(() => + { + var result = validator(null, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + }); + } + + [Fact(DisplayName = "ValidateIssuer should throw if pattern is null or empty")] + public void ValidateIssuer_should_throw_if_pattern_null_or_empty() + { + // Act & Assert + Assert.Throws(() => CertificateValidation.ValidateIssuer(null)); + Assert.Throws(() => CertificateValidation.ValidateIssuer("")); + Assert.Throws(() => CertificateValidation.ValidateIssuer(" ")); + } + + #endregion + + #region Combine Tests + + [Fact(DisplayName = "Combine should handle null validators array")] + public void Combine_should_handle_null_validators() + { + // Act & Assert - Should throw ArgumentException + Assert.Throws(() => CertificateValidation.Combine(null)); + } + + [Fact(DisplayName = "Combine should handle empty validators array")] + public void Combine_should_handle_empty_validators() + { + // Act & Assert - Should throw ArgumentException + Assert.Throws(() => CertificateValidation.Combine()); + Assert.Throws(() => CertificateValidation.Combine(new CertificateValidationCallback[0])); + } + + [Fact(DisplayName = "Combine should short-circuit on first failure")] + public void Combine_should_short_circuit_on_first_failure() + { + // Arrange + var callCount = 0; + CertificateValidationCallback validator1 = (cert, chain, peer, errors, log) => + { + callCount++; + log.Error("First validator failed"); + return false; // Fail + }; + CertificateValidationCallback validator2 = (cert, chain, peer, errors, log) => + { + callCount++; + log.Error("Second validator should never be reached"); + return true; // This should never be called + }; + + var combined = CertificateValidation.Combine(validator1, validator2); + var cert = new X509Certificate2(ValidCertPath, Password); + + // Act & Assert + EventFilter.Error(contains: "First validator failed").ExpectOne(() => + { + var result = combined(cert, null, "test-peer", SslPolicyErrors.None, _log); + Assert.False(result); + Assert.Equal(1, callCount); // Only first validator should be called - short-circuit behavior + }); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs index 172ea725130..62cecfbe8b9 100644 --- a/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/DotNettySslSetupSpec.cs @@ -73,13 +73,9 @@ public DotNettySslSetupSpec(ITestOutputHelper output) : base(TestActorSystemSetu { } - #if !NET471 [Fact] public async Task Secure_transport_should_be_possible_between_systems_sharing_the_same_certificate() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - Setup(true); var probe = CreateTestProbe(); @@ -90,7 +86,6 @@ await AwaitAssertAsync(async () => await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); } - #endif [Fact] public async Task Secure_transport_should_NOT_be_possible_between_systems_using_SSL_and_one_not_using_it() @@ -247,6 +242,526 @@ public void DotNettySslSetup_should_override_HOCON_certificate() Assert.True(settings.Ssl.ValidateCertificateHostname); // From DotNettySslSetup, not HOCON } + [Fact(DisplayName = "DotNettySslSetup with CustomValidator that accepts should allow connection")] + public async Task CustomValidator_that_accepts_should_allow_connection() + { + var validatorCalled = false; + + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Custom validator that accepts all certificates + CertificateValidationCallback customValidator = (cert, chain, peer, errors, log) => + { + validatorCalled = true; + Output.WriteLine($"CustomValidator called for peer: {peer}"); + return true; // Accept all certificates + }; + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: customValidator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-custom-validator", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + + // Verify that CustomValidator was actually called + Assert.True(validatorCalled, "CustomValidator should have been invoked during TLS handshake"); + } + + [Fact(DisplayName = "DotNettySslSetup with CustomValidator that rejects should prevent connection")] + public async Task CustomValidator_that_rejects_should_prevent_connection() + { + var validatorCalled = false; + + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Custom validator that rejects all certificates + CertificateValidationCallback customValidator = (cert, chain, peer, errors, log) => + { + validatorCalled = true; + Output.WriteLine($"CustomValidator called for peer: {peer}, rejecting certificate"); + return false; // Reject all certificates + }; + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: customValidator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-reject-validator", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Connection should fail due to custom validator rejection - TLS handshake fails, so message never arrives + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectNoMsgAsync(TimeSpan.FromSeconds(3)); + + // Verify that CustomValidator was actually called + Assert.True(validatorCalled, "CustomValidator should have been invoked during TLS handshake"); + } + + [Fact(DisplayName = "DotNettySslSetup should pass CustomValidator to SslSettings")] + public void DotNettySslSetup_should_pass_CustomValidator_to_SslSettings() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + var customValidator = CertificateValidation.ValidateChain(); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: customValidator); + + 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-custom-validator", actorSystemSetup); + + // Verify that CustomValidator is passed through to SslSettings + var settings = DotNettyTransportSettings.Create(sys); + Assert.NotNull(settings.Ssl.CustomValidator); + Assert.Same(customValidator, settings.Ssl.CustomValidator); + } + + [Fact(DisplayName = "DotNettySslSetup should take precedence when both setup and HOCON SSL are configured (and log warning)")] + public void DotNettySslSetup_should_take_precedence_when_both_configured() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // HOCON certificate (different from setup) + const string hoconCertPath = "Resources/akka-validcert.pfx"; + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true); + + var actorSystemSetup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create().WithConfig(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 = true + ssl {{ + certificate {{ + path = ""{hoconCertPath}"" + password = ""{Password}"" + }} + suppress-validation = false + }} + }} +}}"))) + .And(sslSetup); + + using var sys = ActorSystem.Create("test-precedence", actorSystemSetup); + + // Verify DotNettySslSetup takes precedence over HOCON + // (A warning will be logged to help users understand this behavior) + var settings = DotNettyTransportSettings.Create(sys); + + Assert.True(settings.EnableSsl); + Assert.Equal(certificate.Thumbprint, settings.Ssl.Certificate.Thumbprint); + Assert.True(settings.Ssl.SuppressValidation); // From DotNettySslSetup, not HOCON (which has false) + } + + [Fact(DisplayName = "CertificateValidation.PinnedCertificate should accept certificates with matching thumbprint")] + public async Task PinnedCertificate_should_accept_matching_thumbprint() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that pins to this specific certificate + var validator = CertificateValidation.PinnedCertificate(certificate.Thumbprint); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-pinned-accept", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because thumbprint matches + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + } + + [Fact(DisplayName = "CertificateValidation.PinnedCertificate should reject certificates with non-matching thumbprint")] + public async Task PinnedCertificate_should_reject_non_matching_thumbprint() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that pins to a DIFFERENT thumbprint (connection should fail) + var validator = CertificateValidation.PinnedCertificate("0000000000000000000000000000000000000000"); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-pinned-reject", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Connection should fail due to thumbprint mismatch + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectNoMsgAsync(TimeSpan.FromSeconds(3)); + } + + [Fact(DisplayName = "CertificateValidation.ValidateSubject should accept certificates with matching subject")] + public async Task ValidateSubject_should_accept_matching_subject() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that accepts the certificate's actual subject + var validator = CertificateValidation.ValidateSubject(certificate.Subject); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-subject-accept", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because subject matches + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + } + + [Fact(DisplayName = "CertificateValidation.ValidateSubject should reject certificates with non-matching subject")] + public async Task ValidateSubject_should_reject_non_matching_subject() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator with a subject that won't match + var validator = CertificateValidation.ValidateSubject("CN=WrongSubject"); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-subject-reject", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Connection should fail due to subject mismatch + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectNoMsgAsync(TimeSpan.FromSeconds(3)); + } + + [Fact(DisplayName = "CertificateValidation.ValidateSubject should support wildcard patterns")] + public void ValidateSubject_should_support_wildcards() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Extract the CN from the subject (e.g., "CN=akka.net, O=Test") + // If subject is "CN=akka.net, O=Test", wildcard "CN=akka*" should match + var subject = certificate.Subject; + Output.WriteLine($"Certificate subject: {subject}"); + + // Test that wildcard pattern matching works + // Extract just the CN part for wildcard testing + var cnStart = subject.IndexOf("CN="); + if (cnStart >= 0) + { + var cnEnd = subject.IndexOf(",", cnStart); + var cn = cnEnd > cnStart ? subject.Substring(cnStart, cnEnd - cnStart) : subject.Substring(cnStart); + + // Extract the first few characters of CN for wildcard + var cnValue = cn.Substring(3); // Skip "CN=" + if (cnValue.Length > 3) + { + var wildcardPattern = "CN=" + cnValue.Substring(0, cnValue.Length - 2) + "*"; + Output.WriteLine($"Testing wildcard pattern: {wildcardPattern}"); + + var validator = CertificateValidation.ValidateSubject(wildcardPattern); + + // Invoke the validator directly to test pattern matching + var log = Akka.Event.Logging.GetLogger(Sys, "test"); + var result = validator(certificate, null, "test-peer", System.Net.Security.SslPolicyErrors.None, log); + Assert.True(result, $"Wildcard pattern '{wildcardPattern}' should match subject '{subject}'"); + } + } + } + + [Fact(DisplayName = "CertificateValidation.ValidateIssuer should accept certificates with matching issuer")] + public async Task ValidateIssuer_should_accept_matching_issuer() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that accepts the certificate's actual issuer + var validator = CertificateValidation.ValidateIssuer(certificate.Issuer); + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-issuer-accept", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because issuer matches + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + } + + [Fact(DisplayName = "CertificateValidation.ChainPlusThen should combine chain validation with custom logic")] + public async Task ChainPlusThen_should_combine_validation() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create validator that does chain validation PLUS custom check + // Note: For self-signed certificates, chain validation will fail, so we'll verify + // the custom logic is invoked by using Combine with a custom validator instead + var customCheckCalled = false; + var validator = CertificateValidation.Combine( + // Accept all for testing (since cert is self-signed) + (cert, chain, peer, errors, log) => true, + // Then custom check - just verify it's called + (cert, chain, peer, errors, log) => + { + customCheckCalled = true; + Output.WriteLine($"Custom validation called for peer: {peer}, subject: {cert?.Subject}"); + // Accept all - we're just testing that Combine works + return true; + } + ); + + var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: true, customValidator: validator); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-chainplusthen", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect (custom validator accepts all, then custom check passes) + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + + // Verify custom validation was actually called + Assert.True(customCheckCalled, "Custom validation logic should have been invoked"); + } + + [Fact(DisplayName = "CustomValidator should take precedence over validateCertificateHostname setting")] + public async Task CustomValidator_should_override_hostname_validation_setting() + { + var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet); + + // Create a custom validator that accepts everything + var customValidatorCalled = false; + CertificateValidationCallback customValidator = (cert, chain, peer, errors, log) => + { + customValidatorCalled = true; + Output.WriteLine($"CustomValidator called (should take precedence over hostname validation)"); + return true; // Accept all + }; + + // Configure with validateCertificateHostname=true, but customValidator should win + var sslSetup = new DotNettySslSetup( + certificate, + suppressValidation: false, + requireMutualAuthentication: true, + validateCertificateHostname: true, // This would normally fail + customValidator: customValidator // But this should take precedence + ); + + var sys2Setup = ActorSystemSetup.Empty + .And(BootstrapSetup.Create() + .WithConfig(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 = true + log-transport = true + } +}"))) + .And(sslSetup); + + _sys2 = ActorSystem.Create("sys2-custom-precedence", sys2Setup); + InitializeLogger(_sys2); + _sys2.ActorOf(Props.Create(), "echo"); + + var address = RARP.For(_sys2).Provider.DefaultAddress; + _echoPath = new RootActorPath(address) / "user" / "echo"; + + var probe = CreateTestProbe(); + + // Should successfully connect because CustomValidator accepts all (overrides hostname validation) + await AwaitAssertAsync(async () => + { + Sys.ActorSelection(_echoPath).Tell("hello", probe.Ref); + await probe.ExpectMsgAsync("hello", TimeSpan.FromSeconds(3)); + }, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100)); + + // Verify custom validator was called (proving it took precedence) + Assert.True(customValidatorCalled, "CustomValidator should have been invoked, proving it takes precedence"); + } + #region helper classes / methods protected override void AfterAll() diff --git a/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs b/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs index 2f6c2e2597c..a79cb03873b 100644 --- a/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs +++ b/src/core/Akka.Remote.Tests/Transport/DotNettySslSupportSpec.cs @@ -164,9 +164,6 @@ public DotNettySslSupportSpec(ITestOutputHelper output) : base(TestConfig(ValidC [Fact] public async Task Secure_transport_should_be_possible_between_systems_sharing_the_same_certificate() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - Setup(ValidCertPath, Password); var probe = CreateTestProbe(); @@ -181,8 +178,6 @@ await AwaitAssertAsync(async () => [LocalFact(SkipLocal = "Racy in Azure AzDo CI/CD")] public async Task Secure_transport_should_be_possible_between_systems_using_thumbprint() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; try { SetupThumbprint(ValidCertPath, Password); @@ -221,9 +216,6 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_is_provided_than_ArgumentNullException_should_be_thrown() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - var aggregateException = await Assert.ThrowsAsync(() => { Setup(true, null, Password); return Task.CompletedTask; @@ -238,9 +230,6 @@ public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_i [Fact] public async Task If_EnableSsl_configuration_is_true_but_not_valid_certificate_password_is_provided_than_WindowsCryptographicException_should_be_thrown() { - // skip this test due to linux/mono certificate issues - if (IsMono) return; - var aggregateException = await Assert.ThrowsAsync(() => { Setup(true, ValidCertPath, null); return Task.CompletedTask; diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs index a6f178c3c7c..4d4c395c891 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs @@ -5,6 +5,7 @@ // //----------------------------------------------------------------------- +#nullable enable using System.Security.Cryptography.X509Certificates; using Akka.Actor.Setup; @@ -22,7 +23,7 @@ public sealed class DotNettySslSetup: Setup /// 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) + : this(certificate, suppressValidation, requireMutualAuthentication: true, validateCertificateHostname: false, customValidator: null) { } @@ -33,7 +34,7 @@ public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation) /// 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) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false, customValidator: null) { } @@ -45,11 +46,37 @@ public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, b /// 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) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { + } + + /// + /// Constructor with custom certificate validation callback + /// + /// 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) + /// Custom certificate validation callback (overrides config-based validation when provided) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, CertificateValidationCallback? customValidator) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false, customValidator) + { + } + + /// + /// Full constructor with all SSL/TLS configuration options including custom validation + /// + /// 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) + /// Custom certificate validation callback (overrides config-based validation when provided) + public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { Certificate = certificate; SuppressValidation = suppressValidation; RequireMutualAuthentication = requireMutualAuthentication; ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; } /// @@ -77,5 +104,13 @@ public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, b /// public bool ValidateCertificateHostname { get; } - internal SslSettings Settings => new SslSettings(Certificate, SuppressValidation, RequireMutualAuthentication, ValidateCertificateHostname); + /// + /// Custom certificate validation callback for advanced validation scenarios. + /// When provided, this callback takes precedence over config-based validation. + /// Use with CertificateValidation helper factory to combine multiple validation strategies. + /// Example: CertificateValidation.Combine(ValidateChain(log), PinnedCertificate(thumbprints)) + /// + public CertificateValidationCallback? CustomValidator { get; } + + internal SslSettings Settings => new SslSettings(Certificate, SuppressValidation, RequireMutualAuthentication, ValidateCertificateHostname, CustomValidator); } diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs index 10f859f88a6..582ea555c1f 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransport.cs @@ -5,6 +5,7 @@ // //----------------------------------------------------------------------- +#nullable enable using System; using System.Collections.Generic; using System.Linq; @@ -70,7 +71,7 @@ public override void ExceptionCaught(IChannelHandlerContext context, Exception e protected abstract void RegisterListener(IChannel channel, IHandleEventListener listener, object msg, IPEndPoint remoteAddress); protected void Init(IChannel channel, IPEndPoint remoteSocketAddress, Address remoteAddress, object msg, - out AssociationHandle op) + out AssociationHandle? op) { var localAddress = DotNettyTransport.MapSocketToAddress((IPEndPoint)channel.LocalAddress, Transport.SchemeIdentifier, Transport.System.Name, Transport.Settings.Hostname); @@ -100,7 +101,7 @@ internal class DotNettyTransportException : RemoteTransportException /// /// The message that describes the error. /// The exception that is the cause of the current exception. - public DotNettyTransportException(string message, Exception cause = null) : base(message, cause) + public DotNettyTransportException(string message, Exception? cause = null) : base(message, cause) { } @@ -120,8 +121,8 @@ internal abstract class DotNettyTransport : Transport protected readonly TaskCompletionSource AssociationListenerPromise; protected readonly ILoggingAdapter Log; - protected volatile Address LocalAddress; - protected internal volatile IChannel ServerChannel; + protected volatile Address? LocalAddress; + protected internal volatile IChannel? ServerChannel; private readonly IEventLoopGroup _serverEventLoopGroup; private readonly IEventLoopGroup _clientEventLoopGroup; @@ -240,8 +241,8 @@ protected async Task NewServer(EndPoint listenAddress) public override Task Associate(Address remoteAddress) { - if (!ServerChannel.Open) - throw new ChannelException("Transport is not open"); + if (ServerChannel == null || !ServerChannel.Open) + throw new ChannelException("Transport is not bound or not open"); return AssociateInternal(remoteAddress); } @@ -357,22 +358,25 @@ private void SetClientPipeline(IChannel channel, Address remoteAddress) IChannelHandler tlsHandler; - // 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; + // Compose validator: either use custom validator or build from config settings + // This ensures a single execution path through validation logic + var validator = Settings.Ssl.CustomValidator ?? ComposeValidatorFromSettings(); - var hostnameValidation = Settings.Ssl.ValidateCertificateHostname - ? HostnameValidationMode.ValidateHostname - : HostnameValidationMode.IgnoreHostnameMismatch; - - var validationCallback = TlsValidationCallbacks.Create(chainValidation, hostnameValidation, Log); + // Create adapter bridge from our CertificateValidationCallback to RemoteCertificateValidationCallback + // The adapter extracts remote peer information from the remote address + RemoteCertificateValidationCallback validationCallback = (sender, cert, chain, errors) => + { + // Convert X509Certificate to X509Certificate2 if needed + var x509Cert = cert as X509Certificate2 ?? (cert != null ? new X509Certificate2(cert) : null); + return validator(x509Cert, chain, remoteAddress.ToString(), errors, Log); + }; if (Settings.Ssl.RequireMutualAuthentication) { + // Mutual TLS requires a certificate to be configured + if (certificate == null) + throw new InvalidOperationException("Mutual TLS authentication is enabled but no certificate is configured. Please provide a certificate via DotNettySslSetup or HOCON configuration."); + // Provide client cert for mutual TLS tlsHandler = new TlsHandler( stream => new SslStream(stream, true, validationCallback, @@ -409,40 +413,34 @@ private void SetServerPipeline(IChannel channel) if (Settings.Ssl.RequireMutualAuthentication) { // Mutual TLS: Require client certificate authentication + // Compose validator: either use custom validator or build from config settings + // This ensures a single execution path through validation logic + var validator = Settings.Ssl.CustomValidator ?? ComposeValidatorFromSettings(); + + // Create adapter bridge from our CertificateValidationCallback to RemoteCertificateValidationCallback + // For server-side, extract the remote peer (client address) from the channel + RemoteCertificateValidationCallback validationCallback = (sender, certificate, chain, errors) => + { + // When mutual TLS is required, reject if no client certificate was provided + if (certificate == null) + { + Log.Warning("Mutual TLS required but client did not provide a certificate from {0}", + channel.RemoteAddress?.ToString() ?? "unknown"); + return false; + } + + // Extract client address from channel + var remoteAddress = channel.RemoteAddress?.ToString() ?? "unknown"; + // Convert X509Certificate to X509Certificate2 if needed + var x509Cert = certificate as X509Certificate2 ?? new X509Certificate2(certificate); + return validator(x509Cert, chain, remoteAddress, errors, Log); + }; + tlsHandler = new TlsHandler( stream => new SslStream( stream, leaveInnerStreamOpen: true, - userCertificateValidationCallback: (sender, certificate, chain, errors) => - { - if (certificate == null) - { - Log.Error("Mutual TLS authentication failed: Client did not provide a certificate.\n" + - "Server requires mutual TLS (require-mutual-authentication = true).\n" + - "Suggestions:\n" + - " - Ensure client has mutual TLS enabled (require-mutual-authentication = true)\n" + - " - Verify client certificate is properly configured and accessible\n" + - " - Check client-side logs for certificate loading errors"); - return false; - } - - if (Settings.Ssl.SuppressValidation) - { - // In test/dev mode, accept any client certificate - return true; - } - - if (errors != SslPolicyErrors.None) - { - // Build detailed error message with certificate details and suggestions - var cert = certificate as X509Certificate2; - var detailedError = TlsErrorMessageBuilder.BuildSslPolicyErrorMessage(errors, cert, chain); - Log.Error("Mutual TLS authentication failed: Client certificate validation error.\n{0}", detailedError); - return false; - } - - return true; - }), + userCertificateValidationCallback: validationCallback), new ServerTlsSettings(Settings.Ssl.Certificate, negotiateClientCertificate: true)); } else @@ -464,6 +462,29 @@ private void SetServerPipeline(IChannel channel) } } + /// + /// Composes a certificate validation callback from the current SSL settings. + /// This creates a validator that respects SuppressValidation + /// and ValidateCertificateHostname configuration options. + /// + /// A CertificateValidationCallback composed from configuration settings. + private CertificateValidationCallback ComposeValidatorFromSettings() + { + // Build validator from configuration settings + // Note: SuppressValidation and ValidateCertificateHostname are independent settings + var suppressChain = Settings.Ssl.SuppressValidation; + var validateHostname = Settings.Ssl.ValidateCertificateHostname; + + return suppressChain switch + { + true when validateHostname => CertificateValidation.ValidateHostname(log: Log), + true => (cert, chain, peer, errors, log) => true, + false when validateHostname => CertificateValidation.Combine( + CertificateValidation.ValidateChain(log: Log), CertificateValidation.ValidateHostname(log: Log)), + _ => CertificateValidation.ValidateChain(log: Log) + }; + } + private ServerBootstrap ServerFactory() { if (InternalTransport != TransportMode.Tcp) @@ -516,14 +537,14 @@ private async Task ResolveNameAsync(DnsEndPoint address, AddressFami #region static methods - public static Address MapSocketToAddress(IPEndPoint socketAddress, string schemeIdentifier, string systemName, string hostName = null, int? publicPort = null) + public static Address? MapSocketToAddress(IPEndPoint socketAddress, string schemeIdentifier, string systemName, string? hostName = null, int? publicPort = null) { return socketAddress == null ? null : new Address(schemeIdentifier, systemName, SafeMapHostName(hostName) ?? SafeMapIPv6(socketAddress.Address), publicPort ?? socketAddress.Port); } - private static string SafeMapHostName(string hostName) + private static string? SafeMapHostName(string? hostName) { return !string.IsNullOrEmpty(hostName) && IPAddress.TryParse(hostName, out var ip) ? SafeMapIPv6(ip) : hostName; } diff --git a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs index 7d98206167b..12dde7ce233 100644 --- a/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs +++ b/src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs @@ -5,7 +5,9 @@ // //----------------------------------------------------------------------- +#nullable enable using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Security; @@ -143,9 +145,26 @@ public static DotNettyTransportSettings Create(ActorSystem system) var config = system.Settings.Config.GetConfig("akka.remote.dot-netty.tcp"); if (config.IsNullOrEmpty()) throw ConfigurationException.NullOrEmptyConfig("akka.remote.dot-netty.tcp"); - + var setup = system.Settings.Setup.Get(); var sslSettings = setup.HasValue ? setup.Value.Settings : null; + + // Warn if both DotNettySslSetup and HOCON SSL are configured (DotNettySslSetup takes precedence) + if (sslSettings != null && config.GetBoolean("enable-ssl")) + { + var sslConfig = config.GetConfig("ssl"); + // Only warn if HOCON has explicit certificate configuration + var hasCertPath = sslConfig.HasPath("certificate.path") && !string.IsNullOrWhiteSpace(sslConfig.GetString("certificate.path")); + var hasCertThumbprint = sslConfig.HasPath("certificate.thumbprint") && !string.IsNullOrWhiteSpace(sslConfig.GetString("certificate.thumbprint")); + + if (hasCertPath || hasCertThumbprint) + { + var log = Logging.GetLogger(system, typeof(DotNettyTransportSettings)); + log.Warning("Both DotNettySslSetup and HOCON SSL configuration are present. " + + "DotNettySslSetup takes precedence and HOCON SSL settings will be ignored."); + } + } + return Create(config, sslSettings); } @@ -354,19 +373,25 @@ private static X509KeyStorageFlags ParseKeyStorageFlag(string str) /// public readonly bool ValidateCertificateHostname; + /// + /// Custom certificate validation callback (overrides config-based validation when provided) + /// + public readonly CertificateValidationCallback? CustomValidator; + private SslSettings() { Certificate = null; SuppressValidation = false; RequireMutualAuthentication = false; ValidateCertificateHostname = false; + CustomValidator = null; } /// /// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true, ValidateCertificateHostname = false /// public SslSettings(X509Certificate2 certificate, bool suppressValidation) - : this(certificate, suppressValidation, requireMutualAuthentication: true, validateCertificateHostname: false) + : this(certificate, suppressValidation, requireMutualAuthentication: true, validateCertificateHostname: false, customValidator: null) { } @@ -374,16 +399,22 @@ public SslSettings(X509Certificate2 certificate, bool suppressValidation) /// Constructor for backward compatibility - defaults to ValidateCertificateHostname = false /// public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) - : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname: false, customValidator: null) { } public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) + : this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { + } + + public SslSettings(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { Certificate = certificate; SuppressValidation = suppressValidation; RequireMutualAuthentication = requireMutualAuthentication; ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; } /// @@ -433,6 +464,11 @@ public void ValidateCertificate() } private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) + : this(certificateThumbprint, storeName, storeLocation, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { + } + + private SslSettings(string certificateThumbprint, string storeName, StoreLocation storeLocation, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { using var store = new X509Store(storeName, storeLocation); store.Open(OpenFlags.ReadOnly); @@ -448,9 +484,15 @@ private SslSettings(string certificateThumbprint, string storeName, StoreLocatio SuppressValidation = suppressValidation; RequireMutualAuthentication = requireMutualAuthentication; ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; } private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) + : this(certificatePath, certificatePassword, flags, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null) + { + } + + private SslSettings(string certificatePath, string certificatePassword, X509KeyStorageFlags flags, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator) { 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`)"); @@ -459,133 +501,297 @@ private SslSettings(string certificatePath, string certificatePassword, X509KeyS SuppressValidation = suppressValidation; RequireMutualAuthentication = requireMutualAuthentication; ValidateCertificateHostname = validateCertificateHostname; + CustomValidator = customValidator; } } /// - /// INTERNAL API + /// PUBLIC API /// - /// Specifies how certificate chain validation should be performed during TLS handshake. - /// Controls whether to validate certificates against the system CA trust store. + /// Custom certificate validation callback for mTLS connections. + /// Invoked during TLS handshake on both client and server sides. /// - 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 - } + /// The peer certificate to validate + /// The X509 chain for validation + /// The remote address/peer identifier + /// SSL policy errors from standard validation + /// Logger for diagnostics + /// True to accept cert, false to reject + public delegate bool CertificateValidationCallback( + X509Certificate2? certificate, + X509Chain? chain, + string remotePeer, + SslPolicyErrors errors, + ILoggingAdapter log); /// - /// INTERNAL API + /// PUBLIC API /// - /// Specifies how hostname validation should be performed during TLS handshake. - /// Controls whether the certificate CN/SAN must match the connection target hostname. + /// Factory methods for common certificate validation scenarios. + /// Helpers return delegates that can be composed or used standalone. + /// Each helper creates a CertificateValidationCallback that can be passed to DotNettySslSetup. /// - internal enum HostnameValidationMode + public static class CertificateValidation { /// - /// 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. + /// Validate certificate chain against system CA store. + /// Use for: CA-signed certificates in production. /// - ValidateHostname, + public static CertificateValidationCallback ValidateChain( + ILoggingAdapter? log = null) + { + return (cert, chain, peer, errors, noClosureLog) => + { + if (cert == null) + { + (log ?? noClosureLog).Error("Certificate chain validation failed for {0}: certificate is null", peer); + return false; + } + + var filteredErrors = errors & ~SslPolicyErrors.RemoteCertificateNameMismatch; + if (filteredErrors == SslPolicyErrors.None) + return true; + + var detailedError = TlsErrorMessageBuilder.BuildSslPolicyErrorMessage( + filteredErrors, cert, chain); + (log ?? noClosureLog).Error("Certificate chain validation failed for {0}:\n{1}", peer, detailedError); + return false; + }; + } /// - /// 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). + /// Validate certificate hostname (CN/SAN) matches expected hostname. + /// Use for: Per-node certificates, FQDN-based identity. + /// Applies bidirectionally on both client and server. /// - IgnoreHostnameMismatch - } + public static CertificateValidationCallback ValidateHostname( + string? expectedHostname = null, + ILoggingAdapter? log = null) + { + return (cert, chain, peer, errors, nonClosureLog) => + { + if (cert == null) + { + (log ?? nonClosureLog).Error( + "Hostname validation failed for {0}: certificate is null", + peer); + return false; + } + + var hostname = expectedHostname ?? peer; + + if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) == 0) return true; + var cn = cert.GetNameInfo(X509NameType.DnsName, false); + (log ?? nonClosureLog).Error( + "Hostname validation failed for {0}: expected '{1}', certificate CN is '{2}'", + peer, hostname, cn); + return false; + + }; + } - /// - /// 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. + /// Pin certificate by thumbprint. Only accept certs matching allowed list. + /// Use for: High-security scenarios, known peer certificates. + /// Best combined with: Certificate revocation checking. /// - /// 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) + public static CertificateValidationCallback PinnedCertificate( + params string[] allowedThumbprints) { - return (sender, cert, chain, errors) => + if (allowedThumbprints == null || allowedThumbprints.Length == 0) + throw new ArgumentException("At least one thumbprint required"); + + // Normalize thumbprints to uppercase for case-insensitive comparison. + // This is SAFE because thumbprints are hexadecimal representations of SHA hashes. + // "2A8B4C" and "2a8b4c" represent the same binary value - just different display conventions. + // Different tools display thumbprints differently (Windows=uppercase, OpenSSL=lowercase), + // so case-insensitive comparison improves usability without compromising security. + // Also filter out any null/empty thumbprints to prevent security issues. + var normalizedThumbprints = new HashSet( + allowedThumbprints + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.ToUpperInvariant())); + + if (normalizedThumbprints.Count == 0) + throw new ArgumentException("At least one valid (non-empty) thumbprint required"); + + return (cert, chain, peer, errors, log) => { - var filteredErrors = errors; - - // Apply chain validation filter - if (chainValidation == ChainValidationMode.IgnoreChainErrors) + if (cert == null) { - filteredErrors &= ~SslPolicyErrors.RemoteCertificateChainErrors; - filteredErrors &= ~SslPolicyErrors.RemoteCertificateNotAvailable; + log.Error("Certificate pinning failed for {0}: certificate is null", peer); + return false; } - // Apply hostname validation filter - if (hostnameValidation == HostnameValidationMode.IgnoreHostnameMismatch) + var thumbprint = cert.Thumbprint?.ToUpperInvariant(); + + if (string.IsNullOrEmpty(thumbprint)) { - filteredErrors &= ~SslPolicyErrors.RemoteCertificateNameMismatch; + log.Error("Certificate pinning failed for {0}: certificate has no thumbprint", peer); + return false; } - if (filteredErrors == SslPolicyErrors.None) - return true; // Certificate is valid after applying configured filters + if (!normalizedThumbprints.Contains(thumbprint!)) + { + log.Error("Certificate pinning failed for {0}: thumbprint '{1}' not in allowed list", + peer, thumbprint); + return false; + } - // 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; + return true; }; } /// - /// Creates validation callback for full TLS validation (chain + hostname). - /// Use for traditional client-server TLS with CA-signed certificates and DNS names. + /// Validate certificate subject DN matches expected pattern. + /// Use for: Organizational CA, issuer-based identity verification. + /// Supports wildcards: "CN=Akka-Node-*" matches "CN=Akka-Node-001" /// - public static RemoteCertificateValidationCallback ValidateFull(ILoggingAdapter log) - => Create(ChainValidationMode.ValidateChain, HostnameValidationMode.ValidateHostname, log); + public static CertificateValidationCallback ValidateSubject( + string expectedSubjectPattern, + ILoggingAdapter? log = null) + { + if (string.IsNullOrWhiteSpace(expectedSubjectPattern)) + throw new ArgumentException("Subject pattern required"); + + return (cert, chain, peer, errors, log_) => + { + if (cert == null) + { + (log ?? log_).Error( + "Subject validation failed for {0}: certificate is null", + peer); + return false; + } + + var cert509 = cert as X509Certificate2; + var subject = cert509?.Subject; + + if (string.IsNullOrEmpty(subject)) + { + (log ?? log_).Error( + "Subject validation failed for {0}: certificate has no subject", + peer); + return false; + } + + if (!SubjectMatchesPattern(subject, expectedSubjectPattern)) + { + (log ?? log_).Error( + "Subject validation failed for {0}: '{1}' does not match pattern '{2}'", + peer, subject, expectedSubjectPattern); + return false; + } + + return true; + }; + } /// - /// Creates validation callback that validates chain but ignores hostname mismatches. - /// Use for: Mutual TLS with per-node certificates, IP-based connections, dynamic service discovery. + /// Validate certificate issuer matches expected DN pattern. + /// Use for: Verifying certificate came from trusted CA. /// - public static RemoteCertificateValidationCallback ValidateChainOnly(ILoggingAdapter log) - => Create(ChainValidationMode.ValidateChain, HostnameValidationMode.IgnoreHostnameMismatch, log); + public static CertificateValidationCallback ValidateIssuer( + string expectedIssuerPattern, + ILoggingAdapter? log = null) + { + if (string.IsNullOrWhiteSpace(expectedIssuerPattern)) + throw new ArgumentException("Issuer pattern required"); + + return (cert, chain, peer, errors, log_) => + { + if (cert == null) + { + (log ?? log_).Error( + "Issuer validation failed for {0}: certificate is null", + peer); + return false; + } + + var cert509 = cert as X509Certificate2; + var issuer = cert509?.Issuer; + + if (string.IsNullOrEmpty(issuer)) + { + (log ?? log_).Error( + "Issuer validation failed for {0}: certificate has no issuer", + peer); + return false; + } + + if (!SubjectMatchesPattern(issuer, expectedIssuerPattern)) + { + (log ?? log_).Error( + "Issuer validation failed for {0}: '{1}' does not match pattern '{2}'", + peer, issuer, expectedIssuerPattern); + return false; + } + + return true; + }; + } /// - /// Creates validation callback that ignores chain errors but validates hostname. - /// Use for: Testing with self-signed certificates where hostname should still match. + /// Compose multiple validation callbacks into a single callback. + /// All validators must pass for certificate to be accepted. + /// Use for: Combining multiple validation strategies. /// - public static RemoteCertificateValidationCallback ValidateHostnameOnly(ILoggingAdapter log) - => Create(ChainValidationMode.IgnoreChainErrors, HostnameValidationMode.ValidateHostname, log); + public static CertificateValidationCallback Combine( + params CertificateValidationCallback[] validators) + { + if (validators == null || validators.Length == 0) + throw new ArgumentException("At least one validator required"); + + return (cert, chain, peer, errors, log) => + { + foreach (var validator in validators!) + { + if (!validator(cert, chain, peer, errors, log)) + return false; + } + return true; + }; + } /// - /// Creates validation callback that accepts all certificates without validation. - /// FOR TESTING ONLY. WARNING: Disables all security checks including chain, hostname, and expiration. + /// Chain validator with optional custom validation. + /// Validates certificate chain, then calls optional custom logic. /// - public static RemoteCertificateValidationCallback AcceptAll() - => (_, _, _, _) => true; + public static CertificateValidationCallback ChainPlusThen( + Func customCheck, + ILoggingAdapter? log = null) + { + if (customCheck == null) + throw new ArgumentException("Custom check function required"); + + return (cert, chain, peer, errors, log_) => + { + // First validate chain + var chainValidator = ValidateChain(log ?? log_); + if (!chainValidator(cert, chain, peer, errors, log_)) + return false; + + // Then custom check + if (!customCheck(cert, chain, peer)) + { + (log ?? log_).Error("Custom certificate validation failed for {0}", peer); + return false; + } + + return true; + }; + } + + private static bool SubjectMatchesPattern(string? subject, string pattern) + { + // Simple wildcard matching: CN=Akka-Node-* matches CN=Akka-Node-001 + if (string.IsNullOrEmpty(subject)) + return false; + + var regex = "^" + System.Text.RegularExpressions.Regex.Escape(pattern) + .Replace("\\*", ".*") + "$"; + return System.Text.RegularExpressions.Regex.IsMatch(subject, regex); + } } /// @@ -612,7 +818,7 @@ public static string BuildSslPolicyErrorMessage( message.AppendLine("TLS/SSL certificate validation failed:"); // Interpret SslPolicyErrors flags - if ((errors & System.Net.Security.SslPolicyErrors.None) != System.Net.Security.SslPolicyErrors.None) + if (errors != System.Net.Security.SslPolicyErrors.None) { if ((errors & System.Net.Security.SslPolicyErrors.RemoteCertificateNotAvailable) != 0) {