1

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());
Topaco
  • 40,594
  • 4
  • 35
  • 62
dale
  • 25
  • 5
  • 1
    Regarding the IV the length of `rgbIV` is crucial. This must be 16 bytes for AES (= the default for `RijndaelManaged`). It's hard to believe that on .NET Fiddle a 17 bytes IV is accepted for the posted code. Please post test data so that this can be reproduced. – Topaco Apr 22 '22 at 06:21
  • 1
    Regarding the key: The C# code uses a key derivation function (unfortunately the non-standard `PasswordDeriveBytes` is used, which makes porting difficult), so the password length is not critical. In the NodeJS code this key derivation is missing. – Topaco Apr 22 '22 at 06:50
  • Hi @Topaco, you can use this [fiddle](https://dotnetfiddle.net/TrgQx9) to reproduce, but dotnetfiddle fails to compile it, so you can just copy and paste the code [here](https://www.programiz.com/csharp-programming/online-compiler/) – dale Apr 22 '22 at 07:35
  • 1
    Under .NET Framework (tested for 4.7.2) a too long IV seems to be truncated after 16 bytes. You can easily verify this by using fixed values for a test instead of random values: The two IVs `HElL!o@wOrld!#@$` and `HElL!o@wOrld!#@$%` then generate the *same* ciphertext. For the NodeJS code this means to use only the first 16 bytes of the IV. – Topaco Apr 22 '22 at 08:48
  • 1
    The silent truncation of the IV seems to me rather a bug. If you set `rijndaelManaged.IV = rgbIV` (as in the MS examples, see [here](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.rijndaelmanaged?view=net-6.0)), then your code will also throw an exception if the IV is too long. .NET Core (tested for 3.0+) always throws an exception if the IV is too long. – Topaco Apr 22 '22 at 08:58
  • @Topaco Thanks. To convert the C# to NodeJS - one, I need to use the Key (or Passphrase) in a _key derivation function_, two, I must retrieve the first 16 bytes of the IV. Anything else I seem to be missing? – dale Apr 22 '22 at 10:31
  • 1
    Of course, there are more implementation details missing (the C# code is more than 10 times larger than the 6 lines of NodeJS code). Regarding the key derivation function: You don't have to take *some* KDF, but the *same* KDF, i.e. you need a NodeJS implementation for `PasswordDeriveBytes`, search the web. – Topaco Apr 22 '22 at 11:51
  • @Topaco I was able to find a `PasswordDeriveBytes` implementation for NodeJS. Would you mind looking at what I found from testing in the post (Edit section)? – dale Apr 23 '22 at 08:37
  • Please take a look at my answer. – Topaco Apr 23 '22 at 14:26

1 Answers1

1

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).
Topaco
  • 40,594
  • 4
  • 35
  • 62
  • AWESOME! Thank you so much! As long as the C# code is under .NET Framework v4.7.2, I can safely assume that the NodeJS implementation mirrors the C# encryption/decryption right? – dale Apr 24 '22 at 00:56
  • 1
    @dale - This applies not only to .NET Framework v4.7.2, but to all .NET Framework versions as of v2.0 (for encryption with the NodeJS code under consideration of the last point under *Explanation*). – Topaco Apr 24 '22 at 07:09
  • FWIW: `PasswordDeriveBytes` is PBKDF1, unless you ask for more bytes than the hash output size, then it… makes up answers. – bartonjs Apr 28 '22 at 21:29