Search code examples
opensslazure-functionsx509certificatebouncycastleazure-appservice

How to validate an incoming client certificate with Azure App Service / Functions? Only possible with `Bouncy Castle`?


In an Azure Function app I need to expose a public HTTP endpoint but want to make it as secure a possible.

I intend to use certificate-based auth as one part of the solution. I have managed to get something up and running with a third party library. I was not able to to it with Microsoft tools alone. Also, if only using Microsoft tools alone, requests are accepted that shouldn't be.

In more detail, I am combining App Services's cert-based auth with Function-level auth. On top of that I sprinkle some security-by-obscurity on-top, i.e. making both the function instance and the endpoint long random strings. I think I got it working now, but for me, the difficulty was in setting up the cert-based auth. Yet, I am still not sure if this is as it should be done. Therefore, I'd like to put my proposal out there for you to scrutinise.

I was unable to validate the cert-auth with only Microsoft tools but instead had to rely on a third party package called Bouncy Castle. I have never heard of that, but on Nuget, it and its apparent predecessor have lots of downloads. When not using Bouncy Castle and instead using only Microsoft SDKs, requests are authenticated that shouldn't be (imho). Therefore I am seeking your help. Any feedback and/or insight is greatly appreciated!

Layer 1 Security by obscurity: Make the endpoints hard-to-guess, and not something that might be well-known.

Layer 2 Function-level API key using the AuthorizationLevel.Function attribute in the function definition.

Layer 3 Cert-based auth. In my understanding this actually comes first. Still, I wanted to get the other two out of they way. As a starter, I am requiring incoming client certificates in the function app's configuration section.

enter image description here

I do have a dummy cert in my user cert store that has the extended key usage client authentication enbabled. The cert is created by me and self-signed.

SCREENSHOT 2 (tet.com cert)

Being on Windows, I use Powershell to test the connectivity.

$dummyCert = ls Cert:\CurrentUser\My\62DAB43EED508EE80E6E712F9C3FC2E3D1CEDE2A
iwr -Uri "<MY-ENDPOINT>" -method "post" -Certificate $dummyCert

This completes successfully. The requirement I configured in the screenshot above only mandates that a valid client cert is presented. App Service will accept any valid cert, even if it is self-signed. This is not what I want. Instead I want to have some assurance, that only known party's requests are processed.

Therefore I did this:

1. Create my own CA cert. I did this in WSL using openssl.

openssl genrsa -des3 -out myCA.key 2048
openssl req -x509 -new -nodes -key myCA.key -sha256 -days 1825 -out myCA.pem

Upload the CA public key certificate myCA.pem to the App Service / Function App, and make it available to the app using the WEBSITE_LOAD_CERTIFICATES app setting.

Make note of the thumbprint 3b6b0c18292522cd2adf94b2bac6d0269a34614d.

2. Create a client cert and have it signed by above CA

openssl genrsa -des3 -out client.key 2048
openssl req -new -sha256 -key client.key -out client.csr
openssl x509 -req -in client.csr -CA myCA.pem -CAkey myCA.key -CAcreateserial -out client.crt -days 825 -sha256 -extfile csr-config.ext

With csr-config.ext:

authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alt_names

[alt_names]
DNS.1 = some.test

Make note of the thumbprint 2c07eb77199c5e0243e842cfea64a1109821c9df.

3. Convert the signed client cert into PFX format. Then, import client.pfx into user cert store using Windows GUI.

openssl pkcs12 -export -in client.crt -inkey client.key -out client.pfx

SCREENSHOT 3 GUI

4. Make authenticated request as client

$url = "https://<guid1>.azurewebsites.net/api/<guid2><guid3>?code=<API_KEY>"
$cert = ls Cert:\CurrentUser\My\2C07EB77199C5E0243E842CFEA64A1109821C9DF
iwr -Uri $url -method "post" -Certificate $cert

5. Validate the client cert's signature in the function code.

What happens now is that in Azure Functions the function triggers, because a valid cert is presented and the proper API key is used. However, I need to perform AuthZ by myself since I don't want to allow just any cert. The client cert is injected by the Azure in the X-ARR-ClientCert header. Basically, I want to make sure that the client cert was signed by my CA from #1. In the following, I am giving you the code that handles the request:

using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using System.Security.Cryptography.X509Certificates;
using Org.BouncyCastle.Crypto;

namespace Me.CertAuthTest;


public class CertTest
{
    private readonly ILogger _log;
    public CertTest(ILogger<CertTest> log) => _log = log;


    [Function(nameof(CertTest))]
    public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "post", Route = "<guid2><guid3>")] HttpRequestData req)
    {
        // CA cert
        string caCertThumbprint = "3b6b0c18292522cd2adf94b2bac6d0269a34614d".ToUpper();
        bool validOnly = false;
        using X509Store certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser);
        certStore.Open(OpenFlags.ReadOnly);
        X509Certificate2Collection certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint,caCertThumbprint, validOnly);
        X509Certificate2? caCert = certCollection.OfType<X509Certificate2>().SingleOrDefault();
        if (caCert is null) throw new Exception($"Certificate with thumbprint {caCertThumbprint} was not found");

        if (req.Headers.TryGetValues("X-ARR-ClientCert", out var clientCertRawData))
        {
            // client cert
            byte[] clientCertBytes = Convert.FromBase64String(clientCertRawData.First());
            Org.BouncyCastle.X509.X509Certificate clientCertBouncy = new(clientCertBytes);

            try
            {
                // convert to bouncy castle cert
                Org.BouncyCastle.X509.X509Certificate caCertBouncy = new(caCert.RawData);
                AsymmetricKeyParameter caPublicKey = caCertBouncy.GetPublicKey();

                // check signature, throws if invalid
                clientCertBouncy.Verify(caPublicKey);
                return await req.RespondWith(new { msg = "okidoki" }, HttpStatusCode.OK);
            }
            catch (Exception ex)
            {
                return await req.RespondWith(new { error = "Signature verification failed. Error= {e}", ex.Message }, HttpStatusCode.Unauthorized);
            }
        }

        return await req.RespondWith(new { error = "No cert found in header" }, HttpStatusCode.Unauthorized);
    }
}

// helper extension method, for making HTTP responses
public static class HttpRequestDataExtension
{
    public static async Task<HttpResponseData> RespondWith(this HttpRequestData req, object o, HttpStatusCode code)
    {
        HttpResponseData response = req.CreateResponse(code);
        byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(o));
        response.Headers.Add("Content-Type", "application/json; charset=utf-8");
        await response.WriteBytesAsync(bytes);
        return response;
    }
}

In pseudo code this is basically:

If no CA cert can be loaded => throw
If no client cert is found in the header => 401
If client cert is not validly signed by the CA => 401
else 200

The request in #4 completes with status 200, as I have expected. In the code I intend to add checks for the expected client cert's thumbprint and for the time period in which it can be used. For illustration purposes I chose to skip these for now. If I use the dummy cert from the beginning I am returned a 401, with the exception message Public key presented not for certificate signature.

So I am ok with how the auth now works. The only thing I would have liked better, is if I could have done it without that third party library and instead use only Microsoft software. To test this (using only Microsoft SDKs) I wrote a copy of the above function and modified it a bit, so there are no Bouncy Castle references:

using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using System.Security.Cryptography.X509Certificates;

namespace Me.CertAuthTest;

public class CertTestWithoutBC
{
    private readonly ILogger _log;
    public CertTestWithoutBC(ILogger<CertTestWithoutBC> log) => _log = log;


    [Function(nameof(CertTestWithoutBC))]
    public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "post", Route = "<guid4><guid5>")] HttpRequestData req)
    {
        // CA cert
        string caCertThumbprint = "3b6b0c18292522cd2adf94b2bac6d0269a34614d".ToUpper();
        bool validOnly = false;
        using X509Store certStore = new X509Store(StoreName.My, StoreLocation.CurrentUser);
        certStore.Open(OpenFlags.ReadOnly);
        X509Certificate2Collection certCollection = certStore.Certificates.Find(X509FindType.FindByThumbprint, caCertThumbprint, validOnly);
        X509Certificate2? caCert = certCollection.OfType<X509Certificate2>().SingleOrDefault();
        if (caCert is null) throw new Exception($"Certificate with thumbprint {caCertThumbprint} was not found");

        if (req.Headers.TryGetValues("X-ARR-ClientCert", out var clientCertRawData))
        {
            // client cert
            byte[] clientCertBytes = Convert.FromBase64String(clientCertRawData.First());
            X509Certificate2 clientCert = new(clientCertBytes);

            // check signature
            X509Chain chain = new();
            chain.ChainPolicy.ExtraStore.Add(caCert);
            chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
            chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
            chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
            bool chainIsValid = chain.Build(clientCert);
            if (!chainIsValid)
            {
                return await req.RespondWith(new { error = "chain invalid " +
                    "/ client certificate not valid" }, HttpStatusCode.Unauthorized);
            }

            return await req.RespondWith(new { msg = "okidoki" }, HttpStatusCode.OK);
        }

        return await req.RespondWith(new { error = "No cert found in header" }, HttpStatusCode.Unauthorized);
    }
}

The main difference is that chain.Build(clientCert) is used to (supposedly?) validate the signature. But I am not sure. Both certificates, the dummyCert (62dab43eed508ee80e6e712f9c3fc2e3d1cede2a) and the cert that is signed by the CA (2c07eb77199c5e0243e842cfea64a1109821c9df) are accepted by the function and return a 200 result. If I ommit (comment out) the line chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority both certs are rejected with a 401. So this approach clearly does not work and I tend to go with Bouncy Castle.

But maybe it is just me that I have misconfigured something. If you have any insights or feedback on why it's not working without Bouncy Castle that would be most appreciated. Also, when using Bouncy Castle, is there something that you would change in the above example?

Cheers


Solution

  • X509Chain.Build() is a complete certificate chain validation tool. It builds the chain, performs revocation checking (if configured), trust, validity, signatures, constraints and many other things. You don't need to do them manually.

    This API alone should be sufficient for most validation tasks. If you have additional requirements, then you should write this additional validation code on top of X509Chain.Build().

    Your particular problem is with invalid configuration. You put your CA certificate into ExtraStore, which isn't designed for trust establishment. It is used to help the chaining engine to build the chain. Your root CA is not trusted, as the result all validation fails until you allow untrusted root. In order to explicitly trust certificates signed by your CA, this CA cert must be added to CustomTrustStore property instead.