Port legacy C# Rijndael encryption algorithm to nodejs with the crypto module

80 views Asked by At

I am migrating a C# .NET 4.8 Framework code base to the cloud. The new architecture uses nodejs firebase functions. These functions only allow nodejs code.

The following code is used to validate an input text password with a hash stored in a database. The application is migrating to a SSO solution but will allow users to claim data from their old accounts by validating their prior credentials.

The following code is generating a key and initialization vector using RFC2898, which is now called PBKDF2, and providing those to the Rijndael object to setup the encryption.

This method combines the password with the salt for some reason, presumably to increase the length of the password. The salt is always 36 characters. It then uses a pepper where the salt would typically go. And finally, the encrypted value is output as a base64 string.

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

var password = "password123";
var salt = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
var pepper = "yyyyyyyy";

var enc = new UTF8Encoding();
var keyGen = new Rfc2898DeriveBytes(password + salt, enc.GetBytes(pepper));

var key = keyGen.GetBytes(32);
var iv = keyGen.GetBytes(16);

var cipher = new RijndaelManaged
{
  Key = key,
  IV = iv
};

Console.WriteLine("Key: " + Convert.ToBase64String(key));
Console.WriteLine("IV: " + Convert.ToBase64String(iv));

var plainText = enc.GetBytes(password + salt);
using var encryptor = cipher.CreateEncryptor();
using var ms = new MemoryStream();
using var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write);

cs.Write(plainText, 0, plainText.Length);
cs.FlushFinalBlock();

Console.WriteLine("Hash: " + Convert.ToBase64String(ms.ToArray()));

Using nodejs 18 and the crypto package, I am able to create equivalent keys and iv's as the C# code above using the following code.

const generatePbkdf2Values = (password, pepper) => {
  const pepperBytes = Buffer.from(pepper, 'utf8');

  const iterations = 1000;
  const length = 32 + 16;
  const digest = 'sha1';
  const keyPlusIv = crypto.pbkdf2Sync(
    password,
    pepperBytes,
    iterations,
    length,
    digest
  );

  return {
    key: keyPlusIv.subarray(0, 32),
    iv: keyPlusIv.subarray(32, length),
  };
};

I've read that Rijndael is a variant of AES with a fixed block size. Therefore, I was hoping to be able to use the crypto's package createCipheriv method to encrypt text but I am struggling to do so. I fear the C# settings or defaults are not compatible or I'm not configuring them properly. This is where I would like your advice.

A few assumptions

  1. The IV is a 16 byte/128 bit key which would require the aes-128-cbc algorithm. But the 128 algorithm throws a key length error so it must be based on the key which is a 32 byte/256 bit key requiring the aes-256-cbc algorithm.
  2. The C# Rijndael algorithm uses PKCS7 padding by default and so does crypto.createCipheriv via cipher.setAutoPadding(true)
  3. The key and iv from the nodejs method are creating equivalent values as C#

The following is the nodejs that I am using to hash the text. key, iv, password, and salt come from the snippets above.

const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);

const passwordAndSalt = Buffer.from(password + salt, 'utf8');
const encoding = 'hex';
const inputEncoding = encoding;
const outputEncoding = encoding

let result = cipher.update(passwordAndSalt, inputEncoding, outputEncoding);
result += cipher.final(outputEncoding);

console.log(result.toString('base64'));

This provides a string that has 32 more characters than the C# solution and shares no pattern.

The correct hash with the values above is DC07eaMXkaeK26KTIt3ldtzTdISTH5j9cmrg3OzFF16MvfKkEi9ihEkI8VDLzNvU

As a plan B, I tried to use the rinjndael-js npm package. Using the same key and iv from above, the following code produces almost the correct hash but not quite.

import Rijndael from 'rijndael-js';

const cipher = new Rijndael(key, 'cbc');
const text = Buffer.from(password + salt, 'utf8');

return Buffer.from(cipher.encrypt(text, 16, iv)).toString('base64')

This produces a hash that is the same length and is 68% equivalent. The first 43 characters match and the last 21 do not. It would be my preference to use the built in crypto package to create the same hash. Is it possible? Can you spot my errors? Thanks in advance.

1

There are 1 answers

1
Steve On BEST ANSWER

Ok, I found the bugs and it has to do with encoding and the response types and concatenation.

Working solution

  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);

  const passwordAndSalt = Buffer.from(password + salt, 'utf8');
  const inputEncoding = 'hex';

  const result = Buffer.concat([cipher.update(passwordAndSalt, inputEncoding), cipher.final()]);

  return result.toString('base64');

Reasons

The result from the above was 50% longer because calling .final('hex') and appending to the result object was combining two hex objects. I assume that leads to issues when trying to display the result as .toString('base64'). Are you even able to .toString('base64') hex values?

If an output encoding isn't supplied, a Buffer is returned. So concatenating the buffers and base64 encoding them provides the correct hash!

edit

This simplifies the returning of base64 with the recommendation from @Topaco.

  const outputEncoding = 'base64';
  
  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
  
  const passwordAndSalt = Buffer.from(password + salt, 'utf8');

  let result = cipher.update(passwordAndSalt, null, outputEncoding);
  result += cipher.final(outputEncoding);

  return result;