DEV Community

Cover image for How To Manually Verify x509 Certificate Chains in .NET
Matt Nelson-White
Matt Nelson-White

Posted on • Edited on

How To Manually Verify x509 Certificate Chains in .NET

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  1. The data is nested inside a SEQUENCE type (tag 0x10)
  2. In order to validate the certificate, we don't need to decode the tbsCertificate, we just need to extract the bytes from the sequence.
  3. The signatureAlgorithm is another sequence from which we want to extract the algorithm OID to know which algorithm to use.
  4. Finally the signatureValue should not even be in a sequence and is just a BIT STRING (tag 0x03).

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);
}
Enter fullscreen mode Exit fullscreen mode
  1. The method starts by getting the raw data of the certificate from the X509Certificate2 type.
  2. 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.
  3. Because we are good people, we use a span to reduce allocations.
  4. 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.
  5. 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 _
    );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
djeikyb profile image
jacob

I think when you're slicing the tbs out of the cert sequence, you could subtract length from consumed to get how far you need to backtrack to get the entire tbs byte array?

ReadOnlySpan<byte> certSequence = certSpan.Slice(offset, length);

AsnDecoder.ReadSequence(
    // TBSCertificate  ::=  SEQUENCE  {
    //     version          [0]  EXPLICIT Version DEFAULT v1,
    //     serialNumber          CertificateSerialNumber,
    //     signature             AlgorithmIdentifier,
    //     ...}
    certSequence,
    encodingRules,
    out offset,
    out length,
    out consumed
);

ReadOnlySpan<byte> tbs = certSequence.Slice(offset - backtrack, length + backtrack);
Enter fullscreen mode Exit fullscreen mode

For normal certs (all certs?), I'm pretty sure it's always four. But thinking of asn parsing more broadly, and about why four works and when it wouldn't..