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.
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.
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:
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
.
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
.
client.pfx
into user cert store using Windows GUI.openssl pkcs12 -export -in client.crt -inkey client.key -out client.pfx
$url = "https://<guid1>.azurewebsites.net/api/<guid2><guid3>?code=<API_KEY>"
$cert = ls Cert:\CurrentUser\My\2C07EB77199C5E0243E842CFEA64A1109821C9DF
iwr -Uri $url -method "post" -Certificate $cert
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
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.