diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs index 891b8b7e6f9..c394cdf20bd 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs @@ -325,5 +325,28 @@ internal void MtlsHttpClientCreationFailed(Exception ex) Level = EventLevel.Error)] internal void MtlsHttpClientCreationFailed(string exception) => this.WriteEvent(34, exception); + + [Event( + 35, + Message = "CA configured for server validation. Subject: '{0}'.", + Level = EventLevel.Informational)] + internal void CaCertificateConfigured(string subject) => + this.WriteEvent(35, subject); + + [NonEvent] + internal void SecureHttpClientCreationFailed(Exception ex) + { + if (Log.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.SecureHttpClientCreationFailed(ex.ToInvariantString()); + } + } + + [Event( + 36, + Message = "Failed to create secure HttpClient. Exception: {0}", + Level = EventLevel.Error)] + internal void SecureHttpClientCreationFailed(string exception) => + this.WriteEvent(36, exception); #endif } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCertificateManager.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCertificateManager.cs index 491d9e6a03e..24c29fe85c6 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCertificateManager.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCertificateManager.cs @@ -10,11 +10,15 @@ namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; /// -/// Manages certificate loading, validation, and security checks for mTLS connections. +/// Manages certificate loading, validation, and security checks for TLS connections. /// +/// +/// This class provides functionality for both simple server certificate trust +/// (for self-signed certificates) and mTLS client authentication scenarios. +/// internal static class OtlpCertificateManager { - internal const string CaCertificateType = "CA certificate"; + internal const string CaCertificateType = "CA Certificate"; internal const string ClientCertificateType = "Client certificate"; internal const string ClientPrivateKeyType = "Client private key"; @@ -218,6 +222,10 @@ public static bool ValidateCertificateChain( /// The SSL policy errors. /// The CA certificate to validate against. /// True if the certificate is valid; otherwise, false. + /// + /// This method is used to validate server certificates against a CA. + /// Common use case: connecting to a server with a self-signed certificate. + /// internal static bool ValidateServerCertificate( X509Certificate2 serverCert, X509Chain chain, @@ -244,6 +252,8 @@ internal static bool ValidateServerCertificate( X509VerificationFlags.AllowUnknownCertificateAuthority; chain.ChainPolicy.CustomTrustStore.Add(caCertificate); chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; + + // Skip CRL/OCSP checks for custom CA validation to avoid network-dependent failures. chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot; diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSecureHttpClientFactory.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSecureHttpClientFactory.cs index 1c2e2fb7cd6..ff9bde7812a 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSecureHttpClientFactory.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSecureHttpClientFactory.cs @@ -8,66 +8,63 @@ namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; /// -/// Factory for creating HttpClient instances configured with mTLS settings. +/// Factory for creating HttpClient instances configured with TLS settings. /// internal static class OtlpSecureHttpClientFactory { /// - /// Creates an HttpClient configured with mTLS settings. + /// Creates an HttpClient configured with TLS settings based on the provided options. /// - /// The mTLS configuration options. + /// The TLS configuration options. /// Optional action to configure the client. - /// An HttpClient configured for mTLS. - /// Thrown when is null. - /// Thrown when mTLS is not enabled. + /// An HttpClient configured for secure communication. + /// Thrown when is null. + /// Thrown when TLS is not enabled. public static HttpClient CreateSecureHttpClient( - OtlpMtlsOptions mtlsOptions, + OtlpTlsOptions tlsOptions, Action? configureClient = null) { - ArgumentNullException.ThrowIfNull(mtlsOptions); + ArgumentNullException.ThrowIfNull(tlsOptions); - if (!mtlsOptions.IsEnabled) + if (!tlsOptions.IsTlsEnabled && !tlsOptions.IsMtlsEnabled) { - throw new InvalidOperationException("mTLS options must include a client or CA certificate path."); + throw new InvalidOperationException( + "TLS options must include at least a CA path or client certificate path."); } - HttpClientHandler? handler = null; X509Certificate2? caCertificate = null; + byte[]? caCertificateData = null; X509Certificate2? clientCertificate = null; + TlsHttpClientHandler? handler = null; try { - // Load certificates - if (!string.IsNullOrEmpty(mtlsOptions.CaCertificatePath)) + if (!string.IsNullOrEmpty(tlsOptions.CaCertificatePath)) { caCertificate = OtlpCertificateManager.LoadCaCertificate( - mtlsOptions.CaCertificatePath); + tlsOptions.CaCertificatePath); - if (mtlsOptions.EnableCertificateChainValidation) + if (tlsOptions.EnableCertificateChainValidation) { OtlpCertificateManager.ValidateCertificateChain( caCertificate, OtlpCertificateManager.CaCertificateType); } + + caCertificateData = caCertificate.RawData; } - if (!string.IsNullOrEmpty(mtlsOptions.ClientCertificatePath)) + if (tlsOptions is OtlpMtlsOptions mtlsOptions && mtlsOptions.IsMtlsEnabled) { - if (string.IsNullOrEmpty(mtlsOptions.ClientKeyPath)) - { - // Load certificate without separate key file (e.g., PKCS#12 format) - clientCertificate = OtlpCertificateManager.LoadClientCertificate( - mtlsOptions.ClientCertificatePath, - null); - } - else - { - clientCertificate = OtlpCertificateManager.LoadClientCertificate( - mtlsOptions.ClientCertificatePath, + clientCertificate = string.IsNullOrEmpty(mtlsOptions.ClientKeyPath) + ? OtlpCertificateManager.LoadClientCertificate( + mtlsOptions.ClientCertificatePath!, + null) + : OtlpCertificateManager.LoadClientCertificate( + mtlsOptions.ClientCertificatePath!, mtlsOptions.ClientKeyPath); - } - if (mtlsOptions.EnableCertificateChainValidation) + if (tlsOptions.EnableCertificateChainValidation) { OtlpCertificateManager.ValidateCertificateChain( clientCertificate, @@ -77,18 +74,27 @@ public static HttpClient CreateSecureHttpClient( OpenTelemetryProtocolExporterEventSource.Log.MtlsConfigurationEnabled( clientCertificate.Subject); } + else if (caCertificate != null) + { + OpenTelemetryProtocolExporterEventSource.Log.CaCertificateConfigured( + caCertificate.Subject); + } - // Create HttpClientHandler with mTLS configuration + // Create HttpClientHandler and apply TLS configuration #pragma warning disable CA2000 // Dispose objects before losing scope - HttpClientHandler is disposed by HttpClient - handler = new MtlsHttpClientHandler(clientCertificate, caCertificate); + handler = new TlsHttpClientHandler(caCertificateData, clientCertificate); #pragma warning restore CA2000 - handler.CheckCertificateRevocationList = true; - // Handler now owns the certificates and will dispose them when disposed. + // Handler copies CA cert data; release the original handle now. + caCertificate?.Dispose(); caCertificate = null; + + // Client certificate lifetime is tied to the handler. clientCertificate = null; +#pragma warning disable CA5399 // CheckCertificateRevocationList is set in ConfigureTls. var client = new HttpClient(handler, disposeHandler: true); +#pragma warning restore CA5399 configureClient?.Invoke(client); @@ -96,70 +102,112 @@ public static HttpClient CreateSecureHttpClient( } catch (Exception ex) { - // Dispose handler if something went wrong handler?.Dispose(); - - OpenTelemetryProtocolExporterEventSource.Log.MtlsHttpClientCreationFailed(ex); + OpenTelemetryProtocolExporterEventSource.Log.SecureHttpClientCreationFailed(ex); throw; } finally { - // Dispose certificates as they are no longer needed after being added to the handler + // Cleanup if ownership was not transferred to the handler. caCertificate?.Dispose(); clientCertificate?.Dispose(); } } - private sealed class MtlsHttpClientHandler : HttpClientHandler + /// + /// Creates an HttpClient configured with mTLS settings. + /// + /// The mTLS configuration options. + /// Optional action to configure the client. + /// An HttpClient configured for mTLS. + /// + /// This method exists for backward compatibility. New code should use + /// . + /// + public static HttpClient CreateMtlsHttpClient( + OtlpMtlsOptions mtlsOptions, + Action? configureClient = null) { - private readonly X509Certificate2? caCertificate; + return CreateSecureHttpClient(mtlsOptions, configureClient); + } + + /// + /// HttpClientHandler that applies TLS configuration based on loaded certificates. + /// + private sealed class TlsHttpClientHandler : HttpClientHandler + { + private readonly byte[]? caCertificateData; private readonly X509Certificate2? clientCertificate; - internal MtlsHttpClientHandler( - X509Certificate2? clientCertificate, - X509Certificate2? caCertificate) + internal TlsHttpClientHandler( + byte[]? caCertificateData, + X509Certificate2? clientCertificate) { + this.caCertificateData = caCertificateData; this.clientCertificate = clientCertificate; - this.caCertificate = caCertificate; - this.CheckCertificateRevocationList = true; - if (clientCertificate != null) + this.ConfigureTls(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) { - this.ClientCertificates.Add(clientCertificate); - this.ClientCertificateOptions = ClientCertificateOption.Manual; + this.clientCertificate?.Dispose(); } + } - if (caCertificate != null) + private void ConfigureTls() + { + this.CheckCertificateRevocationList = true; + + this.ConfigureClientCertificate(); + this.ConfigureCaCertificateValidation(); + } + + private void ConfigureClientCertificate() + { + if (this.clientCertificate == null) { - this.ServerCertificateCustomValidationCallback = ( - httpRequestMessage, - cert, - chain, - sslPolicyErrors) => - { - if (cert == null || chain == null) - { - return false; - } - - return OtlpCertificateManager.ValidateServerCertificate( - cert, - chain, - sslPolicyErrors, - caCertificate); - }; + return; } + + this.ClientCertificates.Add(this.clientCertificate); + this.ClientCertificateOptions = ClientCertificateOption.Manual; } - protected override void Dispose(bool disposing) + private void ConfigureCaCertificateValidation() { - if (disposing) + if (this.caCertificateData == null) { - this.caCertificate?.Dispose(); - this.clientCertificate?.Dispose(); + return; } - base.Dispose(disposing); + var caCertData = this.caCertificateData; + this.ServerCertificateCustomValidationCallback = ( + httpRequestMessage, + cert, + chain, + sslPolicyErrors) => + { + if (cert == null || chain == null) + { + return false; + } + +#if NET9_0_OR_GREATER + using var caCert = X509CertificateLoader.LoadCertificate(caCertData); +#else + using var caCert = new X509Certificate2(caCertData); +#endif + return OtlpCertificateManager.ValidateServerCertificate( + cert, + chain, + sslPolicyErrors, + caCert); + }; } } } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index 29eb4b61997..cf59c5d3360 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -75,7 +75,7 @@ internal OtlpExporterOptions( var timeout = TimeSpan.FromMilliseconds(this.TimeoutMilliseconds); #if NET - // If mTLS is configured, create an mTLS-enabled client + // If TLS configuration is enabled (mTLS or CA only), create a secure client if (this.MtlsOptions?.IsEnabled == true) { return OtlpSecureHttpClientFactory.CreateSecureHttpClient( diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs index ebb1f827c77..08b922e21ba 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs @@ -5,36 +5,50 @@ namespace OpenTelemetry.Exporter; -internal sealed class OtlpMtlsOptions +/// +/// Represents mTLS (mutual TLS) configuration options for OTLP exporter. +/// Extends with client certificate authentication. +/// +/// +/// mTLS is an authentication system in which both the client and server authenticate each other. +/// This class provides client certificate configuration for scenarios requiring mutual authentication. +/// For simple server certificate trust (e.g., self-signed certificates), use directly. +/// +internal sealed class OtlpMtlsOptions : OtlpTlsOptions { - /// - /// Gets or sets the path to the CA certificate file in PEM format. - /// - public string? CaCertificatePath { get; set; } - /// /// Gets or sets the path to the client certificate file in PEM format. /// + /// + /// Corresponds to the OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE environment variable. + /// This is used for client authentication in mTLS scenarios. + /// public string? ClientCertificatePath { get; set; } /// /// Gets or sets the path to the client private key file in PEM format. /// + /// + /// Corresponds to the OTEL_EXPORTER_OTLP_CLIENT_KEY environment variable. + /// Required when the client certificate file does not include the private key. + /// public string? ClientKeyPath { get; set; } /// - /// Gets or sets a value indicating whether to enable certificate chain validation. - /// When enabled, the exporter will validate the certificate chain and reject invalid certificates. + /// Gets a value indicating whether mTLS (mutual TLS) is enabled. /// - public bool EnableCertificateChainValidation { get; set; } = true; + /// + /// Returns true when client certificate is configured for mutual authentication. + /// Note: Having only does not constitute mTLS. + /// + public override bool IsMtlsEnabled => + !string.IsNullOrWhiteSpace(this.ClientCertificatePath); /// - /// Gets a value indicating whether mTLS is enabled. - /// mTLS is considered enabled if at least the client certificate path or CA certificate path is provided. + /// Gets a value indicating whether any TLS configuration is enabled. + /// TLS is considered enabled if at least the client certificate path or CA path is provided. /// - public bool IsEnabled => - !string.IsNullOrWhiteSpace(this.ClientCertificatePath) - || !string.IsNullOrWhiteSpace(this.CaCertificatePath); + public bool IsEnabled => this.IsTlsEnabled || this.IsMtlsEnabled; } #endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTlsOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTlsOptions.cs new file mode 100644 index 00000000000..1bfbf76f6ab --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpTlsOptions.cs @@ -0,0 +1,50 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +namespace OpenTelemetry.Exporter; + +/// +/// Represents TLS configuration options for OTLP exporter. +/// This class handles server certificate trust for scenarios such as self-signed certificates. +/// +/// +/// The option enables trust of server certificates +/// that are not verified by a third-party certificate authority. This is commonly used +/// when connecting to servers with self-signed certificates. +/// +internal class OtlpTlsOptions +{ + /// + /// Gets or sets the path to the CA file in PEM format. + /// + /// + /// This corresponds to the OTEL_EXPORTER_OTLP_CERTIFICATE environment variable. + /// Use this when the server has a self-signed certificate or uses a private CA. + /// + public string? CaCertificatePath { get; set; } + + /// + /// Gets or sets a value indicating whether to enable certificate chain validation. + /// When enabled, the exporter will validate the certificate chain and reject invalid certificates. + /// + public bool EnableCertificateChainValidation { get; set; } = true; + + /// + /// Gets a value indicating whether TLS certificate trust is configured. + /// + public virtual bool IsTlsEnabled => + !string.IsNullOrWhiteSpace(this.CaCertificatePath); + + /// + /// Gets a value indicating whether mTLS (mutual TLS) is configured. + /// + /// + /// Returns true only when client certificates are configured for mutual authentication. + /// Server certificate trust alone (CaCertificatePath) does not constitute mTLS. + /// + public virtual bool IsMtlsEnabled => false; +} + +#endif diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSecureHttpClientFactoryTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSecureHttpClientFactoryTests.cs index 1a7155caa03..8c41c706800 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSecureHttpClientFactoryTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSecureHttpClientFactoryTests.cs @@ -72,16 +72,16 @@ public void CreateHttpClient_ConfiguresClientCertificate_WhenValidCertificatePro } [Fact] - public void CreateHttpClient_ConfiguresServerCertificateValidation_WhenTrustedRootCertificatesProvided() + public void CreateHttpClient_ConfiguresServerCertificateValidation_WhenCaCertificatesProvided() { - RunWithCryptoSupportCheck(() => + SkipTestIfCryptoNotSupported(() => { var tempTrustStoreFile = Path.GetTempFileName(); try { - // Create a self-signed certificate for testing as trusted root - using var trustedCert = CreateSelfSignedCertificate(); - File.WriteAllText(tempTrustStoreFile, ExportCertificateWithPrivateKey(trustedCert)); + // Create a self-signed certificate for testing as CA root + using var caCert = CreateSelfSignedCertificate(); + File.WriteAllText(tempTrustStoreFile, ExportCertificateWithPrivateKey(caCert)); var options = new OtlpMtlsOptions { @@ -115,7 +115,7 @@ public void CreateHttpClient_ConfiguresServerCertificateValidation_WhenTrustedRo [Fact] public void CreateHttpClient_ConfiguresServerValidation_WithCaOnly() { - RunWithCryptoSupportCheck(() => + SkipTestIfCryptoNotSupported(() => { var tempTrustStoreFile = Path.GetTempFileName(); @@ -157,7 +157,7 @@ public void CreateHttpClient_ConfiguresServerValidation_WithCaOnly() [Fact] public void CreateHttpClient_InvokesServerValidationCallbackAfterFactoryReturns() { - RunWithCryptoSupportCheck(() => + SkipTestIfCryptoNotSupported(() => { var tempTrustStoreFile = Path.GetTempFileName(); try @@ -221,6 +221,23 @@ public void ValidateServerCertificate_ReturnsTrue_WhenNoSslPolicyErrors() Assert.True(result); } + [Fact] + public void ValidateServerCertificate_ReturnsFalse_WhenNameMismatch() + { + using var caCertificate = CreateCertificateAuthority(); + using var serverCertificate = CreateServerCertificate(caCertificate); + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateServerCertificate( + serverCertificate, + chain, + SslPolicyErrors.RemoteCertificateNameMismatch, + caCertificate); + + Assert.False(result); + } + [Fact] public void ValidateServerCertificate_ReturnsTrue_WithProvidedCa() { @@ -293,7 +310,7 @@ public void CreateSecureHttpClient_ThrowsArgumentNullException_WhenOptionsIsNull var exception = Assert.Throws(() => OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(null!)); - Assert.Equal("mtlsOptions", exception.ParamName); + Assert.Equal("tlsOptions", exception.ParamName); } private static X509Certificate2 CreateSelfSignedCertificate() @@ -422,7 +439,23 @@ private static string ExportCertificateWithPrivateKey(X509Certificate2 certifica return builder.ToString(); } - private static void RunWithCryptoSupportCheck(Action testBody) + /// + /// Executes a test action and gracefully handles platforms where cryptographic operations are not supported. + /// + /// + /// + /// Some platforms (e.g., certain CI environments or restricted OS configurations) may not support + /// specific cryptographic operations required for TLS/mTLS certificate handling. This method wraps + /// test execution to catch and + /// (when indicating lack of support), allowing tests to pass gracefully on unsupported platforms. + /// + /// + /// Note: xUnit 2.x does not support runtime test skipping. The test will appear as "passed" rather than + /// "skipped" when crypto is not supported. Consider upgrading to xUnit v3 for proper Assert.Skip() support. + /// + /// + /// The test action to execute. + private static void SkipTestIfCryptoNotSupported(Action testBody) { try { @@ -430,11 +463,15 @@ private static void RunWithCryptoSupportCheck(Action testBody) } catch (PlatformNotSupportedException ex) { - Console.WriteLine($"Skipping mTLS HttpClient tests: {ex.Message}"); + // Platform does not support the required cryptographic operations. + // Test is effectively skipped but will appear as passed in xUnit 2.x. + Console.WriteLine($"[SKIPPED] mTLS HttpClient test skipped due to platform limitation: {ex.Message}"); } catch (CryptographicException ex) when (ex.Message.Contains("not supported", StringComparison.OrdinalIgnoreCase)) { - Console.WriteLine($"Skipping mTLS HttpClient tests: {ex.Message}"); + // Cryptographic operation not supported on this platform/configuration. + // Test is effectively skipped but will appear as passed in xUnit 2.x. + Console.WriteLine($"[SKIPPED] mTLS HttpClient test skipped due to crypto limitation: {ex.Message}"); } } } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTlsOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTlsOptionsTests.cs new file mode 100644 index 00000000000..60d811d74f9 --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTlsOptionsTests.cs @@ -0,0 +1,314 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; + +/// +/// Tests for TLS options and secure HTTP client configuration. +/// +public class OtlpTlsOptionsTests +{ + [Fact] + public void OtlpTlsOptions_IsTlsEnabled_ReturnsFalse_WhenNoCaCertificatePath() + { + var options = new OtlpTlsOptions(); + Assert.False(options.IsTlsEnabled); + } + + [Fact] + public void OtlpTlsOptions_IsTlsEnabled_ReturnsTrue_WhenCaCertificatePathProvided() + { + var options = new OtlpTlsOptions { CaCertificatePath = "/path/to/ca.crt" }; + Assert.True(options.IsTlsEnabled); + } + + [Fact] + public void OtlpTlsOptions_IsMtlsEnabled_ReturnsFalse_ByDefault() + { + var options = new OtlpTlsOptions { CaCertificatePath = "/path/to/ca.crt" }; + Assert.False(options.IsMtlsEnabled); + } + + [Fact] + public void OtlpMtlsOptions_IsMtlsEnabled_ReturnsTrue_WhenClientCertificateProvided() + { + var options = new OtlpMtlsOptions { ClientCertificatePath = "/path/to/client.crt" }; + Assert.True(options.IsMtlsEnabled); + } + + [Fact] + public void OtlpMtlsOptions_IsMtlsEnabled_ReturnsFalse_WhenOnlyCaCertificateProvided() + { + // This is the key distinction: CA alone does NOT constitute mTLS + var options = new OtlpMtlsOptions { CaCertificatePath = "/path/to/ca.crt" }; + Assert.False(options.IsMtlsEnabled); + Assert.True(options.IsTlsEnabled); // But TLS is still enabled for server cert validation + } + + [Fact] + public void OtlpSecureHttpClientFactory_CreatesClient_WithCaCertificateOnly() + { + SkipTestIfCryptoNotSupported(() => + { + var tempCertFile = Path.GetTempFileName(); + try + { + using var cert = CreateSelfSignedCertificate(); + File.WriteAllText(tempCertFile, ExportCertificateWithPrivateKey(cert)); + + var options = new OtlpTlsOptions + { + CaCertificatePath = tempCertFile, + EnableCertificateChainValidation = false, + }; + + using var client = OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options); + + Assert.NotNull(client); + } + finally + { + if (File.Exists(tempCertFile)) + { + File.Delete(tempCertFile); + } + } + }); + } + + [Fact] + public void OtlpSecureHttpClientFactory_CreatesClient_WithMtlsClientCertificate() + { + SkipTestIfCryptoNotSupported(() => + { + var tempCertFile = Path.GetTempFileName(); + try + { + using var cert = CreateSelfSignedCertificate(); + var certBytes = cert.Export(X509ContentType.Pfx); + File.WriteAllBytes(tempCertFile, certBytes); + + var options = new OtlpMtlsOptions + { + ClientCertificatePath = tempCertFile, + EnableCertificateChainValidation = false, + }; + + using var client = OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options); + + Assert.NotNull(client); + } + finally + { + if (File.Exists(tempCertFile)) + { + File.Delete(tempCertFile); + } + } + }); + } + + [Fact] + public void OtlpSecureHttpClientFactory_ThrowsArgumentNullException_WhenOptionsIsNull() + { + Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(null!)); + } + + [Fact] + public void OtlpSecureHttpClientFactory_ThrowsInvalidOperationException_WhenTlsNotEnabled() + { + var options = new OtlpTlsOptions(); + Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options)); + } + + [Fact] + public void OtlpCertificateManager_LoadCaCertificate_ThrowsFileNotFoundException() + { + Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.LoadCaCertificate("/nonexistent/cert.pem")); + } + + [Fact] + public void OtlpCertificateManager_ValidateServerCertificate_ReturnsTrue_WhenNoSslPolicyErrors() + { + using var caCertificate = CreateCertificateAuthority(); + using var serverCertificate = CreateServerCertificate(caCertificate); + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateServerCertificate( + serverCertificate, + chain, + SslPolicyErrors.None, + caCertificate); + + Assert.True(result); + } + + [Fact] + public void OtlpCertificateManager_ValidateServerCertificate_ReturnsTrue_WithProvidedTrustedCert() + { + using var caCertificate = CreateCertificateAuthority(); + using var serverCertificate = CreateServerCertificate(caCertificate); + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateServerCertificate( + serverCertificate, + chain, + SslPolicyErrors.RemoteCertificateChainErrors, + caCertificate); + + Assert.True(result); + Assert.Equal(caCertificate.Thumbprint, chain.ChainElements[^1].Certificate.Thumbprint); + } + + private static X509Certificate2 CreateSelfSignedCertificate() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest( + "CN=Test Certificate", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30)); +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.Exportable); +#else +#pragma warning disable SYSLIB0057 + return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 +#endif + } + + private static X509Certificate2 CreateCertificateAuthority() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=Test CA", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, + true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddYears(1)); + +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.Exportable); +#else +#pragma warning disable SYSLIB0057 + return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 +#endif + } + + private static X509Certificate2 CreateServerCertificate(X509Certificate2 issuer) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=localhost", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + true)); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + request.CertificateExtensions.Add(sanBuilder.Build()); + + var serialNumber = new byte[16]; + RandomNumberGenerator.Fill(serialNumber); + + var cert = request.Create( + issuer, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30), + serialNumber); + +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.Exportable); +#else +#pragma warning disable SYSLIB0057 + return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string?)null, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 +#endif + } + + private static string ExportCertificateWithPrivateKey(X509Certificate2 certificate) + { + var builder = new StringBuilder(); + builder.AppendLine(certificate.ExportCertificatePem().Trim()); + + using RSA? privateKey = certificate.GetRSAPrivateKey(); + if (privateKey != null) + { + var pkcs8Bytes = privateKey.ExportPkcs8PrivateKey(); + var privateKeyPem = PemEncoding.Write("PRIVATE KEY", pkcs8Bytes); + builder.AppendLine(new string(privateKeyPem).Trim()); + } + + return builder.ToString(); + } + + /// + /// Executes a test action and gracefully handles platforms where cryptographic operations are not supported. + /// + /// + /// + /// Some platforms (e.g., certain CI environments or restricted OS configurations) may not support + /// specific cryptographic operations required for TLS/mTLS certificate handling. This method wraps + /// test execution to catch and + /// (when indicating lack of support), allowing tests to pass gracefully on unsupported platforms. + /// + /// + /// Note: xUnit 2.x does not support runtime test skipping. The test will appear as "passed" rather than + /// "skipped" when crypto is not supported. Consider upgrading to xUnit v3 for proper Assert.Skip() support. + /// + /// + /// The test action to execute. + private static void SkipTestIfCryptoNotSupported(Action testBody) + { + try + { + testBody(); + } + catch (PlatformNotSupportedException ex) + { + // Platform does not support the required cryptographic operations. + // Test is effectively skipped but will appear as passed in xUnit 2.x. + Console.WriteLine($"[SKIPPED] TLS test skipped due to platform limitation: {ex.Message}"); + } + catch (CryptographicException ex) when (ex.Message.Contains("not supported", StringComparison.OrdinalIgnoreCase)) + { + // Cryptographic operation not supported on this platform/configuration. + // Test is effectively skipped but will appear as passed in xUnit 2.x. + Console.WriteLine($"[SKIPPED] TLS test skipped due to crypto limitation: {ex.Message}"); + } + } +} + +#endif