Skip to content

Commit 1ab1168

Browse files
committed
chore(): Decoupling OTEL_EXPORTER_OTLP_CERTIFICATE from mTLS
1 parent 1080291 commit 1ab1168

15 files changed

+1262
-392
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#if NET
5+
6+
namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation;
7+
8+
/// <summary>
9+
/// Strategy interface for configuring TLS settings on HttpClientHandler.
10+
/// </summary>
11+
/// <remarks>
12+
/// Implementations of this interface apply different TLS configurations:
13+
/// - Server certificate trust (for self-signed certificates).
14+
/// - Mutual TLS (client authentication).
15+
/// </remarks>
16+
internal interface IOtlpTlsHandlerStrategy : IDisposable
17+
{
18+
/// <summary>
19+
/// Configures the HttpClientHandler with appropriate TLS settings.
20+
/// </summary>
21+
/// <param name="handler">The handler to configure.</param>
22+
void Configure(HttpClientHandler handler);
23+
}
24+
25+
#endif

src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,5 +325,28 @@ internal void MtlsHttpClientCreationFailed(Exception ex)
325325
Level = EventLevel.Error)]
326326
internal void MtlsHttpClientCreationFailed(string exception) =>
327327
this.WriteEvent(34, exception);
328+
329+
[Event(
330+
35,
331+
Message = "Trusted certificate configured for server validation. Subject: '{0}'.",
332+
Level = EventLevel.Informational)]
333+
internal void TrustedCertificateConfigured(string subject) =>
334+
this.WriteEvent(35, subject);
335+
336+
[NonEvent]
337+
internal void SecureHttpClientCreationFailed(Exception ex)
338+
{
339+
if (Log.IsEnabled(EventLevel.Error, EventKeywords.All))
340+
{
341+
this.SecureHttpClientCreationFailed(ex.ToInvariantString());
342+
}
343+
}
344+
345+
[Event(
346+
36,
347+
Message = "Failed to create secure HttpClient. Exception: {0}",
348+
Level = EventLevel.Error)]
349+
internal void SecureHttpClientCreationFailed(string exception) =>
350+
this.WriteEvent(36, exception);
328351
#endif
329352
}
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#if NET
5+
6+
using System.Net.Security;
7+
using System.Security.Cryptography;
8+
using System.Security.Cryptography.X509Certificates;
9+
10+
namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation;
11+
12+
/// <summary>
13+
/// Manages certificate loading, validation, and security checks for TLS connections.
14+
/// </summary>
15+
/// <remarks>
16+
/// This class provides functionality for both simple server certificate trust
17+
/// (for self-signed certificates) and mTLS client authentication scenarios.
18+
/// </remarks>
19+
internal static class OtlpCertificateManager
20+
{
21+
internal const string TrustedCertificateType = "Trusted certificate";
22+
internal const string ClientCertificateType = "Client certificate";
23+
internal const string ClientPrivateKeyType = "Client private key";
24+
25+
// Backward compatibility alias
26+
internal const string CaCertificateType = TrustedCertificateType;
27+
28+
/// <summary>
29+
/// Loads a trusted CA certificate from a PEM file.
30+
/// </summary>
31+
/// <param name="certificatePath">Path to the certificate file.</param>
32+
/// <returns>The loaded certificate.</returns>
33+
/// <exception cref="FileNotFoundException">Thrown when the certificate file is not found.</exception>
34+
/// <exception cref="InvalidOperationException">Thrown when the certificate cannot be loaded.</exception>
35+
public static X509Certificate2 LoadCaCertificate(string certificatePath)
36+
{
37+
return LoadTrustedCertificate(certificatePath);
38+
}
39+
40+
/// <summary>
41+
/// Loads a trusted certificate from a PEM file.
42+
/// </summary>
43+
/// <param name="certificatePath">Path to the certificate file.</param>
44+
/// <returns>The loaded certificate.</returns>
45+
/// <exception cref="FileNotFoundException">Thrown when the certificate file is not found.</exception>
46+
/// <exception cref="InvalidOperationException">Thrown when the certificate cannot be loaded.</exception>
47+
/// <remarks>
48+
/// This method is used for loading certificates that establish trust for server
49+
/// validation. This is commonly used when connecting to servers with self-signed
50+
/// certificates. Note: this is NOT mTLS - it only establishes trust for the server's certificate.
51+
/// </remarks>
52+
public static X509Certificate2 LoadTrustedCertificate(string certificatePath)
53+
{
54+
ValidateFileExists(certificatePath, TrustedCertificateType);
55+
56+
try
57+
{
58+
var certificate = X509Certificate2.CreateFromPemFile(certificatePath);
59+
60+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded(
61+
TrustedCertificateType,
62+
certificatePath);
63+
64+
return certificate;
65+
}
66+
catch (Exception ex)
67+
{
68+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed(
69+
TrustedCertificateType,
70+
certificatePath,
71+
ex.Message);
72+
throw new InvalidOperationException(
73+
$"Failed to load CA certificate from '{certificatePath}': {ex.Message}",
74+
ex);
75+
}
76+
}
77+
78+
/// <summary>
79+
/// Loads a client certificate from a single file (e.g., PKCS#12 format) or from separate certificate and key files.
80+
/// </summary>
81+
/// <param name="clientCertificatePath">Path to the client certificate file.</param>
82+
/// <param name="clientKeyPath">Path to the client private key file. Can be null for single-file certificates.</param>
83+
/// <returns>The loaded client certificate with private key.</returns>
84+
/// <exception cref="FileNotFoundException">Thrown when the certificate file is not found.</exception>
85+
/// <exception cref="InvalidOperationException">Thrown when the certificate cannot be loaded.</exception>
86+
/// <exception cref="ArgumentException">Thrown when clientKeyPath is not null for single-file certificate loading.</exception>
87+
public static X509Certificate2 LoadClientCertificate(
88+
string clientCertificatePath,
89+
string? clientKeyPath)
90+
{
91+
if (clientKeyPath == null)
92+
{
93+
// Load certificate from a single file (e.g., PKCS#12 format)
94+
ValidateFileExists(clientCertificatePath, ClientCertificateType);
95+
96+
try
97+
{
98+
X509Certificate2 clientCertificate;
99+
100+
// Try to load as PKCS#12 first, then as PEM
101+
try
102+
{
103+
#if NET9_0_OR_GREATER
104+
clientCertificate = X509CertificateLoader.LoadPkcs12FromFile(clientCertificatePath, (string?)null);
105+
#else
106+
clientCertificate = new X509Certificate2(clientCertificatePath);
107+
#endif
108+
}
109+
catch (Exception ex) when (ex is CryptographicException || ex is InvalidDataException || ex is FormatException)
110+
{
111+
// If PKCS#12 fails, try PEM format
112+
clientCertificate = X509Certificate2.CreateFromPemFile(clientCertificatePath);
113+
}
114+
115+
if (!clientCertificate.HasPrivateKey)
116+
{
117+
throw new InvalidOperationException(
118+
"Client certificate does not have an associated private key.");
119+
}
120+
121+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded(
122+
ClientCertificateType,
123+
clientCertificatePath);
124+
125+
return clientCertificate;
126+
}
127+
catch (Exception ex)
128+
{
129+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed(
130+
ClientCertificateType,
131+
clientCertificatePath,
132+
ex.Message);
133+
throw new InvalidOperationException(
134+
$"Failed to load client certificate from '{clientCertificatePath}': {ex.Message}",
135+
ex);
136+
}
137+
}
138+
139+
// Load certificate and key from separate files
140+
ValidateFileExists(clientCertificatePath, ClientCertificateType);
141+
ValidateFileExists(clientKeyPath, ClientPrivateKeyType);
142+
143+
try
144+
{
145+
X509Certificate2 clientCertificate = X509Certificate2.CreateFromPemFile(
146+
clientCertificatePath,
147+
clientKeyPath);
148+
149+
if (!clientCertificate.HasPrivateKey)
150+
{
151+
throw new InvalidOperationException(
152+
"Client certificate does not have an associated private key.");
153+
}
154+
155+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded(
156+
ClientCertificateType,
157+
clientCertificatePath);
158+
159+
return clientCertificate;
160+
}
161+
catch (Exception ex)
162+
{
163+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed(
164+
ClientCertificateType,
165+
clientCertificatePath,
166+
ex.Message);
167+
throw new InvalidOperationException(
168+
$"Failed to load client certificate from '{clientCertificatePath}' and key from '{clientKeyPath}': {ex.Message}",
169+
ex);
170+
}
171+
}
172+
173+
/// <summary>
174+
/// Validates the certificate chain for a given certificate.
175+
/// </summary>
176+
/// <param name="certificate">The certificate to validate.</param>
177+
/// <param name="certificateType">Type description for logging (e.g., "Client certificate").</param>
178+
/// <returns>True if the certificate chain is valid; otherwise, false.</returns>
179+
public static bool ValidateCertificateChain(
180+
X509Certificate2 certificate,
181+
string certificateType)
182+
{
183+
try
184+
{
185+
using var chain = new X509Chain();
186+
187+
// Configure chain policy
188+
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
189+
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
190+
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
191+
192+
bool isValid = chain.Build(certificate);
193+
194+
if (!isValid)
195+
{
196+
var errors = chain
197+
.ChainStatus.Where(status => status.Status != X509ChainStatusFlags.NoError)
198+
.Select(status => $"{status.Status}: {status.StatusInformation}")
199+
.ToArray();
200+
201+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed(
202+
certificateType,
203+
certificate.Subject,
204+
string.Join("; ", errors));
205+
206+
// Check if certificate is expired - this should throw an exception
207+
bool isExpired = chain.ChainStatus.Any(status =>
208+
status.Status == X509ChainStatusFlags.NotTimeValid ||
209+
status.Status == X509ChainStatusFlags.NotTimeNested);
210+
211+
if (isExpired)
212+
{
213+
throw new InvalidOperationException(
214+
$"Certificate chain validation failed for {certificateType}: Certificate is expired. " +
215+
$"Errors: {string.Join("; ", errors)}");
216+
}
217+
218+
return false;
219+
}
220+
221+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidated(
222+
certificateType,
223+
certificate.Subject);
224+
return true;
225+
}
226+
catch (Exception ex)
227+
{
228+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed(
229+
certificateType,
230+
certificate.Subject,
231+
ex.Message);
232+
233+
return false;
234+
}
235+
}
236+
237+
/// <summary>
238+
/// Validates a server certificate against the provided trusted certificate.
239+
/// </summary>
240+
/// <param name="serverCert">The server certificate to validate.</param>
241+
/// <param name="chain">The certificate chain.</param>
242+
/// <param name="sslPolicyErrors">The SSL policy errors.</param>
243+
/// <param name="trustedCertificate">The trusted certificate to validate against.</param>
244+
/// <returns>True if the certificate is valid; otherwise, false.</returns>
245+
/// <remarks>
246+
/// This method is used to validate server certificates against a trusted CA certificate.
247+
/// Common use case: connecting to a server with a self-signed certificate.
248+
/// </remarks>
249+
internal static bool ValidateServerCertificate(
250+
X509Certificate2 serverCert,
251+
X509Chain chain,
252+
SslPolicyErrors sslPolicyErrors,
253+
X509Certificate2 trustedCertificate)
254+
{
255+
try
256+
{
257+
// If there are no SSL policy errors, accept the certificate
258+
if (sslPolicyErrors == SslPolicyErrors.None)
259+
{
260+
return true;
261+
}
262+
263+
// If the only error is an untrusted root, validate against our trusted cert
264+
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors))
265+
{
266+
// Add our trusted certificate to the chain
267+
chain.ChainPolicy.ExtraStore.Add(trustedCertificate);
268+
chain.ChainPolicy.VerificationFlags =
269+
X509VerificationFlags.AllowUnknownCertificateAuthority;
270+
chain.ChainPolicy.CustomTrustStore.Add(trustedCertificate);
271+
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
272+
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
273+
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
274+
275+
bool isValid = chain.Build(serverCert);
276+
277+
if (isValid)
278+
{
279+
// Verify that the chain terminates with our trusted certificate
280+
var rootCert = chain.ChainElements[^1].Certificate;
281+
if (
282+
string.Equals(
283+
rootCert.Thumbprint,
284+
trustedCertificate.Thumbprint,
285+
StringComparison.OrdinalIgnoreCase))
286+
{
287+
OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidated(
288+
serverCert.Subject);
289+
return true;
290+
}
291+
}
292+
}
293+
294+
OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidationFailed(
295+
serverCert.Subject,
296+
sslPolicyErrors.ToString());
297+
298+
return false;
299+
}
300+
catch (Exception ex)
301+
{
302+
OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidationFailed(
303+
serverCert.Subject,
304+
ex.Message);
305+
306+
return false;
307+
}
308+
}
309+
310+
private static void ValidateFileExists(string filePath, string fileType)
311+
{
312+
if (string.IsNullOrEmpty(filePath))
313+
{
314+
throw new ArgumentException(
315+
$"{fileType} path cannot be null or empty.",
316+
nameof(filePath));
317+
}
318+
319+
if (!File.Exists(filePath))
320+
{
321+
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateFileNotFound(
322+
fileType,
323+
filePath);
324+
throw new FileNotFoundException($"{fileType} file not found at path: {filePath}", filePath);
325+
}
326+
}
327+
}
328+
329+
#endif

0 commit comments

Comments
 (0)