Thursday, October 24, 2019

How to pin a custom certificate trust chain and DNS name in C#

This code tested on dotnet core 3.0, however the API's are stable and this most likely works across all dotnet versions


// create a single long-lived HttpClient, that's what microsoft's documentation says to do
var handler = new HttpClientHandler {

    ServerCertificateCustomValidationCallback = (message, certificate, chain, policyErrors) => {
        // first check the DNS name in the certificate
        var expectedHostName = new Uri(host).DnsSafeHost;
        var certificateDnsName = certificate.GetNameInfo(X509NameType.DnsName, forIssuer: false);

        if (!certificateDnsName.Equals(expectedHostName, StringComparison.InvariantCultureIgnoreCase))
        {
            log.LogError($"Server Certificate rejected due to invalid subject name." + 
               " Certificate provides {certificateDnsName}, expecting {expectedHostName}");
            return false;
        }

        chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
        chain.ChainPolicy.ExtraStore.Add(caCertificate);

        // refer https://social.msdn.microsoft.com/Forums/vstudio/en-US/1966a6e8-b6f4-44d1-9102-ec3a26426789/how-can-i-verify-a-certificate-manually-without-installing-its-parents?forum=clr
        // we can't have a "trusted" root, because we're not affecting the machine-wide trust store, however
        // if a cert correctly chains back to our CA, then the only "error" will be a single untrustedRoot error, which means
        // everything else was good, just the root wasn't in the machine-trust store, so we're good

        // Note that if the chain terminates at a different root certificate that is trusted by windows, we
        // do NOT want to accept that cert, but it will log something like Validation errors: None which may look a bit weird.
        // Also, For future consideration: Do we want to allow customers to specify multiple extra CA's to handle server migrations?

        // If the CA is in the windows trusted roots, this will return true and chainStatus will be empty
        // If the CA is not in the windows trusted roots, this will return false and chainStatus will contain UntrustedRoot. Either are acceptable
        var chainOk = chain.Build(certificate);
        var chainOkUntrustedRoot = chain.ChainStatus.Length == 1 && chain.ChainStatus[0].Status == X509ChainStatusFlags.UntrustedRoot;

        // PROVIDED the chain terminates in the correct CA, and not some other CA that might randomly be in the windows trusted roots
        var chainTerminatesInCorrectCA = chain.ChainElements[chain.ChainElements.Count - 1].Certificate.Thumbprint == caCertificate.Thumbprint;

        if (chainTerminatesInCorrectCA && (chainOk || chainOkUntrustedRoot))
        {
            return true;
        }

        log.LogError("Server Certificate rejected due to invalid trust chain. Info: {0}", string.Join("", chain.ChainStatus.Select(c => c.StatusInformation))); // statusInformation has \r\n at the end itself
        return false;
    }
};

var client = new HttpClient(handler) {
    BaseAddress = new Uri(host)
};

The above code will pin a specific custom Certificate Authority. It's useful for internal deployments where you might have an internal certificate authority provided by your IT department, and you
want to ONLY trust certificates issued by that authority (and not random other certificates from LetsEncrypt, etc)

If you want to just pin the certificate itself then you don't need to verify the chain or anything like that.