.NET's Cryptographic One-Shots
Over the past few releases in .NET, formerly .NET Core, there has been progress on making cryptographic primitives like AES, SHA, etc. better for developers to use.
“Better” is an interesting point of conversation with cryptographic API design. To a developer, better may mean more throughput, less allocations, or a simply less cumbersome API. To a framework or library author, it means thinking about how developers will use, or mis-use, an API.
Let’s look at AES encryption as it was in the .NET Framework days:
using System.Security.Cryptography;
byte[] data = default;
using (Aes aes = Aes.Create())
{
byte[] key = default; // Get a key from somewhere
byte[] iv = default; // Get a unique IV from somewhere
using ICryptoTransform transform = aes.CreateEncryptor(key, iv);
byte[] encrypted = transform.TransformFinalBlock(data, 0, data.Length);
}
This may only be a dozen or so lines of a code, but not all of it is straight forward. What is a “transform”? What is a “block”? What does “final” mean?
The APIs expose quite a bit of functionality, and are confusingly
named. TransformFinalBlock
for example, despite having “Block” in it’s name,
is almost always going to be capable of encrypting more than one block. It also
means the data doesn’t need to be block aligned, so it handles padding
appropriately. Since none of that may be understood, developers often times work
around perceived problems, like handling individual blocks. While this API design
offers the most flexibility for developers, it also offers the most complexity.
This complexity exists for a small group of people. Most developers have some
small amount of data they want to encrypt, and when they don’t, they have a Stream
.
Complex APIs that are prone to misuse are problematic in a security context. A misused cryptographic API almost always harms intended goal of the cryptographic API, whether that be secrecy, integrity, etc.
For .NET, this became a concern when AES GCM and CCM were exposed in .NET Core 3.1.
The AesGcm
and AesCcm
classes do not follow the design of AES prior. In addition
to not inheriting from Aes
or SymmetricAlgorithm
, they also do not expose any
block functionality. This was discussed at length, and instead these types
offer simple Encrypt
or Decrypt
APIs that takes data and return data, or write
to an existing buffer.
This, while less flexible, resolves many concerns about misusing the AES GCM cipher mode. Primarily among those concerns was releasing unauthenticated data. Streaming decryption is, put simply, difficult to do safely.
“Streaming” decryption doesn’t necessarily mean the use of System.IO.Stream
.
Rather, it means processing a block of plaintext or ciphertext a block at a time
and doing something with it in the middle of encrypting or decrypting. This is
often perceived as desirable when handling large amounts of data. After all, if
I have a 12 gigabyte file, I can’t just put that in a byte array and encrypt it.
Rather, processing it in chunks lets me handle it in memory.
In pseudo code, let’s say I wanted to decrypt a file and send it over the network:
# NOTE: This is an example of doing things improperly
encryptedFileStream = getFile()
stream = getStream()
loop {
data = encryptedFileStream.read(128 / 8) # One AES block size
if (data.length == 128 / 8) {
stream.write(decrypt(data))
}
else {
stream.write(decryptFinal(data))
stream.close()
break
}
}
Recall though that AES GCM is authenticated. That is, AES GCM can tell if your
ciphertext has been modified while in storage or in transit. An important detail
of this though is that
GCM cannot authenticate until it has processed the entire ciphertext (when
decryptFinal
is called.)
This is breaks down because as we are decrypting, we’re sending (releasing) the plaintext before AES GCM has been able to authenticate the entire cipher text. If the person, tool, whatever on the other end of the network is processing that decrypted data in real time, then they have processed unauthentic data and it’s too late to go back and tell them “Never mind, that data I send you a few seconds ago might have been tampered with.”
There are correct ways to do this, but are also still difficult to do correctly. You could break the file up in to small chunks and treat them as individual ciphertexts. However then you need to worry about many nonces, ensuring chunks are processed in the right order, a chunk isn’t missing, or replayed, etc.
Before long you’ve invented a cryptographic protocol. This is largely why many folks will recommend using something that is well understood and robust rather than trying to build it yourself. Though not a primitive cipher, this kind of problem falls in the “roll your own cryptography” bucket.
It’s rather easy to accidentally roll your own cryptography, especially so when working with “streaming” data.
In .NET then, AES GCM and CCM do not support encrypting individual blocks. This still does not solve the issue of ensuring chunks are handled appropriately when handling large amounts of data. For that, higher level tools are still recommended. However it removes the temptation for streaming AES GCM, for which any attempt to use is almost always incorrect. Since it is very difficult to use correctly with no practical use cases, it isn’t offered.
Toward Better APIs
Simple APIs are important to making them misuse resistent, and .NET has gotten better at that over the past few releases.
Like AesGcm
, the SymmetricAlgorithm
and its derivatives like Aes
,
TripleDES
, etc. all offer similar one-shot APIs starting in .NET 6 in the form
of EncryptCbc
, EncryptEcb
or DecryptCbc
and DecryptEcb
.
using System.Security.Cryptography;
byte[] data = default;
using (Aes aes = Aes.Create())
{
byte[] key = default; // Get a key from somewhere
byte[] iv = default; // Get a unique IV from somewhere
aes.Key = key;
// Encrypt all the data at once
byte[] encrypted = aes.EncryptCbc(data, iv);
}
There is no ICryptoTransform
that needs to be reasoned about or disposed, and
there is no need to worry about blocks, padding, etc. Where possible, one shots
have been added in most places, and made static where possible.
For hashing, prior to .NET 5 it would look something like this:
// Prior to .NET 5
using System.Security.Cryptography;
byte[] data = default; // Some data
using (SHA256 hash = SHA256.Create())
{
byte[] digest = hash.ComputeHash(data);
}
Rather than creating an instance of a hash algorithm, HashData
now exists:
// Starting in .NET 5
using System.Security.Cryptography;
byte[] data = default; // Some data
byte[] digest = SHA256.HashData(data);
This is much easier to reason about. The method is static, there is no stateful
hash object that needs to be instantiated, no need to remember to dispose of it,
and no need to worry about thread safety. Not only are the one shots easier to
use, they almost always offer better performance, either in throughput or
reduced allocations. These one shots are not simple wrappers around
HashAlgorithm.Create()
and then hashing something. They internally do not
allocate on the managed heap at all. Everyone benefits here: the APIs are
simpler, and developers get better performance.
For .NET 6, the one shot hashing APIs were brought to the HMAC
classes as well,
offering the same improved APIs and better performance.
Also for .NET 6 PBKDF2 got the same treatment with Rfc2898DeriveBytes.Pbkdf2
.
using System.Security.Cryptography;
byte[] salt = RandomNumberGenerator.GetBytes(32);
byte[] prk = Rfc2898DeriveBytes.Pbkdf2(
userPassword,
salt,
iterations: 200_000,
HashAlgorithmName.SHA256,
outputLength: 32);
All of these APIs also offer modern amenities, like working ReadOnlySpan<byte>
for input data and being able to write to a Span<byte>
for output data.
I’m happy with .NETs move toward easier to use APIs for cryptographic primitives. I still largely believe many developers should use higher level concepts rather than these basic building blocks. However, for those that need the building blocks, they are getting better.