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