Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a363508
feat(): add mtls support
sandy2008 Jun 20, 2025
faba6ab
refactor(mtls): extract repeated string literals to constants in Otlp…
sandy2008 Jun 20, 2025
3044fc3
feat(): add mtls support
sandy2008 Jun 20, 2025
7368c9e
feat(): add mtls configurations for certs
sandy2008 Jun 20, 2025
c488394
fix(mtls): include filePath in FileNotFoundException for better diagn…
sandy2008 Jun 23, 2025
472205a
fix(mtls): use string.Equals to safely compare thumbprints and avoid …
sandy2008 Jun 23, 2025
2d69b7c
fix(): remove redundant comment
sandy2008 Jun 23, 2025
960037e
fix(): remove redundant comment
sandy2008 Jun 23, 2025
6226ac5
fix(): remove redundant comment
sandy2008 Jun 23, 2025
966d076
feat(): add support for client key w/ password
sandy2008 Jun 23, 2025
879cd6c
refactor(mtls): extract HttpClient default configuration to separate …
sandy2008 Jun 23, 2025
c1405cb
refactor(mtls): eliminate magic strings in OtlpMtlsHttpClientFactory
sandy2008 Jun 23, 2025
02b9240
feat(mtls): adjust error msg
sandy2008 Jun 24, 2025
6ae5efe
feat(mtls): adjust error msg
sandy2008 Jun 24, 2025
eadf9c2
feat(mtls): adjust error msg
sandy2008 Jun 24, 2025
2c759c6
chore(mtls): when mTLS file permission validation is skipped on unsup…
sandy2008 Jun 24, 2025
87633ba
chore(mtls); Remove preemptive file permission validation from mTLS
sandy2008 Jun 24, 2025
af26417
refactor(mtls): optimize mTLS HttpClient creation to reduce allocations
sandy2008 Jun 24, 2025
0bd7c5e
fix(mtls): format issue
sandy2008 Jun 24, 2025
487d517
fix(mtls): format issue
sandy2008 Jun 24, 2025
59cea03
chore(mtls): Make OtlpMtlsOptions sealed
sandy2008 Jun 24, 2025
b80cd21
fix(mtls): format issue
sandy2008 Jun 24, 2025
6e78148
fix(mtls): format issue
sandy2008 Jun 24, 2025
0fca40b
chore(README): table inline
sandy2008 Jun 24, 2025
fc25a73
fix(): clean up shipped file
sandy2008 Jun 24, 2025
c8a5a55
chore(mtls): fix README doc
sandy2008 Jun 25, 2025
464201b
chore(mtls): adjust return mtlsClient to 1-liner
sandy2008 Jun 25, 2025
ec0db21
chore(mtls): simplify new OtlpMtlsOptions()
sandy2008 Jun 25, 2025
84764e8
refactor(mtls): simplify null check and add comment for ExcludeRoot d…
sandy2008 Jun 25, 2025
3ec30f9
fix(mtls): Fix client certificate loading when ClientKeyPath is not p…
sandy2008 Jun 25, 2025
f30b6f8
chore(mtls): Improve certificate disposal in OtlpMtlsHttpClientFactory
sandy2008 Jun 25, 2025
b948ce1
fix(mtls): Add missing using Xunit statements to mTLS test files
sandy2008 Jun 25, 2025
3d75694
fix(mtls): suppress SYSLIB0057 warnings in certificate loading code
sandy2008 Jun 25, 2025
4d8b83e
fix(mtls): fix test
sandy2008 Jun 25, 2025
3611145
chore(): replace #if NET
sandy2008 Jun 25, 2025
3f7a6bb
chore(mtls): remove needless pragma warning restore SYSLIB0057
sandy2008 Jun 25, 2025
6135c33
fix(): Remove support for client key password and certificate revocat…
sandy2008 Jun 25, 2025
e8f122f
Merge branch 'main' into main
sandy2008 Jul 12, 2025
cabc172
Merge branch 'main' into main
sandy2008 Aug 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -240,4 +240,56 @@ void IConfigurationExtensionsLogger.LogInvalidConfigurationValue(string key, str
{
this.InvalidConfigurationValue(key, value);
}

#if NET
[Event(26, Message = "{0} loaded successfully from '{1}'.", Level = EventLevel.Informational)]
internal void MtlsCertificateLoaded(string certificateType, string filePath) =>
this.WriteEvent(26, certificateType, filePath);

[Event(27, Message = "Failed to load {0} from '{1}'. Error: {2}", Level = EventLevel.Error)]
internal void MtlsCertificateLoadFailed(
string certificateType,
string filePath,
string error) => this.WriteEvent(27, certificateType, filePath, error);

[Event(28, Message = "{0} file not found at path: '{1}'.", Level = EventLevel.Error)]
internal void MtlsCertificateFileNotFound(string certificateType, string filePath) =>
this.WriteEvent(28, certificateType, filePath);

[Event(
29,
Message = "{0} chain validation failed for certificate '{1}'. Errors: {2}",
Level = EventLevel.Error)]
internal void MtlsCertificateChainValidationFailed(
string certificateType,
string subject,
string errors) => this.WriteEvent(29, certificateType, subject, errors);

[Event(
30,
Message = "{0} chain validated successfully for certificate '{1}'.",
Level = EventLevel.Informational)]
internal void MtlsCertificateChainValidated(string certificateType, string subject) =>
this.WriteEvent(30, certificateType, subject);

[Event(
31,
Message = "Server certificate validated successfully for '{0}'.",
Level = EventLevel.Informational)]
internal void MtlsServerCertificateValidated(string subject) => this.WriteEvent(31, subject);

[Event(
32,
Message = "Server certificate validation failed for '{0}'. Errors: {1}",
Level = EventLevel.Error)]
internal void MtlsServerCertificateValidationFailed(string subject, string errors) =>
this.WriteEvent(32, subject, errors);

[Event(
33,
Message = "mTLS configuration enabled. Client certificate: '{0}'.",
Level = EventLevel.Informational)]
internal void MtlsConfigurationEnabled(string clientCertificateSubject) =>
this.WriteEvent(33, clientCertificateSubject);
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
// 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;

namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation;

/// <summary>
/// Manages certificate loading, validation, and security checks for mTLS connections.
/// </summary>
internal static class OtlpMtlsCertificateManager
{
internal const string CaCertificateType = "CA certificate";
internal const string ClientCertificateType = "Client certificate";
internal const string ClientPrivateKeyType = "Client private key";

/// <summary>
/// Loads a CA certificate from a PEM file.
/// </summary>
/// <param name="caCertificatePath">Path to the CA certificate file.</param>
/// <returns>The loaded CA certificate.</returns>
/// <exception cref="FileNotFoundException">Thrown when the certificate file is not found.</exception>
/// <exception cref="InvalidOperationException">Thrown when the certificate cannot be loaded.</exception>
public static X509Certificate2 LoadCaCertificate(string caCertificatePath)
{
ValidateFileExists(caCertificatePath, CaCertificateType);

try
{
var caCertificate = X509Certificate2.CreateFromPemFile(caCertificatePath);

OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded(
CaCertificateType,
caCertificatePath);

return caCertificate;
}
catch (Exception ex)
{
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed(
CaCertificateType,
caCertificatePath,
ex.Message);
throw new InvalidOperationException(
$"Failed to load CA certificate from '{caCertificatePath}': {ex.Message}",
ex);
}
}

/// <summary>
/// Loads a client certificate from a single file (e.g., PKCS#12 format) or from separate certificate and key files.
/// </summary>
/// <param name="clientCertificatePath">Path to the client certificate file.</param>
/// <param name="clientKeyPath">Path to the client private key file. Can be null for single-file certificates.</param>
/// <returns>The loaded client certificate with private key.</returns>
/// <exception cref="FileNotFoundException">Thrown when the certificate file is not found.</exception>
/// <exception cref="InvalidOperationException">Thrown when the certificate cannot be loaded.</exception>
/// <exception cref="ArgumentException">Thrown when clientKeyPath is not null for single-file certificate loading.</exception>
public static X509Certificate2 LoadClientCertificate(
string clientCertificatePath,
string? clientKeyPath)
{
if (clientKeyPath == null)
{
// Load certificate from a single file (e.g., PKCS#12 format)
ValidateFileExists(clientCertificatePath, ClientCertificateType);

try
{
X509Certificate2 clientCertificate;

// Try to load as PKCS#12 first, then as PEM
try
{
#if NET9_0_OR_GREATER
clientCertificate = X509CertificateLoader.LoadPkcs12FromFile(clientCertificatePath, (string?)null);
#else
clientCertificate = new X509Certificate2(clientCertificatePath);
#endif
}
catch (Exception ex) when (ex is CryptographicException || ex is InvalidDataException || ex is FormatException)
{
// If PKCS#12 fails, try PEM format
clientCertificate = X509Certificate2.CreateFromPemFile(clientCertificatePath);
}

if (!clientCertificate.HasPrivateKey)
{
throw new InvalidOperationException(
"Client certificate does not have an associated private key.");
}

OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded(
ClientCertificateType,
clientCertificatePath);

return clientCertificate;
}
catch (Exception ex)
{
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed(
ClientCertificateType,
clientCertificatePath,
ex.Message);
throw new InvalidOperationException(
$"Failed to load client certificate from '{clientCertificatePath}': {ex.Message}",
ex);
}
}

// Load certificate and key from separate files
ValidateFileExists(clientCertificatePath, ClientCertificateType);
ValidateFileExists(clientKeyPath, ClientPrivateKeyType);

try
{
X509Certificate2 clientCertificate = X509Certificate2.CreateFromPemFile(
clientCertificatePath,
clientKeyPath);

if (!clientCertificate.HasPrivateKey)
{
throw new InvalidOperationException(
"Client certificate does not have an associated private key.");
}

OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded(
ClientCertificateType,
clientCertificatePath);

return clientCertificate;
}
catch (Exception ex)
{
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed(
ClientCertificateType,
clientCertificatePath,
ex.Message);
throw new InvalidOperationException(
$"Failed to load client certificate from '{clientCertificatePath}' and key from '{clientKeyPath}': {ex.Message}",
ex);
}
}

/// <summary>
/// Validates the certificate chain for a given certificate.
/// </summary>
/// <param name="certificate">The certificate to validate.</param>
/// <param name="certificateType">Type description for logging (e.g., "Client certificate").</param>
/// <returns>True if the certificate chain is valid; otherwise, false.</returns>
public static bool ValidateCertificateChain(
X509Certificate2 certificate,
string certificateType)
{
try
{
using var chain = new X509Chain();

// Configure chain policy
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;

bool isValid = chain.Build(certificate);

if (!isValid)
{
var errors = chain
.ChainStatus.Where(status => status.Status != X509ChainStatusFlags.NoError)
.Select(status => $"{status.Status}: {status.StatusInformation}")
.ToArray();

OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed(
certificateType,
certificate.Subject,
string.Join("; ", errors));

// Check if certificate is expired - this should throw an exception
bool isExpired = chain.ChainStatus.Any(status =>
status.Status == X509ChainStatusFlags.NotTimeValid ||
status.Status == X509ChainStatusFlags.NotTimeNested);

if (isExpired)
{
throw new InvalidOperationException(
$"Certificate chain validation failed for {certificateType}: Certificate is expired. " +
$"Errors: {string.Join("; ", errors)}");
}

return false;
}

OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidated(
certificateType,
certificate.Subject);
return true;
}
catch (Exception ex)
{
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed(
certificateType,
certificate.Subject,
ex.Message);

return false;
}
}

/// <summary>
/// Validates a server certificate against the provided CA certificate.
/// </summary>
/// <param name="serverCert">The server certificate to validate.</param>
/// <param name="chain">The certificate chain.</param>
/// <param name="sslPolicyErrors">The SSL policy errors.</param>
/// <param name="caCertificate">The CA certificate to validate against.</param>
/// <returns>True if the certificate is valid; otherwise, false.</returns>
internal static bool ValidateServerCertificate(
X509Certificate2 serverCert,
X509Chain chain,
SslPolicyErrors sslPolicyErrors,
X509Certificate2 caCertificate)
{
try
{
// If there are no SSL policy errors, accept the certificate
if (sslPolicyErrors == SslPolicyErrors.None)
{
return true;
}

// If the only error is an untrusted root, validate against our CA
if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
{
// Add our CA certificate to the chain
chain.ChainPolicy.ExtraStore.Add(caCertificate);
chain.ChainPolicy.VerificationFlags =
X509VerificationFlags.AllowUnknownCertificateAuthority;

bool isValid = chain.Build(serverCert);

if (isValid)
{
// Verify that the chain terminates with our CA
var rootCert = chain.ChainElements[^1].Certificate;
if (
string.Equals(
rootCert.Thumbprint,
caCertificate.Thumbprint,
StringComparison.OrdinalIgnoreCase))
{
OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidated(
serverCert.Subject);
return true;
}
}
}

OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidationFailed(
serverCert.Subject,
sslPolicyErrors.ToString());

return false;
}
catch (Exception ex)
{
OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidationFailed(
serverCert.Subject,
ex.Message);

return false;
}
}

private static void ValidateFileExists(string filePath, string fileType)
{
if (string.IsNullOrEmpty(filePath))
{
throw new ArgumentException(
$"{fileType} path cannot be null or empty.",
nameof(filePath));
}

if (!File.Exists(filePath))
{
OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateFileNotFound(
fileType,
filePath);
throw new FileNotFoundException($"{fileType} file not found at path: {filePath}", filePath);
}
}
}

#endif
Loading
Loading