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:
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:
curl --insecure -v https://localhost:5001/weatherForecast
With the “good” cert:
curl --insecure -v --key client.key --cert client.crt https://localhost:5001/weatherForecast
With the “bad” cert:
curl --insecure -v --key badClient.key --cert badClient.crt https://localhost:5001/weatherForecast
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:
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
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:
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:
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).
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:
Changing the stubbed implementation to return false
from IsValid
:
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?
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
:
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;
}
}
curl --insecure -v --key badClient.key --cert badClient.crt https://localhost:5001/weatherForecast
curl --insecure -v --key badClient.key --cert badClient.crt https://localhost:5001/weatherForecast
Top comments (0)