unity-game-enginecryptographystreamingaes

Chunked AES decryption in Unity got "Bad PKCS7 padding. Invalid length 0"


I'm working on a Unity project where I use AES encryption to secure video files on disk and decrypt them later for streaming through an HTTP server. While implementing chunked decryption, I encountered the following error:

CryptographicException: Bad PKCS7 padding. Invalid length 0.

This error occurs during decryption, specifically when I close the CryptoStream manually or when it is disposed after usage. If I surround the close operation with a try/catch, the exception is suppressed, allowing me to generate a decrypted file. However, the resulting file contains artifacts and is thus corrupted.

Code that produces the error

Here is the method that handles decryption by serving video chunks:

public static int ServeVideoChunk(Stream encryptedStream, Stream outputStream, int offset, int chunk)
{
    var position = encryptedStream.Seek(offset, SeekOrigin.Begin);

    using var aes = Aes.Create();
    aes.Key = Key; // Predefined key (128 bits)
    aes.IV = Iv; // Predefined initialization vector (128 bits)

    using var cryptoStream = new CryptoStream(encryptedStream, aes.CreateDecryptor(), CryptoStreamMode.Read);

    // Align to AES block size
    var delta = encryptedStream.Length - position;
    var isEOF = chunk > delta;
    chunk = (int)(isEOF ? delta : chunk);
    chunk -= chunk % (aes.BlockSize / 8);

    var buffer = new byte[chunk];
    var totalReadBytes = cryptoStream.Read(buffer, 0, chunk);

    outputStream.Write(buffer, 0, totalReadBytes);

    // Force close the stream to catch the Bad PKCS7 exception and try reading the file at the end
    try
    {
        cryptoStream.Close();
    }
    catch (Exception e)
    {
        Debug.Log(e);
    }

    return totalReadBytes;
}

The Bad PKCS7 padding exception consistently occurs when closing the CryptoStream. Without the try/catch, the exception is thrown at the end of the method after the using statement for the stream.

Additional Details

Here is how I encrypt the video file. This code works without issues:

public static void EncryptFile(string inputFilePath, string outputFilePath)
{
    Debug.Log($"Encrypt InputPath: {inputFilePath} OutputPath: {outputFilePath}");

    using (var aes = Aes.Create())
    {
        aes.Key = Key;
        aes.IV = Iv;

        using (var fileStream = new FileStream(outputFilePath, FileMode.Create))
        using (var cryptoStream = new CryptoStream(fileStream, aes.CreateEncryptor(), CryptoStreamMode.Write))
        using (var inputFileStream = new FileStream(inputFilePath, FileMode.Open))
        {
            inputFileStream.CopyTo(cryptoStream);
        }
    }

    Debug.Log("Encryption complete.");
}

If I decrypt the entire file at once without chunking, I encounter no issues. The decryption completes successfully, and there are no artifacts in the resulting video:

public static void DecryptFile(Stream encryptedStream, Stream outputStream)
{
    using var aes = Aes.Create();
    aes.Key = Key;
    aes.IV = Iv;

    using var cryptoStream = new CryptoStream(encryptedStream, aes.CreateDecryptor(), CryptoStreamMode.Read);
    cryptoStream.CopyTo(outputStream);
}

However, the above approach does not allow partial reads, and I need chunked decryption for serving videos via an HTTP server.

Simulated HTTP Client

To simulate the chunked reading and decryption process, I used the following test:

private void Start()
{
    var videoPath = UseShortVideo ? ShortVideoPath : VideoPath;
    var encryptedVideoPath = UseShortVideo ? ShortEncryptedVideoPath : EncryptedVideoPath;

    if (Encrypt)
    {
        VideoEncryption.EncryptFile(videoPath, encryptedVideoPath);
    }

    var encryptedVideoFile = new FileInfo(encryptedVideoPath);

    using var outputStream = new FileStream($"{encryptedVideoFile.Directory.FullName}/{DecryptedMovieName}",
        FileMode.Create, FileAccess.Write);

    DecryptByChunk(encryptedVideoPath, outputStream);
}

private static void DecryptByChunk(string inputPath, Stream outputStream)
{
    var offset = 0;
    var chunk = 2 * 1024 * 1024;
    long fileSize;
    do
    {
        using var encryptedStream = new FileStream(inputPath, FileMode.Open, FileAccess.Read);
        fileSize = encryptedStream.Length;

        int totalBytesRead = VideoEncryption.ServeVideoChunk(encryptedStream, outputStream, offset, chunk);

        offset += totalBytesRead;

        if (totalBytesRead == 0)
        {
            Debug.Log($"End of file i: {offset} | total: {fileSize}");
            break;
        }

        Debug.Log($"Offset: {offset} | total: {fileSize}");
    } while (offset < fileSize);
}

What I've Tried

  1. Verified that Key and IV are consistent between encryption and decryption.
  2. Checked that the length of the encrypted stream is a multiple of the AES block size (16 bytes for AES-128).
  3. Attempted to suppress the exception with try/catch, which allows me to produce a decrypted file, but it contains visible artifacts and is corrupted.

Question

How can I reliably decrypt AES-encrypted data in chunks without triggering this exception or corrupting the output?


EDIT 28/03/2025

Solved thanks to @Topaco's comments.


Solution

  • Solved thanks to @Topaco's comments. Here is the updated code with padding disabled for intermediate chunks and previous block IV usage.

    public static int ServeVideoChunk(Stream encryptedStream, Stream outputStream, long offset, int chunk)
    {
        using var aes = Aes.Create();
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;
        aes.Key = Key; // Predefined key (128 bits)
        var blockSize = aes.BlockSize / 8; // 16 bytes
    
    
        // Seek one block before the offset if possible to get the IV
        var seekPosition = Math.Max(0, offset - blockSize);
        encryptedStream.Seek(seekPosition, SeekOrigin.Begin);
    
        if (seekPosition <= 0)
        {
            aes.IV = Iv; // Predefined initialization vector (128 bits)
        }
        else
        {
            var iv = new byte[blockSize];
            encryptedStream.Read(iv, 0, blockSize);
            aes.IV = iv;
        }
    
        // Remaining ciphertext to decrypt
        var delta = encryptedStream.Length - offset;
    
        // EOF
        if (chunk > delta)
        {
            // The delta is supposed to already be block aligned
            chunk = (int)delta;
        }
        else
        {
            // Disable padding for intermediate chunks
            aes.Padding = PaddingMode.None;
        }
    
        using var cryptoStream = new CryptoStream(encryptedStream, aes.CreateDecryptor(), CryptoStreamMode.Read);
    
        var buffer = new byte[chunk];
        cryptoStream.Read(buffer, 0, chunk);
        outputStream.Write(buffer, 0, chunk);
    
        return chunk;
    }