Matt Nelson-White
Posted on January 10, 2023
I am frequently in a situation where I rely on using certificates to secure my applications, but the methods vary quite a bit. One thing is clear, I prefer to provide root or intermediate certificates for validation purposes, to the runtime by configuration and not using the system certificate store.
The .NET framework has a X509Chain
class where a x509 certificate chain can verify a certificate. However, the problem with this API is that it uses the system's root certificate store to validate the certificate. In scenarios where the root certificate is only held by the application and is not present in the system's root store, this API does not give reliable results.
Looking around online there really doesn't appear to be any solution to this other than "use bouncycastle", but for those who don't really want to introduce such a large project dependency to validate a certificate chain then that solution may not be appetising.
X509 Certificate Structure
In order to understand how to validate a certificate chain, we need to understand how a X509 certificate is structured and encoded.
According to RFC 3280 Section 4.1, the certificate is a ASN.1 encoded structure, and at it's base level is comprised of only 3 elements.
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING
}
tbsCertificate
: This is the "To Be Signed" certificate structure which is signed by the the signing certificate. The RFC describes this as:
The field contains the names of the subject and issuer, a public key associated with the subject, a validity period, and other associated information.
So pretty much all the certificate extensions and information.
signatureAlgorithm
: This is an identifier for the algorithm used to sign the tbsCertificate; this information is used to know which algorithm to validate the certificate signature, using the signing certificate's public key.
signatureValue
: This is a sequence of bytes which represents the cryptographic proof that the certificate has in fact been signed by some other signing certificate.
Broadly speaking, to validate the certificate we provide the algorithm identified by the signatureAlgorithm
along with the tbsCertificate
and the signatureValue
and the validation function returns a bool to indicate whether the certificate was signed by the chosen signing certificate.
Decoding the Certificate
The certificate data is ASN.1 encoded; but what is ASN.1 encoding? It is essentially a standard for "defining data structures that can be serialised and deserialised in a cross-platform way" (ASN.1).
The standard includes different standards of encoding rules, such as BER, DER, CER, and many others.
For the purposes of decoding a X509 certificate, we only need knowledge of BER and DER (a restricted subset of BER)
The way BER ASN.1 encoding works is the first set of bytes in the data indicates the tag, which is a ASN.1 term to describe the type of data that is encoded.
The next bytes are the data length, then the data itself. For example:
For example, the bytes 0304FFA0CB01
can be deconstructed to:
tag | length | value |
---|---|---|
03 |
04 |
FFA0CB01 |
The tag 03
corresponds to the OCTET STRING type. So the deserialiser knows to read 4
bytes into an octet string of FFA0CB01
.
Looking at the ASN.1 definition for the certificate we can work out the basic structure.
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING
}
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version DEFAULT v1,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
validity Validity,
subject Name,
subjectPublicKeyInfo SubjectPublicKeyInfo,
issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL,
subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
extensions [3] EXPLICIT Extensions OPTIONAL
}
AlgorithmIdentifier ::= SEQUENCE {
algorithm OBJECT IDENTIFIER,
parameters ANY DEFINED BY algorithm OPTIONAL
}
- The data is nested inside a SEQUENCE type (tag
0x10
) - In order to validate the certificate, we don't need to decode the
tbsCertificate
, we just need to extract the bytes from the sequence. - The
signatureAlgorithm
is another sequence from which we want to extract thealgorithm
OID to know which algorithm to use. - Finally the
signatureValue
should not even be in a sequence and is just a BIT STRING (tag0x03
).
Fortunately .NET has AsnDecoder which can help with decoding ASN.1 structures.
Decoding the TBS Certificate
Looking at the methods and properties of the X509Certificate2
type, it does not contain any way to extract the TBS Certificate from it, but we can use what we know to decode the certificate using the AsnDecoder
.
To decode the TBS Certificate from the X509 Certificate, we can write an extension method.
public static ReadOnlySpan<byte> GetTbsCertificate(
this X509Certificate2 certificate,
AsnEncodingRules encodingRules = AsnEncodingRules.BER)
{
var signedData = certificate.RawDataMemory;
AsnDecoder.ReadSequence(
signedData.Span,
encodingRules,
out var offset,
out var length,
out _
);
var certificateSpan = signedData.Span.Slice(offset, length);
AsnDecoder.ReadSequence(
certificateSpan,
encodingRules,
out var tbsOffset,
out var tbsLength,
out _
);
// include ASN1 4 byte preamble offset to get WHOLE TBS Cert
return certificateSpan.Slice(tbsOffset - 4, tbsLength + 4);
}
- The method starts by getting the raw data of the certificate from the
X509Certificate2
type. - From the ASN.1 definitions shown above we know that the certificate data is within an ASN.1 sequence type,
AsnDecoder.ReadSequence
is used to find the offset and length of that sequence. - Because we are good people, we use a span to reduce allocations.
- Now we can start decoding the elements of the structure, the TBS certificate is the first of the ranks. It can be read as a sequence.
- Even though we know the TBS Certificate offset and length, this is not exactly the data we need. After a lot of hair pulling, I discovered the signature is actually based on the data INCLUDING the ASN.1 SEQUENCE preamble. Not including this will give you the wrong data! 😣.
Decoding the Signature Algorithm
In Microsoft's infinite grace, they have already included a property on the X509Certificfate2
type to obtain the signature algorithm.
The SignatureAlgorithm
property returns the OID for the algorithm. Microsoft has a table of signature algorithm OIDs and their corresponding algorithms; we can write our code to accommodate the algorithms we want.
If you are writing your own library you probably want to support as many as possible, but for the purposes of the article, we only need to support some common variants. The OIDs are often organised hierarchically by encryption algorithm and hashing algorithm; for example, RSA has a prefix of 1.2.840.113549.1.1.
and SHA256, SHA384 and SHA512 OIDs are 1.2.840.113549.1.1.11
, 1.2.840.113549.1.1.12
and 1.2.840.113549.1.1.13
respectively.
Decoding the Signature
Unfortunately the X509Certificate2
type does not help us extract the signature data, but knowing what we already know it should be pretty trivial. The only annoyance is that we need to know the length of the TBS certificate and the signature algorithm to get the offset to read the signature.
We can write a separate method to extract the signature, but it needs to read through the other elements.
public static byte[] GetSignature(
this X509Certificate2 certificate,
AsnEncodingRules encodingRules = AsnEncodingRules.BER)
{
var signedData = certificate.RawDataMemory;
AsnDecoder.ReadSequence(
signedData.Span,
encodingRules,
out var offset,
out var length,
out _
);
var certificateSpan = signedData.Span.Slice(offset, length);
AsnDecoder.ReadSequence(
certificateSpan,
encodingRules,
out var tbsOffset,
out var tbsLength,
out _
);
var offsetSpan = certificateSpan[(tbsOffset + tbsLength)..];
AsnDecoder.ReadSequence(
offsetSpan,
encodingRules,
out var algOffset,
out var algLength,
out _
);
return AsnDecoder.ReadBitString(
offsetSpan[(algOffset + algLength)..],
encodingRules,
out _,
out _
);
}
Verifying the Certificate Chain
Now that we have extracted all three elements of the X509 certificate necessary to verify signing by a signing certificate. We can write another extension to determine if a target signing certificate (signedBy
) has signed a particular certificate (signed
).
The overall approach is:
- Using the signature algorithm OID determine which encryption algorithm is being used (we will write for RSA and ECDsa) and use that information to know what type of public key to extract from the
signedBy
certificate. - Using the
VerifyData
method on the public key, we provide the TBS certificate, the signature data, and the hash algorithm, which is obtained from the signature algorithm OID as well. - We will also need to identify the type of signature padding for RSA, and the DSA signature format for ECDsa. We won't go into determining that from the certificate data, instead we will define them statically.
public static bool IsSignedBy(
this X509Certificate2 signed,
X509Certificate2 signer)
{
var signature = signed.GetSignature();
var tbs = signed.GetTbsCertificate();
var alg = signed.SignatureAlgorithm;
switch (alg)
{
case { Value: var value } when value?.StartsWith("1.2.840.113549.1.1.") is true:
return signer.GetRSAPublicKey()?.VerifyData(
tbs,
signature,
value switch {
"1.2.840.113549.1.1.11" => HashAlgorithmName.SHA256,
"1.2.840.113549.1.1.12" => HashAlgorithmName.SHA384,
"1.2.840.113549.1.1.13" => HashAlgorithmName.SHA512,
_ => throw new UnsupportedAlgorithm(alg)
},
RSASignaturePadding.Pkcs1
) ?? false;
case { Value: var value } when value?.StartsWith("1.2.840.10045.") is true:
return signer.GetECDsaPublicKey()?.VerifyData(
tbs,
signature,
value switch {
"1.2.840.10045.4.3.2" => HashAlgorithmName.SHA256,
"1.2.840.10045.4.3.3" => HashAlgorithmName.SHA384,
"1.2.840.10045.4.3.4" => HashAlgorithmName.SHA512,
_ => throw new UnsupportedAlgorithm(alg)
},
DSASignatureFormat.Rfc3279DerSequence
) ?? false;
default: throw new UnsupportedAlgorithm(alg);
}
}
The one thing in this code that took WAY too long to work out was that the ECDsa Verify method has an overload for the DSASignatureFormat
parameter. I have NO IDEA why it defaults to a non-DER format, but it does, and does not work unless you discover this overload.
The above approach can be expanded and abstracted to support more algorithms, but as is it supports the most common algorithms with very little code.
Other checks, such as comparing the "not before" and "not after" dates on the certificate extensions can be done elsewhere to separate concerns.
Example Usage
if (!myCertificate.IsSignedBy(rootCertificate)
{
throw new InvalidCertificateException();
}
Posted on January 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.