Setting up mTLS and Kestrel (cont.)

Setting up mTLS and Kestrel (cont.)

In the last post we started talking about mTLS. In the post I pointed out that the client cert’s signing CA was not verified, let’s fix that!

The problem

The main thing accomplished in the previous post was getting a CA up and running with a client certificate signed by that CA. We updated our web api to require client certificates, and successfully connected to the application using our new client certificate.

The flaw in the previous post was though we were validating a client certificate was present, we weren’t actually validating that the client certificate was signed by the CA we created. If the client certificate were valid for some other reason (like if it were signed by another trusted root CA on the system, like an actual internet CA) it would still “get in”.

Steps to Remedy

  • Set up for depicting the problem
  • Demonstrate the problem
  • Solve the problem with code

Set up for depicting the problem

So first things first, we need to show that this is in fact a problem. I’ve already described the problem, but how can we go about proving it?

Easiest thing IMO, is to just create another CA and client cert with the new CA. The web api should accept both the original “client” cert, as well as the “badClient” cert.

I’m going to do all the steps from the previous post for setting up the CA, including trusting our “badCa”. The reason this is being done has already been covered above, but just to reiterate, we want to show that any valid cert is currently getting in; where we want only valid certs from our intended CA to get in.

So now we should have the following:

  • A “good” CA - the one we want to ensure checked out client certs
  • A “good” client cert - the one that should be accepted via our app
  • A “bad” CA - a valid CA, but not one that should be allowed to sign certificates that can get into our app
  • A “bad” client cert - signed by the “bad” ca, should not (but currently will be able to) get into our app

It’ll look something like this if you’re inspecting the certificates:

So many certs

Demonstrate the problem

Now all there is to do is hit our web api with both the “good” cert and the “bad” cert, and confirm we are in fact able to get output from both.

Just like previously, let’s make sure the web api is running with a dotnet run from the “Kritner.Mtls” project.

Then hit the application first w/o a cert:

1
curl --insecure -v https://localhost:5001/weatherForecast

No cert

With the “good” cert:

1
curl --insecure -v --key client.key --cert client.crt https://localhost:5001/weatherForecast

Good cert

With the “bad” cert:

1
curl --insecure -v --key badClient.key --cert badClient.crt https://localhost:5001/weatherForecast

Good cert


You can see, as expected, the request w/o a client cert is rejected, and the requests with both the “good” and “bad” client certs get through. Now, we need to figure out how to go about restricting the app to only accept client certificates signed by our “good” CA

Solve the problem with code

So first things first, we need to identify something about what makes “good” client certificate “good”, and what makes “bad” client certificate “bad”. If you inspect the certificates on your system, you’ll see there is an “Authority Key Identifier” as an attribute. This “Authority Key Identifier” on the client certificate matches the “Authority Key Identifier” and/or “Subject Key Identifier” on the CA that signed the certificate:

Authority Key Identifier

Apologies about all different CA labels and whatnot if you’ve noticed them in the screenshots, I’m switching around computers like a madlad!

In the above, you’ll be able to see that the “good” cert “belongs” to the “good” CA, and the “bad” cert “belongs” to the “bad” CA - this is the information we need! Now we just need a way to get to the information in code.

Introduce an additional service to validate the CA

Note the starting point of the code I’m working with is https://github.com/Kritner-Blogs/Kritner.Mtls/releases/tag/v0.9.1

Let’s review the current code within Startup I even left a little note for myself and others from the last post:

Implementation Stub
1
2
3
4
5
6
7
8
9
10
11
12
13
14
OnCertificateValidated = context =>
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<Startup>>();

// You should implement a service that confirms the certificate passed in
// was signed by the root CA.

// Otherwise, a certificate that is valid to one of the other trusted CAs on the webserver,
// would be valid in this case as well.

logger.LogInformation("You did it my dudes!");

return Task.CompletedTask;
}

Let’s introduce a service into this section of code, its abstraction will look like this:

1
2
3
4
public interface ICertificateAuthorityValidator
{
bool IsValid(X509Certificate2 clientCert);
}

In the above abstraction, we’re taking in a client certificate, and returning whether or not it’s valid (obviously). the context within OnCertificateValidated has access to the ClientCertificate and it’s already in the form of X509Certificate2.

Let’s stub out our implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CertificateAuthorityValidator : ICertificateAuthorityValidator
{
private readonly ILogger<CertificateAuthorityValidator> _logger;

public CertificateAuthorityValidator(ILogger<CertificateAuthorityValidator> logger)
{
_logger = logger;
}

public bool IsValid(X509Certificate2 clientCert)
{
_logger.LogInformation($"Validating certificate within the {nameof(CertificateAuthorityValidator)}");
return true;
}
}

The above is obviously just a “starting point”, where we’re always saying its valid. We’ll wire it up by registering it as a service, and plugging it into our OnCertificateValidated. The writing up of the services I’ve covered several times in other posts but if you need help, take a look at the finished code (TODO put a link here… if I miss this on my review, there’ll probably be a link at the bottom).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
OnCertificateValidated = context =>
{
var logger = context.HttpContext.RequestServices.GetService<ILogger<Startup>>();
logger.LogInformation("Within the OnCertificateValidated portion of Startup");

var caValidator = context.HttpContext.RequestServices.GetService<ICertificateAuthorityValidator>();
if (!caValidator.IsValid(context.ClientCertificate))
{
const string failValidationMsg = "The client certificate failed to validate";
logger.LogWarning(failValidationMsg);
context.Fail(failValidationMsg);
}

return Task.CompletedTask;
}

Above, we’re getting an instance of our ICertificateAuthorityValidator once the client certificate is (otherwise) validated, then running out additional validation procedure on it. If the validation fails, it will mark the validation as failure, otherwise it will still be successful.

With our stubbed implementation returning true from IsValid, let’s see what that looks like:

Success with our IsValid stub always returning true

Changing the stubbed implementation to return false from IsValid:

Failure with our IsValid stub always returning false

Actual Implementation

Now we can work on our actual implementation of the CertificateAuthorityValidator. You’ll recall that we can (hopefully) rely on the “Authority Key Identifier” to ensure only our intended CA’s signed certificates can make it through validation.

Shall we do some debugging?

Authority Key Identifier within clientCert

The screenshot above shows that the information we need is in fact present in the data presented to us from the X509Certificate2. To save a bit of time and writing, know that the “raw data” on this extension does represent the same value on the CA cert, but there’s a few additional bytes of information, namely “KeyID=” (as seen in the screenshots earlier). I could not actually get this data from the bytes to confirm (tried getting the byte string as ascii, utf8, and several others), but that’s what it seemed to be. This means for our implementation, we need the “raw data” from this extension, minus a few of the first bytes to account for what I can only assume is “KeyID=”.

The full CertificateAuthorityValidator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class CertificateAuthorityValidator : ICertificateAuthorityValidator
{
private readonly ILogger<CertificateAuthorityValidator> _logger;

// this should probably be injected via config or loaded from the cert
// Apparently the bytes are in the reverse order when using this BigInteger parse method,
// hence the reverse
private readonly byte[] _caCertSubjectKeyIdentifier = BigInteger.Parse(
"e9be86f64eb53bc12c1b5fe0f63df450274811da",
System.Globalization.NumberStyles.HexNumber
).ToByteArray().Reverse().ToArray();

private const string AuthorityKeyIdentifier = "Authority Key Identifier";

public CertificateAuthorityValidator(ILogger<CertificateAuthorityValidator> logger)
{
_logger = logger;
}

public bool IsValid(X509Certificate2 clientCert)
{
_logger.LogInformation($"Validating certificate within the {nameof(CertificateAuthorityValidator)}");

if (clientCert == null)
return false;
foreach (var extension in clientCert.Extensions)
{
if (extension.Oid.FriendlyName.Equals(AuthorityKeyIdentifier, StringComparison.OrdinalIgnoreCase))
{
try
{
var authorityKeyIdentifier = new byte[_caCertSubjectKeyIdentifier.Length];
// Copy from the extension raw data, starting at the index that should be after the "KeyID=" bytes
Array.Copy(
extension.RawData, extension.RawData.Length - _caCertSubjectKeyIdentifier.Length,
authorityKeyIdentifier, 0,
authorityKeyIdentifier.Length);

if (_caCertSubjectKeyIdentifier.SequenceEqual(authorityKeyIdentifier))
{
_logger.LogInformation("Successfully validated the certificate came from the intended CA.");
return true;
}
else
{
_logger.LogError($"Client cert with subject '{clientCert.Subject}' not signed by our CA.");
return false;
}
}
catch (Exception e)
{
_logger.LogError(e, string.Empty);
return false;
}
}
}

_logger.LogError($"'{clientCert.Subject}' did not contain the extension to check for CA validity.");
return false;
}
}

Should be relatively self explanatory what’s going on in the above, but here’s the breakdown:

  • Setting a variable as the CA certificates “Subject Key Identifier” (SKI), using the hex string from the images near the beginning of the post. (I pointed it out in the comments in code but note that the byte array is reversed from how we want it, so we reversed the bytes to get what we needed)
  • Enumerate through the clientCert.Extensions until finding the one with a “Friendly Name” of “Authority Key Identifier”
  • When found, drop the “KeyID=” (assumed) bytes by doing an array copy starting at the full raw bytes length minus the length of the SKI
  • Compare the two sequences
  • Return true if they match, false otherwise

Testing it out

It’s now time to check our work! Run the app if it’s not already running, and let’s check if we can get information using our good certificate:

1
curl --insecure -v --key client.key --cert client.crt https://localhost:5001/weatherForecast

Good cert test

1
curl --insecure -v --key badClient.key --cert badClient.crt https://localhost:5001/weatherForecast

Bad cert test

It works!

References

Author

Russ Hammett

Posted on

2020-07-22

Updated on

2022-10-13

Licensed under

Comments