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/OtlpMtlsCertificateManager.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCertificateManager.cs similarity index 95% rename from src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsCertificateManager.cs rename to src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpCertificateManager.cs index b394068e1c4..d445e4d309d 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsCertificateManager.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. /// -internal static class OtlpMtlsCertificateManager +/// +/// 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, diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsHttpClientFactory.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsHttpClientFactory.cs deleted file mode 100644 index fbf46ede410..00000000000 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsHttpClientFactory.cs +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -#if NET - -using System.Security.Cryptography.X509Certificates; - -namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; - -/// -/// Factory for creating HttpClient instances configured with mTLS settings. -/// -internal static class OtlpMtlsHttpClientFactory -{ - /// - /// Creates an HttpClient configured with mTLS settings. - /// - /// The mTLS configuration options. - /// Optional action to configure the client. - /// An HttpClient configured for mTLS. - /// Thrown when is null. - /// Thrown when mTLS is not enabled. - public static HttpClient CreateMtlsHttpClient( - OtlpMtlsOptions mtlsOptions, - Action? configureClient = null) - { - ArgumentNullException.ThrowIfNull(mtlsOptions); - - if (!mtlsOptions.IsEnabled) - { - throw new InvalidOperationException("mTLS options must include a client or CA certificate path."); - } - - HttpClientHandler? handler = null; - X509Certificate2? caCertificate = null; - X509Certificate2? clientCertificate = null; - - try - { - // Load certificates - if (!string.IsNullOrEmpty(mtlsOptions.CaCertificatePath)) - { - caCertificate = OtlpMtlsCertificateManager.LoadCaCertificate( - mtlsOptions.CaCertificatePath); - - if (mtlsOptions.EnableCertificateChainValidation) - { - OtlpMtlsCertificateManager.ValidateCertificateChain( - caCertificate, - OtlpMtlsCertificateManager.CaCertificateType); - } - } - - if (!string.IsNullOrEmpty(mtlsOptions.ClientCertificatePath)) - { - if (string.IsNullOrEmpty(mtlsOptions.ClientKeyPath)) - { - // Load certificate without separate key file (e.g., PKCS#12 format) - clientCertificate = OtlpMtlsCertificateManager.LoadClientCertificate( - mtlsOptions.ClientCertificatePath, - null); - } - else - { - clientCertificate = OtlpMtlsCertificateManager.LoadClientCertificate( - mtlsOptions.ClientCertificatePath, - mtlsOptions.ClientKeyPath); - } - - if (mtlsOptions.EnableCertificateChainValidation) - { - OtlpMtlsCertificateManager.ValidateCertificateChain( - clientCertificate, - OtlpMtlsCertificateManager.ClientCertificateType); - } - - OpenTelemetryProtocolExporterEventSource.Log.MtlsConfigurationEnabled( - clientCertificate.Subject); - } - - // Create HttpClientHandler with mTLS configuration -#pragma warning disable CA2000 // Dispose objects before losing scope - HttpClientHandler is disposed by HttpClient - handler = new MtlsHttpClientHandler(clientCertificate, caCertificate); -#pragma warning restore CA2000 - handler.CheckCertificateRevocationList = true; - - // Handler now owns the certificates and will dispose them when disposed. - caCertificate = null; - clientCertificate = null; - - var client = new HttpClient(handler, disposeHandler: true); - - configureClient?.Invoke(client); - - return client; - } - catch (Exception ex) - { - // Dispose handler if something went wrong - handler?.Dispose(); - - OpenTelemetryProtocolExporterEventSource.Log.MtlsHttpClientCreationFailed(ex); - throw; - } - finally - { - // Dispose certificates as they are no longer needed after being added to the handler - caCertificate?.Dispose(); - clientCertificate?.Dispose(); - } - } - - private sealed class MtlsHttpClientHandler : HttpClientHandler - { - private readonly X509Certificate2? caCertificate; - private readonly X509Certificate2? clientCertificate; - - internal MtlsHttpClientHandler( - X509Certificate2? clientCertificate, - X509Certificate2? caCertificate) - { - this.clientCertificate = clientCertificate; - this.caCertificate = caCertificate; - this.CheckCertificateRevocationList = true; - - if (clientCertificate != null) - { - this.ClientCertificates.Add(clientCertificate); - this.ClientCertificateOptions = ClientCertificateOption.Manual; - } - - if (caCertificate != null) - { - this.ServerCertificateCustomValidationCallback = ( - httpRequestMessage, - cert, - chain, - sslPolicyErrors) => - { - if (cert == null || chain == null) - { - return false; - } - - return OtlpMtlsCertificateManager.ValidateServerCertificate( - cert, - chain, - sslPolicyErrors, - caCertificate); - }; - } - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - this.caCertificate?.Dispose(); - this.clientCertificate?.Dispose(); - } - - base.Dispose(disposing); - } - } -} - -#endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSecureHttpClientFactory.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSecureHttpClientFactory.cs new file mode 100644 index 00000000000..143ef37633f --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSecureHttpClientFactory.cs @@ -0,0 +1,205 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using System.Security.Cryptography.X509Certificates; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; + +/// +/// Factory for creating HttpClient instances configured with TLS settings. +/// +internal static class OtlpSecureHttpClientFactory +{ + /// + /// Creates an HttpClient configured with TLS settings based on the provided options. + /// + /// The TLS configuration options. + /// Optional action to configure the client. + /// An HttpClient configured for secure communication. + /// Thrown when is null. + /// Thrown when TLS is not enabled. + public static HttpClient CreateSecureHttpClient( + OtlpTlsOptions tlsOptions, + Action? configureClient = null) + { + ArgumentNullException.ThrowIfNull(tlsOptions); + + if (!tlsOptions.IsTlsEnabled && !tlsOptions.IsMtlsEnabled) + { + throw new InvalidOperationException( + "TLS options must include at least a CA path or client certificate path."); + } + + X509Certificate2? caCertificate = null; + X509Certificate2? clientCertificate = null; + TlsHttpClientHandler? handler = null; + + try + { + if (!string.IsNullOrEmpty(tlsOptions.CaCertificatePath)) + { + caCertificate = OtlpCertificateManager.LoadCaCertificate( + tlsOptions.CaCertificatePath); + + if (tlsOptions.EnableCertificateChainValidation) + { + OtlpCertificateManager.ValidateCertificateChain( + caCertificate, + OtlpCertificateManager.CaCertificateType); + } + } + + if (tlsOptions is OtlpMtlsOptions mtlsOptions && mtlsOptions.IsMtlsEnabled) + { + clientCertificate = string.IsNullOrEmpty(mtlsOptions.ClientKeyPath) + ? OtlpCertificateManager.LoadClientCertificate( + mtlsOptions.ClientCertificatePath!, + null) + : OtlpCertificateManager.LoadClientCertificate( + mtlsOptions.ClientCertificatePath!, + mtlsOptions.ClientKeyPath); + + if (tlsOptions.EnableCertificateChainValidation) + { + OtlpCertificateManager.ValidateCertificateChain( + clientCertificate, + OtlpCertificateManager.ClientCertificateType); + } + + OpenTelemetryProtocolExporterEventSource.Log.MtlsConfigurationEnabled( + clientCertificate.Subject); + } + else if (caCertificate != null) + { + OpenTelemetryProtocolExporterEventSource.Log.CaCertificateConfigured( + caCertificate.Subject); + } + + // Create HttpClientHandler and apply TLS configuration +#pragma warning disable CA2000 // Dispose objects before losing scope - HttpClientHandler is disposed by HttpClient + handler = new TlsHttpClientHandler(caCertificate, clientCertificate); +#pragma warning restore CA2000 + + // Handler now owns certificates + caCertificate = null; + 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); + + return client; + } + catch (Exception ex) + { + // Clean up on failure + handler?.Dispose(); + caCertificate?.Dispose(); + clientCertificate?.Dispose(); + + OpenTelemetryProtocolExporterEventSource.Log.SecureHttpClientCreationFailed(ex); + throw; + } + } + + /// + /// 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) + { + return CreateSecureHttpClient(mtlsOptions, configureClient); + } + + /// + /// HttpClientHandler that applies TLS configuration based on loaded certificates. + /// + private sealed class TlsHttpClientHandler : HttpClientHandler + { + private readonly X509Certificate2? caCertificate; + private readonly X509Certificate2? clientCertificate; + private bool disposed; + + internal TlsHttpClientHandler( + X509Certificate2? caCertificate, + X509Certificate2? clientCertificate) + { + this.caCertificate = caCertificate; + this.clientCertificate = clientCertificate; + + this.ConfigureTls(); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !this.disposed) + { + this.clientCertificate?.Dispose(); + this.caCertificate?.Dispose(); + this.disposed = true; + } + + base.Dispose(disposing); + } + + private void ConfigureTls() + { + this.CheckCertificateRevocationList = true; + + this.ConfigureClientCertificate(); + this.ConfigureCaCertificateValidation(); + } + + private void ConfigureClientCertificate() + { + if (this.clientCertificate == null) + { + return; + } + + this.ClientCertificates.Add(this.clientCertificate); + this.ClientCertificateOptions = ClientCertificateOption.Manual; + } + + private void ConfigureCaCertificateValidation() + { + if (this.caCertificate == null) + { + return; + } + + var caCert = this.caCertificate; + this.ServerCertificateCustomValidationCallback = ( + httpRequestMessage, + cert, + chain, + sslPolicyErrors) => + { + if (cert == null || chain == null) + { + return false; + } + + return OtlpCertificateManager.ValidateServerCertificate( + cert, + chain, + sslPolicyErrors, + caCert); + }; + } + } +} + +#endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index 8c91d38d985..cf59c5d3360 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -75,10 +75,10 @@ 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 OtlpMtlsHttpClientFactory.CreateMtlsHttpClient( + return OtlpSecureHttpClientFactory.CreateSecureHttpClient( this.MtlsOptions, client => client.Timeout = timeout); } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs index ebb1f827c77..ae8f7bbadcd 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs @@ -5,32 +5,48 @@ 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) 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/OtlpMtlsCertificateManagerTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpCertificateManagerTests.cs similarity index 88% rename from test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsCertificateManagerTests.cs rename to test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpCertificateManagerTests.cs index df5b1d811d7..a7eb0a7d4fe 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsCertificateManagerTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpCertificateManagerTests.cs @@ -5,7 +5,7 @@ namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; -public class OtlpMtlsCertificateManagerTests +public class OtlpCertificateManagerTests { private const string TestCertPem = @"-----BEGIN CERTIFICATE----- @@ -39,7 +39,7 @@ INVALID CERTIFICATE DATA public void LoadClientCertificate_ThrowsFileNotFoundException_WhenCertificateFileDoesNotExist() { var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate( + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.LoadClientCertificate( "/nonexistent/client.crt", "/nonexistent/client.key")); @@ -56,7 +56,7 @@ public void LoadClientCertificate_ThrowsFileNotFoundException_WhenPrivateKeyFile try { var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate( + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.LoadClientCertificate( tempCertFile, "/nonexistent/client.key")); @@ -73,7 +73,7 @@ public void LoadClientCertificate_ThrowsFileNotFoundException_WhenPrivateKeyFile public void LoadCaCertificate_ThrowsFileNotFoundException_WhenTrustStoreFileDoesNotExist() { var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadCaCertificate("/nonexistent/ca.crt")); + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.LoadCaCertificate("/nonexistent/ca.crt")); Assert.Contains("CA certificate file not found", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("/nonexistent/ca.crt", exception.Message, StringComparison.OrdinalIgnoreCase); @@ -90,7 +90,7 @@ public void LoadClientCertificate_ThrowsInvalidOperationException_WhenCertificat try { var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate(tempCertFile, tempKeyFile)); + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.LoadClientCertificate(tempCertFile, tempKeyFile)); Assert.Contains( "Failed to load client certificate", @@ -113,7 +113,7 @@ public void LoadCaCertificate_ThrowsInvalidOperationException_WhenTrustStoreFile try { var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadCaCertificate(tempTrustStoreFile)); + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.LoadCaCertificate(tempTrustStoreFile)); Assert.Contains( "Failed to load CA certificate", @@ -133,7 +133,7 @@ public void ValidateCertificateChain_DoesNotThrow_WithValidCertificate() using var cert = CreateSelfSignedCertificate(); // Should not throw for self-signed certificate with proper validation - var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateCertificateChain(cert, "test certificate"); + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateCertificateChain(cert, "test certificate"); // For self-signed certificates, validation may fail, but method should not throw Assert.True(result || !result); // Just check that it returns a boolean @@ -146,7 +146,7 @@ public void ValidateCertificateChain_ReturnsResult_WithValidCertificate() using var cert = CreateSelfSignedCertificate(); // Should return a boolean result - var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateCertificateChain(cert, "test certificate"); + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateCertificateChain(cert, "test certificate"); // The result can be true or false, but the method should not throw Assert.True(result || !result); @@ -166,7 +166,7 @@ public void LoadClientCertificate_LoadsFromSeparateFiles() // Note: We expect this to fail because we're using dummy cert/key content // but it should not fail due to the method signature var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate( + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.LoadClientCertificate( tempCertFile, tempKeyFile)); diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsHttpClientFactoryTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSecureHttpClientFactoryTests.cs similarity index 90% rename from test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsHttpClientFactoryTests.cs rename to test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSecureHttpClientFactoryTests.cs index cb001217ef8..66ff27b481c 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsHttpClientFactoryTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSecureHttpClientFactoryTests.cs @@ -10,7 +10,7 @@ namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; -public class OtlpMtlsHttpClientFactoryTests +public class OtlpSecureHttpClientFactoryTests { [Fact] public void CreateHttpClient_ThrowsInvalidOperationException_WhenMtlsIsDisabled() @@ -18,7 +18,7 @@ public void CreateHttpClient_ThrowsInvalidOperationException_WhenMtlsIsDisabled( var options = new OtlpMtlsOptions(); // Disabled by default Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options)); + OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options)); } [Fact] @@ -27,7 +27,7 @@ public void CreateHttpClient_ThrowsFileNotFoundException_WhenCertificateFileDoes var options = new OtlpMtlsOptions { ClientCertificatePath = "/nonexistent/client.crt" }; var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options)); + OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options)); Assert.Contains("Certificate file not found", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -49,7 +49,7 @@ public void CreateHttpClient_ConfiguresClientCertificate_WhenValidCertificatePro EnableCertificateChainValidation = false, // Ignore validation for test cert }; - using var httpClient = OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options); + using var httpClient = OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options); Assert.NotNull(httpClient); @@ -72,16 +72,16 @@ public void CreateHttpClient_ConfiguresClientCertificate_WhenValidCertificatePro } [Fact] - public void CreateHttpClient_ConfiguresServerCertificateValidation_WhenTrustedRootCertificatesProvided() + public void CreateHttpClient_ConfiguresServerCertificateValidation_WhenCaCertificatesProvided() { RunWithCryptoSupportCheck(() => { 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 { @@ -89,7 +89,7 @@ public void CreateHttpClient_ConfiguresServerCertificateValidation_WhenTrustedRo EnableCertificateChainValidation = false, // Avoid platform-specific chain build differences }; - using var httpClient = OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options); + using var httpClient = OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options); Assert.NotNull(httpClient); @@ -130,7 +130,7 @@ public void CreateHttpClient_ConfiguresServerValidation_WithCaOnly() EnableCertificateChainValidation = false, // Avoid platform-specific chain build differences }; - using var httpClient = OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options); + using var httpClient = OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options); var handlerField = typeof(HttpMessageInvoker).GetField( "_handler", @@ -171,7 +171,7 @@ public void CreateHttpClient_InvokesServerValidationCallbackAfterFactoryReturns( EnableCertificateChainValidation = false, }; - using var httpClient = OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options); + using var httpClient = OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(options); var handlerField = typeof(HttpMessageInvoker).GetField( "_handler", @@ -212,7 +212,7 @@ public void ValidateServerCertificate_ReturnsTrue_WhenNoSslPolicyErrors() using var chain = new X509Chain(); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateServerCertificate( + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateServerCertificate( serverCertificate, chain, SslPolicyErrors.None, @@ -229,7 +229,7 @@ public void ValidateServerCertificate_ReturnsTrue_WithProvidedCa() using var chain = new X509Chain(); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateServerCertificate( + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateServerCertificate( serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors, @@ -248,7 +248,7 @@ public void ValidateServerCertificate_ReturnsFalse_WhenCaDoesNotMatch() using var chain = new X509Chain(); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; - var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateServerCertificate( + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateServerCertificate( serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors, @@ -262,20 +262,20 @@ public void ValidateCertificateChain_ReturnsFalseForExpiredCertificate() { using var expiredCertificate = CreateExpiredCertificate(); - var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateCertificateChain( + var result = OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ValidateCertificateChain( expiredCertificate, - OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ClientCertificateType); + OpenTelemetryProtocol.Implementation.OtlpCertificateManager.ClientCertificateType); Assert.False(result); } [Fact] - public void CreateMtlsHttpClient_ThrowsArgumentNullException_WhenOptionsIsNull() + public void CreateSecureHttpClient_ThrowsArgumentNullException_WhenOptionsIsNull() { var exception = Assert.Throws(() => - OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(null!)); + OpenTelemetryProtocol.Implementation.OtlpSecureHttpClientFactory.CreateSecureHttpClient(null!)); - Assert.Equal("mtlsOptions", exception.ParamName); + Assert.Equal("tlsOptions", exception.ParamName); } private static X509Certificate2 CreateSelfSignedCertificate() diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTlsOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTlsOptionsTests.cs new file mode 100644 index 00000000000..2a4fb828afe --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpTlsOptionsTests.cs @@ -0,0 +1,294 @@ +// 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() + { + RunWithCryptoSupportCheck(() => + { + 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() + { + RunWithCryptoSupportCheck(() => + { + 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(); + } + + private static void RunWithCryptoSupportCheck(Action testBody) + { + try + { + testBody(); + } + catch (PlatformNotSupportedException ex) + { + Console.WriteLine($"Skipping TLS tests: {ex.Message}"); + } + catch (CryptographicException ex) when (ex.Message.Contains("not supported", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"Skipping TLS tests: {ex.Message}"); + } + } +} + +#endif