NodeJS 中的无效密钥长度,但 C# 中的有效密钥长度

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

我正在将 Rijndael 解密从 C# 转换为 NodeJS。

使用的密钥(或密码)长度为 13 个字符。使用的 IV 长度为 17 个字符。
注意:我无法控制长度选择

下面是C#中的Rijndael解密

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);

        }
}

出于某种原因,我可以解密 dotnetfiddle 中的字符串,但不能解密 Visual Studio 中的字符串(因为它 returns 是 'Specified initialization vector (IV) does not match the block size for this algorithm. (Parameter 'rgbIV')'[=24= 的错误]

下面是我尝试使用 rijndael-js 库在 NodeJS 中进行转换:

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));

其中 returns 错误 Unsupported key size: 104 bit

所有错误都指向同一件事:Key/IV 长度无效。

是否有变通方法可以强制 NodeJS 接受 Key 和 IV 作为有效长度?有什么我遗漏、做错或配置错误的吗?


编辑:

我找到了 PasswordDeriveBytes implementation for NodeJS 并比较了 C# 的结果,它们是相等的。

我更新了我的 NodeJS 实现(参见 sandbox)并注意到一些事情:

  1. 所有生成的密文都是一样的。我猜这源于盐。
  2. 我尝试解密从 C# 生成的密文,但结果值的左侧似乎有几个字符。 示例:C# 加密字符串:zAqv5w/gwT0sFYXZEx+Awg==,NodeJS 解密字符串:���&��4423
  3. 当我尝试在 C# 中解密 NodeJS 生成的密文时,C# 编译器 returns 出现 System.Security.Cryptography.CryptographicException: Padding is invalid and cannot be removed.
  4. 错误

编辑:

C# 代码(可使用 .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 代码(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 
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());

基于沙箱代码并与 C# 代码兼容的可能的 NodeJS 实现是:

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'))

使用从 linked post 派生的密钥。

用此代码生成的密文可以用C#代码解密,反之,用C#代码生成的密文也可以用此代码解密。


解释:

  • 链接的 C# 代码可以在 .NET Framework 下处理 17 字节的 IV(针对 4.7.2 测试)。但是,只考虑前 16 个字节。添加 rijndaelManaged.IV = rgbIV (如在 MS 示例中)会引发异常。在 .NET Core(针对 3.0+ 进行测试)下,始终会抛出异常。这表明在 .NET Framework 中处理过大的 IV 更可能是错误。无论如何,在 NodeJS 代码中也只需要考虑 IV 的前 16 个字节。
  • C# 代码使用专有密钥推导 PasswordDeriveBytes。必须在 NodeJS 代码中应用相同的密钥派生。在上面的代码中,使用了OP链接的实现。
  • 涉及的图书馆 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。或者,可以使用另一个默认应用 PKCS#7 填充的库来代替 rijndael-js。
  • C# 代码使用了两种盐:一种是 empty (!) rgbSalt,用于密钥推导。另一个是 second salt,它是在加密过程中随机生成的长度和内容,被添加到明文中,并包含有关 salt 长度的信息,该信息在加密过程中确定解密。必须在 NodeJS 代码中实现此逻辑才能使两种代码兼容。
  • 无法移植 GenerateRandomNumber() 方法,因为其结果取决于应用 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() 的内部细节。此 NodeJS 函数还允许 0x00 字节,如果需要可以对其进行优化。

安全性:

  • 专有密钥派生 PasswordDeriveBytes 已弃用且不安全。相反,NodeJS 代码中的 Rfc2898DeriveBytes should be used in the C# code and PBKDF2
  • 密钥派生中缺少盐是不安全的,并且允许攻击,例如通过彩虹表。相反,应该为每次加密随机生成足够大小(至少 8 个字节)的盐。此盐不是秘密的,因此通常与密文连接在一起。
  • C# 实现使用静态 IV,这也是不安全的。尽管随机 second salt 为相同的明文和相同的 IV 提供了不同的密文,但应该应用最佳实践而不是用户定义的构造。一种行之有效的方法是随机生成 IV,类似于用于密钥派生的盐(为每次加密随机生成,与密文连接)。