Search code examples
c#encryptioncryptographybouncycastle

C# BouncyCastle AES encryption and decryption + hash padding secret


I am trying to use the Bouncy Castle C# library to encrypt and decrypt a string + pad the secret to 236 by hashing it using SHA256 and I am failing. More specifically, I can't get the original text back after encryption and then decryption. I appreciate any help or hint.

static string Process(bool encrypt, string keyString, string input)
{
    // We undo replacing of the 2 characters from Base64 which where not URL safe 
    if (!encrypt)
    {
        input = input.Replace("-", "+").Replace("_", "/");
    }

    // Get UTF8 byte array of input string for encryption
    var inputBytes = Encoding.UTF8.GetBytes(input);

    // Again, get UTF8 byte array of key for use in encryption
    var keyBytes = Encoding.UTF8.GetBytes(keyString);

    // Padding the key to 256
    var myHash = new Sha256Digest();
    myHash.BlockUpdate(keyBytes, 0, keyBytes.Length);
    var keyBytesPadded = new byte[myHash.GetDigestSize()];
    myHash.DoFinal(keyBytesPadded, 0);

    // Initialize AES CTR (counter) mode cipher from the BouncyCastle cryptography library
    var cipher = CipherUtilities.GetCipher("AES/CTR/NoPadding");

    cipher.Init(true, new ParametersWithIV(ParameterUtilities.CreateKeyParameter("AES", keyBytesPadded), new byte[16]));

    // As this is a stream cipher, you can process bytes chunk by chunk until complete, then close with DoFinal.
    // In our case we don't need a stream, so we simply call DoFinal() to encrypt the entire input at once.
    var encryptedBytes = cipher.DoFinal(inputBytes);

    // The encryption is complete, however we still need to get the encrypted byte array into a useful form for passing as a URL parameter
    // First, we convert the encrypted byte array to a Base64 string to make it use ASCII characters
    var base64EncryptedOutputString = Convert.ToBase64String(encryptedBytes);

    // Lastly, we replace the 2 characters from Base64 which are not URL safe ( + and / ) with ( - and _ ) as recommended in IETF RFC4648
    var urlEncodedBase64EncryptedOutputString = base64EncryptedOutputString;

    if (encrypt)
    {
        urlEncodedBase64EncryptedOutputString =
            urlEncodedBase64EncryptedOutputString.Replace("+", "-").Replace("/", "_");
    }

    // This final string is now safe to be passed around, into our web service by URL, etc.
    return urlEncodedBase64EncryptedOutputString;
}

static string Encrypt(string keyString, string input)
{
    return Process(true, keyString, input);
}

static string Decrypt(string keyString, string input)
{
    return Process(false, keyString, input);
}

const string key = "supersecret";
const string message = "hello world!";

Decrypt(key, Encrypt(key, message)).ShouldBe(message); // False!

Repository


Solution

  • The problem is caused by an inconsistent encoding and decoding.

    Usually during encryption the plaintext is UTF-8 encoded and the ciphertext Base64(url) encoded. During decryption it is the other way around, the ciphertext is Base64(url) decoded and the decrypted data is UTF-8 decoded.

    In the posted code, the Process() method is used for both encryption and decryption, and here the input and output encoding is inconsistently implemented:
    The input data is always UTF-8 encoded, which is incorrect in the case of the ciphertext. The ciphertext must be Base64(url) decoded.
    The output data is always Base64(url) encoded, which is incorrect in the case of the decrypted data. The decrypted data must be UTF-8 decoded.

    Since the input and output encodings depend on whether encrypting or decrypting, it makes the most sense to implement the encoding/decoding in Encrypt()/Decrypt(). It can also be implemented in Process(), but this would lead to corresponding if/else statements.

    Example for an implementation of the encoding/decoding in Encrypt()/Decrypt():

    static byte[] Process(bool encrypt, string keyMaterial, byte[] input)
    {
        // Keyderivation via SHA256
        var keyMaterialBytes = Encoding.UTF8.GetBytes(keyMaterial);
        var digest = new Sha256Digest();
        digest.BlockUpdate(keyMaterialBytes, 0, keyMaterialBytes.Length);
        var keyBytes = new byte[digest.GetDigestSize()];
        digest.DoFinal(keyBytes, 0);
    
        // Encryption/Decryption with AES-CTR using a static IV
        var cipher = CipherUtilities.GetCipher("AES/CTR/NoPadding");
        cipher.Init(encrypt, new ParametersWithIV(ParameterUtilities.CreateKeyParameter("AES", keyBytes), new byte[16]));
        return cipher.DoFinal(input);
    }
    
    static string Encrypt(string keyMaterial, string plaintext)
    {
        var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);                             // UTF-8 encode
        var ciphertextBytes = Process(true, keyMaterial, plaintextBytes);
        return Convert.ToBase64String(ciphertextBytes).Replace("+", "-").Replace("/", "_"); // Base64url encode
    }
    
    static string Decrypt(string keyMaterial, string ciphertext)
    {
        var ciphertextBytes = Convert.FromBase64String(ciphertext.Replace("-", "+").Replace("_", "/")); // Base64url decode
        var decryptedBytes = Process(false, keyMaterial, ciphertextBytes);
        return Encoding.UTF8.GetString(decryptedBytes);                                                 // UTF-8 decode
    }
    

    Note that the code has vulnerabilities:

    • a static IV is applied. This is especially serious in conjunction with CTR, s. here. Instead, a random IV should be generated during encryption and passed to the decrypting side along with the ciphertext, typically concatenated (the IV is not a secret).
    • a fast digest as key derivation is used. Instead a reliable key derivation function such as Argon2 or at least PBKDF2 should be applied.

    Base64url is often used without padding characters. If this is of interest to you, s. here.