Supporting Encrypted Content-Encoding in HttpClient - Replacing Bouncy Castle With .NET Core

More than three years ago I've written about supporting Encrypted Content-Encoding in HttpClient. Back then I've used Bouncy Castle for AES GCM encryption and decryption. It was a logical choice as Bouncy Castle was, and in many cases still is, the go-to library for many cryptographic algorithms and protocols. But time has passed and .NET has been growing. With the release of .NET Core 3.0, we have been given built-in support for AES GCM and I've decided to replace Bouncy Castle with it.

Encrypting

AES GCM encryption with Bouncy Castle has three steps: configuration, processing, and finalization. In the configuration step, one needs to provide key and nonce (I will not describe specifics of generating nonce according to Encrypted Content-Encoding specification here, as I did it in encoding post). The processing step is about feeding the configured cipher instance with plaintext bytes which results in filling ciphertext buffer. In the finalization step, the cipher will generate an authentication tag into the ciphertext buffer. The below code illustrates those steps.

internal class Aes128GcmCipher : IDisposable
{
    ...

    public int Encrypt(byte[] plainText, int plainTextLength, byte[] cipherTextBuffer, ulong recordSequenceNumber)
    {
        ConfigureAes128GcmCipher(_aes128GcmCipher, true, _key, _nonceInfoParameterHash, recordSequenceNumber);

        return Aes128GcmCipherProcessBytes(_aes128GcmCipher, plainText, plainTextLength, cipherTextBuffer);
    }

    private static void ConfigureAes128GcmCipher(GcmBlockCipher aes128GcmCipher, bool forEncryption,
        KeyParameter key, byte[] nonceInfoParameterHash, ulong recordSequenceNumber)
    {
        aes128GcmCipher.Reset();
        AeadParameters aes128GcmParameters = new AeadParameters(key, 128,
            Aes128GcmHelper.XorNonce(nonceInfoParameterHash, recordSequenceNumber));
        aes128GcmCipher.Init(forEncryption, aes128GcmParameters);
    }

    private static int Aes128GcmCipherProcessBytes(GcmBlockCipher aes128GcmCipher, byte[] bytesToProcess,
        int bytesToProcessLength, byte[] processedBytesBuffer)
    {
        int processBytesCount = aes128GcmCipher.ProcessBytes(bytesToProcess, 0, bytesToProcessLength,
            processedBytesBuffer, 0);
        int doFinalBytesCount = aes128GcmCipher.DoFinal(processedBytesBuffer, processBytesCount);

        return processBytesCount + doFinalBytesCount;
    }

    ...
}

The .NET Core implementation of AES GCM has a little bit of different flow. It uses the fact that it is common to perform multiple operations with the same key, just providing unique values for the nonce. So the key is provided in cipher constructor and the encryption is just a single method call. The .NET Core implementation also has a different approach to authentication tag - it will generate it into a separate buffer, not the one for ciphertext. This means that there are potentially multiple buffers required. Thankfully the implementation supports Span. This means that a single buffer can be used by pointing to the right parts of it. Offset calculation is not a problem because, in the case of AES GCM, ciphertext always has the same length as plaintext, and authentication tag length is constant (the same as key length). This way implementation is simpler than with Bouncy Castle.

internal class Aes128GcmCipher : IDisposable
{
    ...

    public int Encrypt(byte[] plainText, int plainTextLength, byte[] cipherTextBuffer, ulong recordSequenceNumber)
    {
        Span cipherTextBufferSpan = cipherTextBuffer.AsSpan();

        _aesGcmCipher.Encrypt(
            Aes128GcmHelper.XorNonce(_nonceInfoParameterHash, recordSequenceNumber).AsSpan(),
            plainText.AsSpan().Slice(0, plainTextLength),
            cipherTextBufferSpan.Slice(0, plainTextLength),
            cipherTextBufferSpan.Slice(plainTextLength, Aes128GcmHelper.CONTENT_ENCRYPTION_KEY_LENGTH)
            );

        return plainTextLength + Aes128GcmHelper.CONTENT_ENCRYPTION_KEY_LENGTH;
    }

    ...
}

Decrypting

In general, decryption is an opposite process to encryption (yes I'm stating the obvious here). As a result, usually, there are symmetric APIs for encryption and decryption. This is not a case when it comes to Bouncy Castle. In the case of Bouncy Castle, the difference is just single boolean value.

internal class Aes128GcmCipher : IDisposable
{
    ...

    public int Decrypt(byte[] cipherText, int cipherTextLength, byte[] plainTextBuffer, ulong recordSequenceNumber)
    {
        ConfigureAes128GcmCipher(_aes128GcmCipher, false, _key, _nonceInfoParameterHash, recordSequenceNumber);

        return Aes128GcmCipherProcessBytes(_aes128GcmCipher, cipherText, cipherTextLength, plainTextBuffer);
    }

    private static void ConfigureAes128GcmCipher(GcmBlockCipher aes128GcmCipher, bool forEncryption,
        KeyParameter key, byte[] nonceInfoParameterHash, ulong recordSequenceNumber)
    {
        aes128GcmCipher.Reset();
        AeadParameters aes128GcmParameters = new AeadParameters(key, 128,
            Aes128GcmHelper.XorNonce(nonceInfoParameterHash, recordSequenceNumber));
        aes128GcmCipher.Init(forEncryption, aes128GcmParameters);
    }

    private static int Aes128GcmCipherProcessBytes(GcmBlockCipher aes128GcmCipher, byte[] bytesToProcess,
        int bytesToProcessLength, byte[] processedBytesBuffer)
    {
        int processBytesCount = aes128GcmCipher.ProcessBytes(bytesToProcess, 0, bytesToProcessLength,
            processedBytesBuffer, 0);
        int doFinalBytesCount = aes128GcmCipher.DoFinal(processedBytesBuffer, processBytesCount);

        return processBytesCount + doFinalBytesCount;
    }

    ...
}

In the case of .NET Core implementation, things are more typical. There is a Decrypt method which takes nonce, ciphertext, authentication tag, and plaintext buffer. With the help of spans, usage is again very clean.

internal class Aes128GcmCipher : IDisposable
{
    ...

    public int Decrypt(byte[] cipherText, int cipherTextLength, byte[] plainTextBuffer, ulong recordSequenceNumber)
    {
        int textLength = cipherTextLength - Aes128GcmHelper.CONTENT_ENCRYPTION_KEY_LENGTH;

        Span cipherTextSpan = cipherText.AsSpan();

        _aesGcmCipher.Decrypt(
            Aes128GcmHelper.XorNonce(_nonceInfoParameterHash, recordSequenceNumber).AsSpan(),
            cipherTextSpan.Slice(0, textLength),
            cipherTextSpan.Slice(textLength, Aes128GcmHelper.CONTENT_ENCRYPTION_KEY_LENGTH),
            plainTextBuffer.AsSpan().Slice(0, textLength)
            );

        return textLength;
    }

    ...
}

Performance

The gains I've achieved from the replacement were fewer dependencies and cleaner code (well the last one is subjective). But there is one more thing .NET Core promise to deliver - performance. So I've decided to run some benchmarks for various data sets.

First, I've benchmarked the Bouncy Castle implementation running on .NET Core 3.1.

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
EncodeSingleRecordAsync 127.4 μs 1.45 μs 1.21 μs 15.8691 - - 48.21 KB
EncodeMultipleRecordsAsync 977.3 μs 14.60 μs 14.34 μs 80.0781 - - 244.8 KB
DecodeSingleRecordAsync 136.1 μs 2.64 μs 3.61 μs 16.1133 - - 49.06 KB
DecodeMultipleRecordsAsync 988.3 μs 19.26 μs 21.41 μs 78.1250 - - 245.38 KB

Second, I've benchmarked the "native" .NET Core 3.1 implementation.

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
EncodeSingleRecordAsync 8.183 μs 0.1584 μs 0.1760 μs 0.3967 - - 1.26 KB
EncodeMultipleRecordsAsync 22.848 μs 0.4465 μs 0.4586 μs 0.7629 - - 2.38 KB
DecodeSingleRecordAsync 8.320 μs 0.1590 μs 0.1487 μs 0.6561 - - 2.05 KB
DecodeMultipleRecordsAsync 22.311 μs 0.4149 μs 0.3881 μs 0.7629 - - 2.41 KB

Clearly .NET Core delivers on its promise. And I guess that is the most (probably only) important note in this post. The .NET (Core) is growing fast and it's worth watching new APIs being added as migrating to them might be really worth it.