-
Notifications
You must be signed in to change notification settings - Fork 859
[OTLP] Add mTLS Support for OTLP Exporter #6343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a363508
faba6ab
3044fc3
7368c9e
c488394
472205a
2d69b7c
960037e
6226ac5
966d076
879cd6c
c1405cb
02b9240
6ae5efe
eadf9c2
2c759c6
87633ba
af26417
0bd7c5e
487d517
59cea03
b80cd21
6e78148
0fca40b
fc25a73
c8a5a55
464201b
ec0db21
84764e8
3ec30f9
f30b6f8
b948ce1
3d75694
4d8b83e
3611145
3f7a6bb
6135c33
e8f122f
cabc172
2825069
b3830a5
ad2871d
f2ba5b9
f94aaaa
855d125
4f19bad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to support
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @martincostello Does this commit look ok? 7368c9e
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I got the same concern as @martincostello, your commit looks fine with me. Why that's not a part of the PR?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rajkumar-rangaraj we need to change the spec first: open-telemetry/opentelemetry-specification#4580 (comment) |
||
| chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot; | ||
sandy2008 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Users with password-protected PFX/PKCS#12 keystores cannot use it here. Are you planning to follow up on
OTEL_EXPORTER_OTLP_CLIENT_KEY_PASSWORDas follow up?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes :) I think we need to change the spec thou: open-telemetry/opentelemetry-specification#4580 (comment)