46

I'm using a string Encryption/Decryption class similar to the one provided here as a solution.

This worked well for me in .Net 5.
Now I wanted to update my project to .Net 6.

When using .Net 6, the decrypted string does get cut off a certain point depending on the length of the input string.

▶️ To make it easy to debug/reproduce my issue, I created a public repro Repository here.

  • The encryption code is on purpose in a Standard 2.0 Project.
  • Referencing this project are both a .Net 6 as well as a .Net 5 Console project.

Both are calling the encryption methods with the exact same input of "12345678901234567890" with the path phrase of "nzv86ri4H2qYHqc&m6rL".

.Net 5 output: "12345678901234567890"
.Net 6 output: "1234567890123456"

The difference in length is 4.

I also looked at the breaking changes for .Net 6, but could not find something which guided me to a solution.

I'm glad for any suggestions regarding my issue, thanks!

Encryption Class

public static class StringCipher
{
    // This constant is used to determine the keysize of the encryption algorithm in bits.
    // We divide this by 8 within the code below to get the equivalent number of bytes.
    private const int Keysize = 128;

    // This constant determines the number of iterations for the password bytes generation function.
    private const int DerivationIterations = 1000;

    public static string Encrypt(string plainText, string passPhrase)
    {
        // Salt and IV is randomly generated each time, but is preprended to encrypted cipher text
        // so that the same Salt and IV values can be used when decrypting.  
        var saltStringBytes = Generate128BitsOfRandomEntropy();
        var ivStringBytes = Generate128BitsOfRandomEntropy();
        var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
        using (var password = new Rfc2898DeriveBytes(passPhrase, saltStringBytes, DerivationIterations))
        {
            var keyBytes = password.GetBytes(Keysize / 8);
            using (var symmetricKey = Aes.Create())
            {
                symmetricKey.BlockSize = 128;
                symmetricKey.Mode = CipherMode.CBC;
                symmetricKey.Padding = PaddingMode.PKCS7;
                using (var encryptor = symmetricKey.CreateEncryptor(keyBytes, ivStringBytes))
                {
                    using (var memoryStream = new MemoryStream())
                    {
                        using (var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write))
                        {
                            cryptoStream.Write(plainTextBytes, 0, plainTextBytes.Length);
                            cryptoStream.FlushFinalBlock();
                            // Create the final bytes as a concatenation of the random salt bytes, the random iv bytes and the cipher bytes.
                            var cipherTextBytes = saltStringBytes;
                            cipherTextBytes = cipherTextBytes.Concat(ivStringBytes).ToArray();
                            cipherTextBytes = cipherTextBytes.Concat(memoryStream.ToArray()).ToArray();
                            memoryStream.Close();
                            cryptoStream.Close();
                            return Convert.ToBase64String(cipherTextBytes);
                        }
                    }
                }
            }
        }
    }

    public static string Decrypt(string cipherText, string passPhrase)
    {
        // Get the complete stream of bytes that represent:
        // [32 bytes of Salt] + [16 bytes of IV] + [n bytes of CipherText]
        var cipherTextBytesWithSaltAndIv = Convert.FromBase64String(cipherText);
        // Get the saltbytes by extracting the first 16 bytes from the supplied cipherText bytes.
        var saltStringBytes = cipherTextBytesWithSaltAndIv.Take(Keysize / 8).ToArray();
        // Get the IV bytes by extracting the next 16 bytes from the supplied cipherText bytes.
        var ivStringBytes = cipherTextBytesWithSaltAndIv.Skip(Keysize / 8).Take(Keysize / 8).ToArray();
        // Get the actual cipher text bytes by removing the first 64 bytes from the cipherText string.
        var cipherTextBytes = cipherTextBytesWithSaltAndIv.Skip((Keysize / 8) * 2).Take(cipherTextBytesWithSaltAndIv.Length - ((Keysize / 8) * 2)).ToArray();

        using (var password = new Rfc2898DeriveBytes(passPhrase, saltStringBytes, DerivationIterations))
        {
            var keyBytes = password.GetBytes(Keysize / 8);
            using (var symmetricKey = Aes.Create())
            {
                symmetricKey.BlockSize = 128;
                symmetricKey.Mode = CipherMode.CBC;
                symmetricKey.Padding = PaddingMode.PKCS7;
                using (var decryptor = symmetricKey.CreateDecryptor(keyBytes, ivStringBytes))
                {
                    using (var memoryStream = new MemoryStream(cipherTextBytes))
                    {
                        using (var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read))
                        {
                            var plainTextBytes = new byte[cipherTextBytes.Length];
                            var decryptedByteCount = cryptoStream.Read(plainTextBytes, 0, plainTextBytes.Length);
                            memoryStream.Close();
                            cryptoStream.Close();
                            return Encoding.UTF8.GetString(plainTextBytes, 0, decryptedByteCount);
                        }
                    }
                }
            }
        }
    }

    private static byte[] Generate128BitsOfRandomEntropy()
    {
        var randomBytes = new byte[16]; // 16 Bytes will give us 128 bits.
        using (var rngCsp = RandomNumberGenerator.Create())
        {
            // Fill the array with cryptographically secure random bytes.
            rngCsp.GetBytes(randomBytes);
        }
        return randomBytes;
    }
}

Calling code

var input = "12345678901234567890";
var inputLength = input.Length;
var inputBytes = Encoding.UTF8.GetBytes(input);

var encrypted = StringCipher.Encrypt(input, "nzv86ri4H2qYHqc&m6rL");

var output = StringCipher.Decrypt(encrypted, "nzv86ri4H2qYHqc&m6rL");
var outputLength = output.Length;
var outputBytes = Encoding.UTF8.GetBytes(output);

var lengthDiff = inputLength - outputLength;
Optim
  • 523
  • 1
  • 5
  • 9
  • Thanks for the repro, but noone's going to search through an external repository. Can you post the relevant code in your question? – canton7 Nov 10 '21 at 10:02
  • @canton7 I added code to the question. The repo is a very simple repro of the issue. Let me know if the Code works for you. – Optim Nov 10 '21 at 10:12
  • Perhaps it's more efficient to create an issue in the .NET repo? Either the dev close it after explaining what went wrong, or turns out you found out something they missed. – Martheen Nov 10 '21 at 10:17
  • I can't spot anything obviously wrong. Have you debugged it? Is the contents of `cipherTextBytes` the same in `Encrypt` and `Decrypt` for instance? Does `plainTextBytes` have the expected length? – canton7 Nov 10 '21 at 10:17

4 Answers4

64

The reason is this breaking change:

DeflateStream, GZipStream, and CryptoStream diverged from typical Stream.Read and Stream.ReadAsync behavior in two ways:

They didn't complete the read operation until either the buffer passed to the read operation was completely filled or the end of the stream was reached.

And the new behaviour is:

Starting in .NET 6, when Stream.Read or Stream.ReadAsync is called on one of the affected stream types with a buffer of length N, the operation completes when:

At least one byte has been read from the stream, or The underlying stream they wrap returns 0 from a call to its read, indicating no more data is available.

In your case you are affected because of this code in Decrypt method:

using (var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read))
{
    var plainTextBytes = new byte[cipherTextBytes.Length];
    var decryptedByteCount = cryptoStream.Read(plainTextBytes, 0, plainTextBytes.Length);
    memoryStream.Close();
    cryptoStream.Close();
    return Encoding.UTF8.GetString(plainTextBytes, 0, decryptedByteCount);
}

You do not check how much bytes Read actually read and whether it read them all. You could get away with this in previous versions of .NET because as mentioned CryptoStream behaviour was different from other streams, and because your buffer length is enough to hold all data. However, this is no longer the case and you need to check it as you would do for other streams. Or even better - just use CopyTo:

using (var plainTextStream = new MemoryStream())
{
    cryptoStream.CopyTo(plainTextStream);
    var plainTextBytes = plainTextStream.ToArray();
    return Encoding.UTF8.GetString(plainTextBytes, 0, plainTextBytes.Length);
} 

Or even better as another answer suggests, since you decrypt UTF8 text:

using (var plainTextReader = new StreamReader(cryptoStream))
{
    return plainTextReader.ReadToEnd();
}  
Evk
  • 98,527
  • 8
  • 141
  • 191
21

I think your problem is in here:

var decryptedByteCount = cryptoStream.Read(plainTextBytes, 0, plainTextBytes.Length);

From the Stream.Read docs:

An implementation is free to return fewer bytes than requested even if the end of the stream has not been reached.

So that single call to Read is not guaranteed to read all available bytes (up to plainTextBytes.Length) -- it's well within its rights to read a smaller number of bytes.

.NET 6 has many performance improvements, and I wouldn't be surprised if this was the sort of trade-off they'd make in the name of performance.

You'll have to be good, and keep calling Read until it returns 0, which indicates that there's no more data to return.

However, it's a lot easier to just use a StreamReader, which will also take care of the UTF-8 decoding for you.

return new StreamReader(cryptoStream).ReadToEnd();
canton7
  • 37,633
  • 3
  • 64
  • 77
3

I use these 2 extension methods in my .net6 project.

namespace WebApi.Utilities;

public static class StringUtil
{
    static string key = "Mohammad-Komaei@Encrypt!keY#";


    public static string Encrypt(this string text)
    {
        if (string.IsNullOrEmpty(key))
            throw new ArgumentException("Key must have valid value.", nameof(key));
        if (string.IsNullOrEmpty(text))
            throw new ArgumentException("The text must have valid value.", nameof(text));

        var buffer = Encoding.UTF8.GetBytes(text);
        var hash = SHA512.Create();
        var aesKey = new byte[24];
        Buffer.BlockCopy(hash.ComputeHash(Encoding.UTF8.GetBytes(key)), 0, aesKey, 0, 24);

        using (var aes = Aes.Create())
        {
            if (aes == null)
                throw new ArgumentException("Parameter must not be null.", nameof(aes));

            aes.Key = aesKey;

            using (var encryptor = aes.CreateEncryptor(aes.Key, aes.IV))
            using (var resultStream = new MemoryStream())
            {
                using (var aesStream = new CryptoStream(resultStream, encryptor, CryptoStreamMode.Write))
                using (var plainStream = new MemoryStream(buffer))
                {
                    plainStream.CopyTo(aesStream);
                }

                var result = resultStream.ToArray();
                var combined = new byte[aes.IV.Length + result.Length];
                Array.ConstrainedCopy(aes.IV, 0, combined, 0, aes.IV.Length);
                Array.ConstrainedCopy(result, 0, combined, aes.IV.Length, result.Length);

                return Convert.ToBase64String(combined);
            }
        }
    }


    public static string Decrypt(this string encryptedText)
    {
        if (string.IsNullOrEmpty(key))
            throw new ArgumentException("Key must have valid value.", nameof(key));
        if (string.IsNullOrEmpty(encryptedText))
            throw new ArgumentException("The encrypted text must have valid value.", nameof(encryptedText));

        var combined = Convert.FromBase64String(encryptedText);
        var buffer = new byte[combined.Length];
        var hash = SHA512.Create();
        var aesKey = new byte[24];
        Buffer.BlockCopy(hash.ComputeHash(Encoding.UTF8.GetBytes(key)), 0, aesKey, 0, 24);

        using (var aes = Aes.Create())
        {
            if (aes == null)
                throw new ArgumentException("Parameter must not be null.", nameof(aes));

            aes.Key = aesKey;

            var iv = new byte[aes.IV.Length];
            var ciphertext = new byte[buffer.Length - iv.Length];

            Array.ConstrainedCopy(combined, 0, iv, 0, iv.Length);
            Array.ConstrainedCopy(combined, iv.Length, ciphertext, 0, ciphertext.Length);

            aes.IV = iv;

            using (var decryptor = aes.CreateDecryptor(aes.Key, aes.IV))
            using (var resultStream = new MemoryStream())
            {
                using (var aesStream = new CryptoStream(resultStream, decryptor, CryptoStreamMode.Write))
                using (var plainStream = new MemoryStream(ciphertext))
                {
                    plainStream.CopyTo(aesStream);
                }

                return Encoding.UTF8.GetString(resultStream.ToArray());
            }
        }
    }
}
M Komaei
  • 7,006
  • 2
  • 28
  • 34
0

After upgrading from .net 2.2 to 6 I was facing exactly the same issue. It does not read entire buffer - mostly reads only upto 16 bytes, so, just break it down in loop upto maximum of 16 bytes.

This code may help:

int totalRead = 0;
int maxRead = 16;
while (totalRead < plainTextBytes.Length)
{
    var countLeft = plainTextBytes.Length - totalRead;
    var count = countLeft < 16 ? countLeft : maxRead;
    int bytesRead = cryptoStream.Read(plainTextBytes, totalRead, count);
    totalRead += bytesRead;
    if (bytesRead == 0) break;
}