Search code examples
javascriptc#node.jsencryptionrijndael

Invalid Key Length in NodeJS, but Valid Key Length in C#


I'm converting Rijndael decryption from C# to NodeJS.

The Key (or Passphrase) used is 13 characters long. The IV used is 17 characters long.
Note: I have no control over the length choice

Below is the Rijndael decryption in C#

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
                    
public class Program
{
    public class CryptoProvider
    {
        private ICryptoTransform encryptor = (ICryptoTransform)null;
        private ICryptoTransform decryptor = (ICryptoTransform)null;
        private int minSaltLen = -1;
        private int maxSaltLen = -1;
        
        public CryptoProvider(string passPhrase, string initVector)
          : this(passPhrase, initVector, -1, -1, -1, (string)null, (string)null, 3)
        {
        }

        public CryptoProvider(
          string passPhrase,
          string initVector,
          int minSaltLen,
          int maxSaltLen,
          int keySize,
          string hashAlgorithm,
          string saltValue,
          int passwordIterations)
        {
            this.minSaltLen = 4; 
            this.maxSaltLen = 8;
            keySize = 256;
            hashAlgorithm = "SHA512";

            byte[] rgbIV = Encoding.ASCII.GetBytes(initVector);
            byte[] rgbSalt = new byte[0];
            byte[] bytes = new PasswordDeriveBytes(passPhrase, rgbSalt, hashAlgorithm, passwordIterations).GetBytes(keySize / 8);

            RijndaelManaged rijndaelManaged = new RijndaelManaged();

            if (rgbIV.Length == 0)
                rijndaelManaged.Mode = CipherMode.ECB;
            else
                rijndaelManaged.Mode = CipherMode.CBC;

            this.encryptor = rijndaelManaged.CreateEncryptor(bytes, rgbIV);
            this.decryptor = rijndaelManaged.CreateDecryptor(bytes, rgbIV);
        }

        public string Decrypt(string cipherText) {
            return this.Decrypt(Convert.FromBase64String(cipherText));
        }
        
        public string Decrypt(byte[] cipherTextBytes) {
            return Encoding.UTF8.GetString(this.DecryptToBytes(cipherTextBytes));
        }

        public byte[] DecryptToBytes(string cipherText) {
            return this.DecryptToBytes(Convert.FromBase64String(cipherText));
        }

        public byte[] DecryptToBytes(byte[] cipherTextBytes)
        {
            int num = 0;
            int sourceIndex = 0;
            MemoryStream memoryStream = new MemoryStream(cipherTextBytes);
            byte[] numArray = new byte[cipherTextBytes.Length];
            lock (this)
            {
                CryptoStream cryptoStream = new CryptoStream((Stream)memoryStream, this.decryptor, CryptoStreamMode.Read);
                num = cryptoStream.Read(numArray, 0, numArray.Length);
                memoryStream.Close();
                cryptoStream.Close();
            }
            if (this.maxSaltLen > 0 && this.maxSaltLen >= this.minSaltLen)
                sourceIndex = (int)numArray[0] & 3 | (int)numArray[1] & 12 | (int)numArray[2] & 48 | (int)numArray[3] & 192;
            byte[] destinationArray = new byte[num - sourceIndex];
            Array.Copy((Array)numArray, sourceIndex, (Array)destinationArray, 0, num - sourceIndex);
            return destinationArray;
        }
    }
    
    public static void Main()
        {
            string Key = "";
            string IV = "";

            string encryptedUserData = "u7uENpFfpQhMXiTThL/ajA==";
            string decryptedUserData;

            CryptoProvider crypto = new CryptoProvider(Key, IV);
            decryptedUserData = crypto.Decrypt(encryptedUserData.Trim());

            Console.WriteLine(decryptedUserData);

        }
}

which for some reason, I can decrypt the string in dotnetfiddle, but not in Visual Studio (because it returns an error of 'Specified initialization vector (IV) does not match the block size for this algorithm. (Parameter 'rgbIV')'

Below is my attempt to convert in NodeJS using the rijndael-js library:

const Rijndael = require("rijndael-js");

const key = "";
const iv = "";

const cipher = new Rijndael(key, "cbc");

const ciphertext = "u7uENpFfpQhMXiTThL/ajA==";

const plaintext = Buffer.from(cipher.decrypt(ciphertext, 256, iv));

which returns an error of Unsupported key size: 104 bit

All errors point to the same thing: Invalid Key/IV lengths.

Would there be a work-around where I can force NodeJS to accept the Key and IV as valid lengths? Is there something I am missing, doing incorrectly, or misconfigured?


Edit:

I was able to find a PasswordDeriveBytes implementation for NodeJS and compared the results from C# and they are equal.

I updated my NodeJS implementation (see sandbox) and noticed a few things:

  1. All resulting ciphertexts are the same. I am guessing this stems from salts.
  2. I tried decrypting a ciphertext generated from C#, but there seems to be a few characters to the left of the resulting value. Example: C# Encrypted String: zAqv5w/gwT0sFYXZEx+Awg==, NodeJS Decrypted String: ���&��4423
  3. When I try to decrypt a ciphertext generated in NodeJS in C#, the C# compiler returns an error of System.Security.Cryptography.CryptographicException: Padding is invalid and cannot be removed.

Edit:

C# code (executable with .NET Framework 4.7.2):

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace ProgramEncrypt
{
    public class CryptoProvider
    {
        private ICryptoTransform encryptor = (ICryptoTransform)null;
        private ICryptoTransform decryptor = (ICryptoTransform)null;
        private int minSaltLen = -1;
        private int maxSaltLen = -1;

        public CryptoProvider(string passPhrase, string initVector) : this(passPhrase, initVector, -1, -1, -1, (string)null, (string)null, 3) { }

        public CryptoProvider(
          string passPhrase,
          string initVector,
          int minSaltLen,
          int maxSaltLen,
          int keySize,
          string hashAlgorithm,
          string saltValue,
          int passwordIterations)
        {
            this.minSaltLen = 4;
            this.maxSaltLen = 8;
            keySize = 256;
            hashAlgorithm = "SHA512";

            byte[] rgbIV = Encoding.ASCII.GetBytes(initVector);
            byte[] rgbSalt = new byte[0];
            byte[] bytes = new PasswordDeriveBytes(passPhrase, rgbSalt, hashAlgorithm, passwordIterations).GetBytes(keySize / 8);

            RijndaelManaged rijndaelManaged = new RijndaelManaged();

            if (rgbIV.Length == 0)
                rijndaelManaged.Mode = CipherMode.ECB;
            else
                rijndaelManaged.Mode = CipherMode.CBC;

            this.encryptor = rijndaelManaged.CreateEncryptor(bytes, rgbIV);
            this.decryptor = rijndaelManaged.CreateDecryptor(bytes, rgbIV);
        }

        public string Encrypt(string plainText) => this.Encrypt(Encoding.UTF8.GetBytes(plainText));

        public string Encrypt(byte[] plainTextBytes) => Convert.ToBase64String(this.EncryptToBytes(plainTextBytes));

        public byte[] EncryptToBytes(string plainText) => this.EncryptToBytes(Encoding.UTF8.GetBytes(plainText));

        public byte[] EncryptToBytes(byte[] plainTextBytes)
        {
            byte[] buffer = this.AddSalt(plainTextBytes);
            MemoryStream memoryStream = new MemoryStream();
            lock (this)
            {
                CryptoStream cryptoStream = new CryptoStream((Stream)memoryStream, this.encryptor, CryptoStreamMode.Write);
                cryptoStream.Write(buffer, 0, buffer.Length);
                cryptoStream.FlushFinalBlock();
                byte[] array = memoryStream.ToArray();
                memoryStream.Close();
                cryptoStream.Close();
                return array;
            }
        }

        public string Decrypt(string cipherText) => this.Decrypt(Convert.FromBase64String(cipherText));

        public string Decrypt(byte[] cipherTextBytes) => Encoding.UTF8.GetString(this.DecryptToBytes(cipherTextBytes));

        public byte[] DecryptToBytes(string cipherText) => this.DecryptToBytes(Convert.FromBase64String(cipherText));

        public byte[] DecryptToBytes(byte[] cipherTextBytes)
        {
            int num = 0;
            int sourceIndex = 0;
            MemoryStream memoryStream = new MemoryStream(cipherTextBytes);
            byte[] numArray = new byte[cipherTextBytes.Length];
            lock (this)
            {
                CryptoStream cryptoStream = new CryptoStream((Stream)memoryStream, this.decryptor, CryptoStreamMode.Read);
                num = cryptoStream.Read(numArray, 0, numArray.Length);
                memoryStream.Close();
                cryptoStream.Close();
            }
            if (this.maxSaltLen > 0 && this.maxSaltLen >= this.minSaltLen)
                sourceIndex = (int)numArray[0] & 3 | (int)numArray[1] & 12 | (int)numArray[2] & 48 | (int)numArray[3] & 192;
            byte[] destinationArray = new byte[num - sourceIndex];
            Array.Copy((Array)numArray, sourceIndex, (Array)destinationArray, 0, num - sourceIndex);
            return destinationArray;
        }

        private byte[] AddSalt(byte[] plainTextBytes)
        {
            if (this.maxSaltLen == 0 || this.maxSaltLen < this.minSaltLen)
                return plainTextBytes;
            byte[] salt = this.GenerateSalt();
            byte[] destinationArray = new byte[plainTextBytes.Length + salt.Length];
            Array.Copy((Array)salt, (Array)destinationArray, salt.Length);
            Array.Copy((Array)plainTextBytes, 0, (Array)destinationArray, salt.Length, plainTextBytes.Length);
            return destinationArray;
        }

        private byte[] GenerateSalt()
        {
            int length = this.minSaltLen != this.maxSaltLen ? this.GenerateRandomNumber(this.minSaltLen, this.maxSaltLen) : this.minSaltLen;
            byte[] data = new byte[length];
            new RNGCryptoServiceProvider().GetNonZeroBytes(data);
            data[0] = (byte)((int)data[0] & 252 | length & 3);
            data[1] = (byte)((int)data[1] & 243 | length & 12);
            data[2] = (byte)((int)data[2] & 207 | length & 48);
            data[3] = (byte)((int)data[3] & 63 | length & 192);
            return data;
        }

        private int GenerateRandomNumber(int minValue, int maxValue)
        {
            byte[] data = new byte[4];
            new RNGCryptoServiceProvider().GetBytes(data);
            return new Random(((int)data[0] & (int)sbyte.MaxValue) << 24 | (int)data[1] << 16 | (int)data[2] << 8 | (int)data[3]).Next(minValue, maxValue + 1);
        }

        public static void Main()
        {
            string Key = "HelL!oWoRL3ds";
            string IV = "HElL!o@wOrld!#@%$";

            string toEncrypt = "1234";
            string encryptedData, decryptedData;

            CryptoProvider crypto = new CryptoProvider(Key, IV);
            encryptedData = crypto.Encrypt(toEncrypt.Trim());
            decryptedData = crypto.Decrypt(encryptedData.Trim());

            Console.WriteLine("ENCRYPTED: " + encryptedData);
            Console.WriteLine("DECRYPTED: " + decryptedData);
        }
    }
}

NodeJS code (codesandbox.io):

import { deriveBytesFromPassword } from "./deriveBytesFromPassword";
const Rijndael = require("rijndael-js");

const dataToEncrypt = "1234";

const SECRET_KEY = "HelL!oWoRL3ds"; // 13 chars
const SECRET_IV = "HElL!o@wOrld!#@%$"; // 17 chars

const keySize = 256;
const hashAlgorithm = "SHA512";

// Use only the first 16 bytes of the IV
const rgbIV = Buffer.from(SECRET_IV, "ascii").slice(0, 16); // @ref https://stackoverflow.com/a/57147116/12278028
const rgbSalt = Buffer.from([]);

const derivedPasswordBytes = deriveBytesFromPassword(
  SECRET_KEY,
  rgbSalt,
  3,
  hashAlgorithm,
  keySize / 8
);

const dataToEncryptInBytes = Buffer.from(dataToEncrypt, "utf8");

const cipher = new Rijndael(derivedPasswordBytes, "cbc");
const encrypted = Buffer.from(cipher.encrypt(dataToEncryptInBytes, 16, rgbIV));

console.log(encrypted.toString("base64"));

// Use this if you only have the Base64 string
// Note: The Base64 string in Line 34 is from C#
// const decrypted = Buffer.from(
//   cipher.decrypt(Buffer.from("zAqv5w/gwT0sFYXZEx+Awg==", "base64"), 16, rgbIV)
// );

const decrypted = Buffer.from(cipher.decrypt(encrypted, 16, rgbIV));

console.log(decrypted.toString());

Solution

  • A possible NodeJS implementation based on your sandbox code that is compatible with the C# code is:

    const crypto = require("crypto");
    const Rijndael = require("rijndael-js");
    const pkcs7 = require('pkcs7-padding');
    
    const SECRET_KEY = "HelL!oWoRL3ds"; // 13 chars
    const SECRET_IV = "HElL!o@wOrld!#@%$"; // 17 chars
    const rgbIV = Buffer.from(SECRET_IV, "ascii").slice(0, 16); 
    const rgbSalt = Buffer.from([]);
    
    const keySize = 256;
    const hashAlgorithm = "SHA512";
    
    const minSaltLen = 4;
    const maxSaltLen = 8;
    
    function encrypt(plaintextStr) {
      var derivedPasswordBytes = deriveBytesFromPassword(SECRET_KEY, rgbSalt, 3, hashAlgorithm, keySize/8);  
      var cipher = new Rijndael(derivedPasswordBytes, "cbc");
      var plaintext = Buffer.from(plaintextStr, "utf8");
      var salt = generateSalt();
      var saltPlaintext = Buffer.concat([salt, plaintext])
      var saltPlaintextPadded = pkcs7.pad(saltPlaintext, 16)
      var ciphertext = Buffer.from(cipher.encrypt(saltPlaintextPadded, 128, rgbIV));
      return ciphertext.toString("base64");
    }
    
    function decrypt(ciphertextB64) {
      var derivedPasswordBytes = deriveBytesFromPassword(SECRET_KEY, rgbSalt, 3, hashAlgorithm, keySize/8);  
      var cipher = new Rijndael(derivedPasswordBytes, "cbc");
      var ciphertext = Buffer.from(ciphertextB64, 'base64');
      var saltPlaintextPadded = Buffer.from(cipher.decrypt(ciphertext, 128, rgbIV));
      var sourceIndex = saltPlaintextPadded[0] & 3 | saltPlaintextPadded[1] & 12 | saltPlaintextPadded[2] & 48 | saltPlaintextPadded[3] & 192
      var plaintextPadded = saltPlaintextPadded.subarray(sourceIndex)
      var plaintext = pkcs7.unpad(plaintextPadded)
      return plaintext;
    }
    
    function generateSalt() {
      var length =  minSaltLen !=  maxSaltLen ?  crypto.randomInt(minSaltLen,  maxSaltLen + 1) :  minSaltLen;
      var data = crypto.randomBytes(length);
      data[0] = data[0] & 252 | length & 3;
      data[1] = data[1] & 243 | length & 12;
      data[2] = data[2] & 207 | length & 48;
      data[3] = data[3] & 63 | length & 192;
      return data;
    }
    
    var plaintext = "1234";
    var ciphertextB64 = encrypt(plaintext);
    var plaintext = decrypt(ciphertextB64);
    console.log(ciphertextB64);
    console.log(plaintext.toString('hex'))
    

    using the key derivation from the linked post.

    Ciphertexts generated with this code can be decrypted with the C# code, and vice versa, ciphertexts generated with the C# code can be decrypted with this code.


    Explanation:

    • The linked C# code can process a 17 bytes IV under .NET Framework (tested for 4.7.2). However, only the first 16 bytes are taken into account. With the addition rijndaelManaged.IV = rgbIV (as in the MS examples) an exception is thrown. Under .NET Core (tested for 3.0+) an exception is always thrown. This indicates that processing an IV in the .NET Framework that is too large, is more likely a bug. Anyway, in the NodeJS code also only the first 16 bytes of the IV have to be considered.
    • The C# code uses the proprietary key derivation PasswordDeriveBytes. The same key derivation must be applied in the NodeJS code. In the code above, the implementation linked by the OP is used.
    • The library involved rijndael-js applies Zero padding, but the C# code uses PKCS#7 padding. Therefore, in the NodeJS code, the plaintext (or concatenation of salt and plaintext) must be padded with PKCS#7 before encryption (this satisfies the length criterion and Zero padding is no longer applied). Accordingly, the padding must be removed after decryption. A possible library is pkcs7-padding. Alternatively, instead of rijndael-js, another library could be used which applies PKCS#7 padding by default.
    • The C# code uses two salts: One is the empty (!) rgbSalt, which is applied in the key derivation. The other is a second salt, which is randomly generated with respect to both length and content during encryption, is prepended to the plaintext, and contains the information about the salt length, which is determined during decryption. This logic must be implemented in the NodeJS code for both codes to be compatible.
    • The GenerateRandomNumber() method cannot be ported because its result depends on the internal details of the Random() implementation (which, by the way, is not a CSPRNG). The method is supposed to generate a random integer. For this purpose crypto.randomInt() is used. For RNGCryptoServiceProvider#GetNonZeroBytes() create.RandomBytes() is applied. This NodeJS function also allows 0x00 bytes, which could be optimized if needed.

    Security:

    • The proprietary key derivation PasswordDeriveBytes is deprecated and insecure. Instead, Rfc2898DeriveBytes should be used in the C# code and PBKDF2 in the NodeJS code.
    • The missing salt in the key derivation is insecure and allows attacks e.g. via rainbow tables. Instead, a salt of sufficient size (at least 8 bytes) should be randomly generated for each encryption. This salt is not secret and is therefore usually concatenated with the ciphertext.
    • The C# implementation uses a static IV, which is insecure as well. Although the random second salt provides a different ciphertext for identical plaintexts and identical IVs, a best practice should be applied instead of a user defined construct. A proven way is a randomly generated IV, analogous to the salt used for key derivation (randomly generated for each encryption, concatenated with the ciphertext).