Search code examples
c#.netcertificatesslstreampinning

Root Certificate Pinning in C#/.NET


I want to implement certificate/public key pinning in my C# application. I already saw a lot of solutions that pin the certificate of the server directly as e.g. in this question. However, to be more flexible I want to pin the root certificate only. The certificate the server gets in the setup is signed by an intermediate CA which itself is signed by the root.

What I implemented so far is a server that loads its own certificate, the private key, intermediate certificate, and the root certificate from an PKCS#12 (.pfx) file. I created the file using the following command:

openssl pkcs12 -export -inkey privkey.pem -in server_cert.pem -certfile chain.pem -out outfile.pfx

The chain.pem file contains the root and intermediate certificate.

The server loads this certificate and wants to authenticate itself against the client:

// certPath is the path to the .pfx file created before
var cert = new X509Certificate2(certPath, certPass)
var clientSocket = Socket.Accept();
var sslStream = new SslStream(
    new NetworkStream(clientSocket),
    false
);

try {
    sslStream.AuthenticateAsServer(cert, false, SslProtocols.Tls12, false);
} catch(Exception) {
     // Error during authentication
}

Now, the client wants to authenticate the server:

public void Connect() {
    var con = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    con.Connect(new IPEndPoint(this.address, this.port));
    var sslStream = new SslStream(
        new NetworkStream(con),
        false,
        new RemoteCertificateValidationCallback(ValidateServerCertificate),
        null
    );
    sslStream.AuthenticateAsClient("serverCN");
}

public static bool ValidateServerCertificate(
    object sender,
    X509Certificate certificate,
    X509Chain chain,
    SslPolicyErrors sslPolicyErrors
)
{
    // ??
}

The problem now is that the server only sends its own certificate to the client. Also the chain parameter does not contain further information. This is somehow plausible as the X509Certificate2 cert (in the server code) only contains the server certificate and no information about the intermediate or root certificate. However, the client is not able to validate the whole chain as (at least) the intermediate certificate is missing.

So far, I did not find any possiblity to make .NET send the whole certificate chain, but I do not want to pin the server certificate iself or the intermediate one as this destroys the flexibility of root certificate pinning.

Therefore, does anyone know a possibility to make SslStream sending the whole chain for authentication or implement the functionality using another approach? Or do I have to pack the certificates differently?

Thanks!

Edit: I made some other tests to detect the problem. As suggested in the comments, I created a X509Store that contains all the certificates. After that, I built a X509Chain using my server's certificate and the store. On the server itself, the new chain contains all the certificates correctly, but not in the ValidateServerCertificate function..


Solution

  • SslStream will never send the whole chain (except for self-issued certificates). The convention is to send everything except for the root, because the other side either already has and trusts the root or doesn't have (thus/or doesn't trust the root), and either way it was a waste of bandwidth.

    But SslStream can only send the intermediates when it understands the intermediates.

    var cert = new X509Certificate2(certPath, certPass);
    

    This only extracts the end-entity certificate (the one with the private key), it discards any other certificates in the PFX. If you want to load all of the certificates you need to use X509Certificate2Collection.Import. But... that doesn't really help you either. SslStream only accepts the end-entity certificate, it expects the system to be able to build a functioning chain for it.

    In order to build a functioning chain, your intermediate and root certificates need to be in any of:

    • Provided as manual input via X509Chain.ChainPolicy.ExtraStore
      • Since the chain in question is built by SslStream you can't really do this here.
    • CurrentUser\My X509Store
    • *LocalMachine\My X509Store
    • CurrentUser\CA X509Store
    • **LocalMachine\CA X509Store
    • CurrentUser\Root X509Store
    • **LocalMachine\Root X509Store
    • *LocalMachine\ThirdPartyRoot X509Store
    • An http (not-s) location identified in an Authority Access Identifier extension in the certificate.

    The stores marked with * don't exist on .NET Core on Linux. The stores marked with ** do exist on Linux, but cannot be modified by a .NET application.

    That's also not quite sufficient, because (at least for SslStream on Linux and probably macOS on .NET Core) it still only sends the intermediates if it built a chain it trusted. So the server needs to actually trust the root certificate for it to send the intermediates. (Or the client needs to trust the root for the client cert)


    On the other side, the same rules apply. The difference is that in the callback you can choose to rebuild the chain to add the extra certificates.

    private static bool IsExpectedRootPin(X509Chain chain)
    {
        X509Certificate2 lastCert = chain.ChainElements[chain.ChainElements.Count - 1].Certificate;
        return lastCert.RawBytes.SequenceEquals(s_pinnedRootBytes);
    }
    
    private static bool ValidateServerCertificate(
        object sender,
        X509Certificate certificate,
        X509Chain chain,
        SslPolicyErrors sslPolicyErrors
    )
    {
        if ((sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != 0)
        {
            // No cert, or name mismatch (or any future errors)
            return false;
        }
    
        if (IsExpectedRootPin(chain))
        {
            return true;
        }
    
        chain.ChainPolicy.ExtraStore.Add(s_intermediateCert);
        chain.ChainPolicy.ExtraStore.Add(s_pinnedRoot);
        chain.ChainPolicy.VerificationFlags |= X509VerificationFlags.AllowUnknownCertificateAuthority;
    
        if (chain.Build(chain.ChainElements[0].Certificate))
        {
            return IsExpectedRootPin(chain);
        }
    
        return false;
    }
    

    Of course, the problem with this approach is that you need to also understand and provide the intermediate on the remote side. The real solution to this is that the intermediates should be available on an HTTP distribution endpoint and the issued certificates should carry the Authority Information Access extension to be able to locate them dynamically.