How To Manually Verify x509 Certificate Chains in .NET
Tags: csharp, dotnet, security
Published: 2023-01-10 11:38:58

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](https://www.bouncycastle.org/csharp/index.html)", 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](https://www.rfc-editor.org/rfc/rfc3280#section-4.1), the certificate is a [ASN.1 encoded](https://www.itu.int/ITU-T/studygroups/com17/languages/X.691-0207.pdf) 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](https://en.wikipedia.org/wiki/ASN.1)).

The standard includes different standards of encoding rules, such as [BER](https://en.wikipedia.org/wiki/Basic_Encoding_Rules), [DER](https://en.wikipedia.org/wiki/Distinguished_Encoding_Rules), [CER](https://en.wikipedia.org/wiki/Canonical_Encoding_Rules), 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
}
```

1. The data is nested inside a **SEQUENCE** type (tag `0x10`)
1. In order to validate the certificate, we don't need to decode the `tbsCertificate`, we just need to extract the bytes from the sequence.
1. The `signatureAlgorithm` is another sequence from which we want to extract the `algorithm` OID to know which algorithm to use.
1. Finally the `signatureValue` should not even be in a sequence and is just a BIT STRING (tag `0x03`).

Fortunately .NET has [AsnDecoder](https://learn.microsoft.com/en-us/dotnet/api/system.formats.asn1.asndecoder?view=net-7.0) 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.

``` c#
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);
}
```

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](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509certificate2.signaturealgorithm?view=net-7.0) on the `X509Certificfate2` type to obtain the signature algorithm.

The `SignatureAlgorithm` property returns the OID for the algorithm. Microsoft has [a table](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnap/a48b02b2-2a10-4eb0-bed4-1807a6d2f5ad) 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.

``` c#
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:

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.
1. 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.
1. 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.

``` c#
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

``` c#
if (!myCertificate.IsSignedBy(rootCertificate)
{
    throw new InvalidCertificateException();
}
```