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呢?会不会从上面字符串后面截取?如果太长就阶段,太短就补零?
顺着这个思路做下去,果然成功地把自己给做死了
#03 青少年
在多番尝试均失败告终后,不得不承认自己的年幼无知。
于是祭出搜索引擎,本着“大家都是中国人,看中文怎么着都要更方便”的原因,先尝试百度搜索了一下。
这不搜不要紧,一搜真的有用,我搜到了以下的资料:
真的要感谢这个世界上最伟大的发明——搜索引擎,真的是爱死你们了。
总结一下以上的文章,能总结出以下的结论:
- 他们似乎都是用的ECB的Padding
- 他们都说字符串转换成的Key其实是MD5后的结果
如此说来,怪不得死活不对,原来是这样啊。
但是就算这样,还是有问题:
- 文章里的AES-128-ECB加密方式,密钥长度128位(16字节),这和MD5算后的长度刚好一致,但是我们的AES192要的是24位,咋办?
- ECB不需要IV,但是我们这个是ECB吗?直接写个aes192似乎看不出是不是ECB啊
本着简单粗暴的原则,我又做了如下的尝试:
- 对于密钥长度不够的问题,分别测试补0、补0-7、补1-8、循环使用MD5后的数据
- 对于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)); }
此刻我的情绪明显开始渐渐地不耐烦……
#04 成年
此时我对这个 createCiphor()
函数渐渐地好奇起来。这个函数,到底干嘛了,为啥就被过时了?
于是我去翻了翻 Node.js 的源码,顺藤摸瓜,居然摸到了这个代码,我相信我离真相不远了。
查了查OpenSSL的API,果然最终AES加密的KEY和IV都是从EVP_BytesToKey
这个函数生成的。
然后我又谷歌了一下后,在OpenSSL的文档里找到了这个函数的说明:
……我嘞个去,原来有一套算法啊??
对照上面 Node.js 源码,我们可以看出来这里的核心算法是:
- 所需要KEY和IV,合并起来,成为一个序列
- 用指定的Hash算法(Node.js中是MD5),对密钥和盐(salt, Node.js中为空)连接后的数据,进行散列
- 如果散列后的数据不够长,则将上一步中的数据和密钥、盐再度连接起来,再次散列,并追加在数据之后
- 重复第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 反思
所以这个函数被废弃的主要原因是太秀了吧!!!!!
大神,关注你好久了。对于非对称加密有什么心得吗?