Custom certificate validation in .NET

 
 
  • Gérald Barré

Many companies run their certificate authority (CA). While this is very convenient for a company as they can generate certificates on demand, it means that every software that makes a call to a https internal service must be aware of the internal root certificate. On Windows, you can add the root certificate to the Certificate Store using a GPO so it's very easy. Other mechanisms could be used on other operating systems. Thus, all applications can validate the root certificate using the certificate store. However, there are some cases where you cannot add the certificate to the store and benefit from the automatic validation. By default, .NET will throw an exception when a certificate cannot be validated:

The underlying connection was closed: Could not establish trust relationship for the SSL/TLS secure channel.

In this case, you need to implement your certificate validation. .NET allows you to override the default certificate validation, but many people use it to disable the validation… This is very bad as it reduces the security of your application. To do it right you need to know the reasons why a certificate can be invalid and handle only the case you are interested in. Here're some of the reasons a certificate is invalid:

  • It can be expired
  • It can be revoked (check the Certificate Revocation List (CRL))
  • It can have a weak signature (it uses an unsecured algorithm for the signature)
  • it can be tampered (the signature is invalid)
  • It can be used for a usage it was not allowed to
  • One of the certificates of the chain is invalid
  • The root certificate is not trusted
  • etc.

In our case, we would like .NET to do all the common checks and just handle the case where the root certificate is not trusted. In this specific case and only this one, we'll check if the root certificate matches the internal CA's root certificate.

First, you need to get the root certificate and export it to an easy to use format. If the certificate is in the certificate store, you can export it as base64 encoded X.509.

  1. Open the certificate manager certmgr.msc

  2. Select the root certificate and select export

    Certificate Manager - Export CertificateCertificate Manager - Export Certificate

  3. Select the base-64 encoded X.509 format

    Certificate Export Wizard - Select CER formatCertificate Export Wizard - Select CER format

  4. At the end, you should have a file in the following form

    Certificate exported in CER formatCertificate exported in CER format

Now let's write the validation method. The signature matches the signature of the event we'll use later. In our case, the only interesting parameter is the certificate chain. The chain contains all the certificates from the website's certificate to the CA's certificate (root certificate). All the certificates of the chain must be valid. if the root certificate is not valid because it is untrusted, we'll compare it with the internal CA's certificate. (Thanks Shahar Zini for reviewing the code)

C#
private static bool ValidateCertificate(HttpRequestMessage request, X509Certificate2 certificate, X509Chain certificateChain, SslPolicyErrors policy)
{
    var validRootCertificates = new []
    {
        Convert.FromBase64String(@"MIIDdzCCAl+gAwIBAgI...9OhgQ="), // Set your own root certificates (format CER)
    };

    if(certificateChain.ChainStatus.Any(status => status.Status != X509ChainStatusFlags.UntrustedRoot))
        return false;

    foreach (var element in certificateChain.ChainElements)
    {
        foreach (var status in element.ChainElementStatus)
        {
            if (status.Status == X509ChainStatusFlags.UntrustedRoot)
            {
                // improvement: we could validate that the request matches an internal domain by using request.RequestUri in addition to the certicate validation

                // Check that the root certificate matches one of the valid root certificates
                if (validRootCertificates.Any(cert => cert.SequenceEqual(element.Certificate.RawData)))
                    continue; // Process the next status
            }

            return false;
        }
    }

    // Return true only if all certificates of the chain are valid
    return true;
}

You can now use an HttpClientHandler and set the custom certificate handler. Then, every time an invalid certificate is found it should call the custom handler which will have the last word.

C#
using var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = ValidateCertificate;
using var httpClient = new HttpClient(handler);

// This should success
var result = await httpClient.GetAsync("https://server.internal/");

The call should now succeed thanks to the custom certificate handler!

#.NET 5 way of validating a custom root certificate

.NET 5 simplifies this specific scenario by providing new properties to use a custom trust store. This way you can prevent any security issues. The code comes from this GitHub issue: https://github.com/dotnet/runtime/issues/39835.

C#
public static RemoteCertificateValidationCallback CreateCustomRootRemoteValidator(X509Certificate2Collection trustedRoots, X509Certificate2Collection intermediates = null)
{
    if (trustedRoots == null)
        throw new ArgumentNullException(nameof(trustedRoots));
    if (trustedRoots.Count == 0)
            throw new ArgumentException("No trusted roots were provided", nameof(trustedRoots));

    // Let's avoid complex state and/or race conditions by making copies of these collections.
    // Then the delegates should be safe for parallel invocation (provided they are given distinct inputs, which they are).
    X509Certificate2Collection roots = new X509Certificate2Collection(trustedRoots);
    X509Certificate2Collection intermeds = null;

    if (intermediates != null)
    {
            intermeds = new X509Certificate2Collection(intermediates);
    }

    intermediates = null;
    trustedRoots = null;

    return (sender, serverCert, chain, errors) =>
    {
        // Missing cert or the destination hostname wasn't valid for the cert.
        if ((errors & ~SslPolicyErrors.RemoteCertificateChainErrors) != 0)
        {
            return false;
        }

        for (int i = 1; i < chain.ChainElements.Count; i++)
        {
            chain.ChainPolicy.ExtraStore.Add(chain.ChainElements[i].Certificate);
        }

        if (intermeds != null)
        {
            chain.ChainPolicy.ExtraStore.AddRange(intermeds);
        }

        chain.ChainPolicy.CustomTrustStore.Clear();
        chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
        chain.ChainPolicy.CustomTrustStore.AddRange(roots);
        return chain.Build((X509Certificate2)serverCert);
    };
}

public static Func<HttpRequestMessage, X509Certificate2, X509Chain, SslPolicyErrors, bool> CreateCustomRootValidator(X509Certificate2Collection trustedRoots, X509Certificate2Collection intermediates = null)
{
    RemoteCertificateValidationCallback callback = CreateCustomRootRemoteValidator(trustedRoots, intermediates);
    return (message, serverCert, chain, errors) => callback(null, serverCert, chain, errors);
}
C#
using var handler = new HttpClientHandler();
byte[] rootCertificateData = Convert.FromBase64String(@"MIIDdzCCAl+gAwIBAgI...9OhgQ="); // Set your own root certificates (format CER)
var rootCertificate = new X509Certificate2(rootCertificateData);
var rootCertificates = new X509Certificate2Collection(rootCertificate);
handler.ServerCertificateCustomValidationCallback = CreateCustomRootValidator(rootCertificates);
using var httpClient = new HttpClient(handler);

// This should success
var result = await httpClient.GetAsync("https://server.internal/");

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub