本鱼拟成立工作室承接项目开发/软件定制/云设施开发运维/办公设备技术支持等,如您有相关需求,欢迎来询 | ::博客文章推荐::

如何用C#正确解密Node.js中 crypto.createCipher() 加密的数据?

: DOT.NET 木魚 5067℃ 1评论
你要是问我为啥这样做,我也不知道,毕竟 crypto.createCipher() 这方法都已经过期了啊。

#01 缘起

事情的来源,要源于项目中的一部分数据,为了“安全”需要,存入数据库之前,是需要加密的。这个加密方式呢,就是AES-192。而对应的数据,不好说,谁知道有啥不可描述的数据呢。

而项目,是基于 Node.js 的。于是,在项目中,有如下的代码(历史遗留原因):

const crypto = require('crypto');

const encryptor = crypto.createCipher('aes192', 'hello_world');
encryptor.update('hello!', 'utf-8');

console.info(encryptor.final('hex'));

这段代码输出是:

b139320e970dc3f4c34b3ba1a44f6aa1

那么问题来了,如果我想用C#来进行解密它,知道密钥是,可是 hello_world,要如何解密呢?

一不小心的想法,揭开了慢慢求学之路。

#02 童年

一眼看过去就知道,这肯定是用AES-192加密的。于是,我很顺理成章地写下了如下的代码。

namespace Crypto
{
   using System;
   using System.Security.Cryptography;

   internal class Program
   {

      public static void Main(string[] args)
      {
         var encryptedData = "b139320e970dc3f4c34b3ba1a44f6aa1";
         var key = "hello_world";

         var decrypted = Decrypt(key, encryptedData);
         Console.WriteLine($"解密后的内容为:{decrypted}");
      }

      /// <summary>
      /// 解密数据,并返回解密后的结果
      /// </summary>
      /// <param name="key">加密密钥</param>
      /// <param name="encryptedData">加密后的数据</param>
      /// <returns></returns>
      static string Decrypt(string key, string encryptedData)
      {

      }
   }
}

话说这个加密后的一堆数字,很容易看得出来其实是一个二进制的序列。那么在解密之前,我们需要将它转码回原始的byte数组。

虽然类似的方法在网上一搜一大把,但是基本上都是基于字符串分割实现的。我们这里不这么干,毕竟我们对性能是有要求的。

/// <summary>
/// 解密数据,并返回解密后的结果
/// </summary>
/// <param name="key">加密密钥</param>
/// <param name="encryptedData">加密后的数据</param>
/// <returns></returns>
static string Decrypt(string key, string encryptedData)
{
   var buffer = BytesFromHexString(encryptedData);
}

/// <summary>
/// 将HEX字符(0-9,a-f,A-F)转换为其对应的byte数字
/// </summary>
static byte ByteFromChar(char c) => (byte)(c >= 'a' ? c - 'a' + 10 : c >= 'A' ? c - 'A' + 10 : c - '0');

/// <summary>
/// 将两位HEX字符(如aa)转换为byte
/// </summary>
static byte ByteFromChar(char c1, char c2) => (byte)(ByteFromChar(c1) << 4 | ByteFromChar(c2));

/// <summary>
/// 将一个二位hex字符串序列转换为原始的byte序列
/// </summary>
static byte[] BytesFromHexString(string s)
{
   var buffer = new byte[s.Length / 2];
   var ca = s.ToCharArray();

   for (int i = 0; i < buffer.Length; i++)
   {
      buffer[i] = ByteFromChar(ca[i * 2], ca[i * 2 + 1]);
   }

   return buffer;
}

然后我们来继续完善 Decrypt 函数。

static string Decrypt(string key, string encryptedData)
{
   var buffer = BytesFromHexString(encryptedData);
   var aes = AesCryptoServiceProvider.Create();

   //???...这个aes的Key和IV应该怎么弄?

   var dec = aes.CreateDecryptor();
   return Encoding.UTF8.GetString(dec.TransformFinalBlock(buffer, 0, buffer.Length));
}

这里我懵了一下。这个AES加密应该需要Key和IV。但是这个Key咋弄。一个字符串?

不得不说这里我轻敌了。最初的想法是,会不会我直接用UTF8取字符串的编码?那这样的话,字符串长度和KEY的长度不一样咋办?毕竟192位的,需要24字节长度的Key啊。

会不会不够长填0太长就截取?

那IV呢?会不会从上面字符串后面截取?如果太长就阶段,太短就补零?

顺着这个思路做下去,果然成功地把自己给做死了 29.gif 

#03 青少年

在多番尝试均失败告终后,不得不承认自己的年幼无知。

于是祭出搜索引擎,本着“大家都是中国人,看中文怎么着都要更方便”的原因,先尝试百度搜索了一下。

这不搜不要紧,一搜真的有用,我搜到了以下的资料:

  1. 【不怕坑】之 Node.js加密 C#解密
  2. nodejs使用aes-128-ecb加密如何在c#中解密

真的要感谢这个世界上最伟大的发明——搜索引擎,真的是爱死你们了。

总结一下以上的文章,能总结出以下的结论:

  1. 他们似乎都是用的ECB的Padding
  2. 他们都说字符串转换成的Key其实是MD5后的结果

如此说来,怪不得死活不对,原来是这样啊。

但是就算这样,还是有问题:

  1. 文章里的AES-128-ECB加密方式,密钥长度128位(16字节),这和MD5算后的长度刚好一致,但是我们的AES192要的是24位,咋办?
  2. ECB不需要IV,但是我们这个是ECB吗?直接写个aes192似乎看不出是不是ECB啊

本着简单粗暴的原则,我又做了如下的尝试:

  1. 对于密钥长度不够的问题,分别测试补0、补0-7、补1-8、循环使用MD5后的数据
  2. 对于IV问题,先试试ECB,不行再用MD5后的数据试试;用MD5的数据则分别尝试从0位开始,和从上面使用后的后续数据

如此枚举了大半天后,宣告失败,始终无法解密。这时候的代码,是类似以下的:

static byte[] Md5(string str) => Md5(Encoding.UTF8.GetBytes(str));

static byte[] Md5(byte[] buffer) => MD5.Create().ComputeHash(buffer);

/// <summary>
/// 解密数据,并返回解密后的结果
/// </summary>
/// <param name="key">加密密钥</param>
/// <param name="encryptedData">加密后的数据</param>
/// <returns></returns>
static string Decrypt(string key, string encryptedData)
{
   var buffer = BytesFromHexString(encryptedData);
   var aes = AesCryptoServiceProvider.Create();

   var keyMd5 = Md5(key);
   aes.Key = keyMd5.Concat(Enumerable.Repeat((byte)0, 8)).ToArray();
   aes.IV = keyMd5;


   var dec = aes.CreateDecryptor();
   return Encoding.UTF8.GetString(dec.TransformFinalBlock(buffer, 0, buffer.Length));
}

此刻我的情绪明显开始渐渐地不耐烦…… 56.gif 

#04 成年

此时我对这个 createCiphor() 函数渐渐地好奇起来。这个函数,到底干嘛了,为啥就被过时了?

于是我去翻了翻 Node.js 的源码,顺藤摸瓜,居然摸到了这个代码,我相信我离真相不远了。

查了查OpenSSL的API,果然最终AES加密的KEY和IV都是从EVP_BytesToKey这个函数生成的。

然后我又谷歌了一下后,在OpenSSL的文档里找到了这个函数的说明

……我嘞个去,原来有一套算法啊??

对照上面 Node.js 源码,我们可以看出来这里的核心算法是:

  1. 所需要KEY和IV,合并起来,成为一个序列
  2. 用指定的Hash算法(Node.js中是MD5),对密钥和盐(salt, Node.js中为空)连接后的数据,进行散列
  3. 如果散列后的数据不够长,则将上一步中的数据和密钥、盐再度连接起来,再次散列,并追加在数据之后
  4. 重复第2-3步,直到数据长度足够

依据以上的算法,那么就可以写出来一个很通用的生成KEY和IV的算法了。

static (byte[] key, byte[] iv) InitCipherWithOpenSSL_EVP_BytesToKey(SymmetricAlgorithm algorithm, byte[] key, byte[] salt = null, HashAlgorithm digest = null, int iterationCount = 1)
{
   if (digest == null)
      digest = MD5.Create();

   var keySize = algorithm.KeySize / 8;
   var ivSize = algorithm.BlockSize / 8;
   var digestSize = digest.HashSize / 8;
   var buffer = new byte[GetPreferedCapacity(keySize + ivSize, digestSize)];
   var bufferPosition = 0;

   byte[] tmp = null;
   do
   {
      digest.Initialize();
      for (int i = 0; i < iterationCount; i++)
      {
         if (tmp != null)
            digest.TransformBlock(tmp, 0, tmp.Length, null, 0);
         if (i == 0)
         {
            digest.TransformBlock(key, 0, key.Length, null, 0);
            if (salt != null)
               digest.TransformBlock(salt, 0, salt.Length, null, 0);
         }

         digest.TransformFinalBlock(key, 0, 0); //done for once.
         tmp = digest.Hash;
      }

      Buffer.BlockCopy(tmp, 0, buffer, bufferPosition, tmp.Length);
      bufferPosition += tmp.Length;
   } while (bufferPosition < keySize + ivSize);

   var algKey = new byte[keySize];
   var algIv = new byte[ivSize];

   Buffer.BlockCopy(buffer, 0, algKey, 0, algKey.Length);
   Buffer.BlockCopy(buffer, algKey.Length, algIv, 0, algIv.Length);

   algorithm.Key = algKey;
   algorithm.IV = algIv;

   return (algKey, algIv);
}

/// <summary>
/// 根据一次写入的数据量,判断要装下指定的数据量最适合的待写入缓冲区长度
/// </summary>
static int GetPreferedCapacity(int count, int blockSize) => (count / blockSize + (count % blockSize > 0 ? 1 : 0)) * blockSize;

这个算法写得很长,但……主要是为了性能和通用着想。

具体的逻辑不解释了,自行查看吧。根据上面这个逻辑,继续完善解码算法如下:

static string Decrypt(string key, string encryptedData)
{
   var buffer = BytesFromHexString(encryptedData);
   var aes = AesCryptoServiceProvider.Create();
   aes.KeySize = 192;

   InitCipherWithOpenSSL_EVP_BytesToKey(aes, Encoding.UTF8.GetBytes(key), digest: MD5.Create());

   var dec = aes.CreateDecryptor();
   return Encoding.UTF8.GetString(dec.TransformFinalBlock(buffer, 0, buffer.Length));
}

运行一下,终于搞定了:

#05 反思

所以这个函数被废弃的主要原因是太秀了吧!!!!!

58.gif

喜欢 (11)
发表我的评论
取消评论
表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
(1)个小伙伴在吐槽
  1. 大神,关注你好久了。对于非对称加密有什么心得吗?

    Samtown2021-01-03 09:55 回复