最近在开发一个简单接口的时候,因为敏感度不高所以前后台直接用对称加密解决。过程中遇到了点问题,让我想起几年前也遇到一样的问题,归根结底还是对 AES 的了解不多,所以学习了一下,其实在应用层面来说这个加密算法还是蛮简单的。
首先 AES 是一种对称加密算法,加解密都用同一个 Key,简单理解为:
明文 + Key => 密文
密文 + Key => 明文
不过实际使用中这个算法参数要更复杂些,通常会用到以下几个关键参数:
- Key Length: 密钥长度
- Key: 密钥本身
- IV: 初始向量
- Mode: 加密模式
- Padding: 填充方式
双方要对齐这五个参数才能完成加解密过程。我们用 node 来模拟后端加密:
const request = require('request');
const crypto = require("crypto");
const key = "your-key"; // 256 bits for AES256
const iv = "some-random-iv" // 128 bits for CBC mode
// 加密
let data = "some-strings"
cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(text, "utf8", "base64");
encrypted += cipher.final("base64");
console.log(encrypted)
关键代码是 cipher = crypto.createCipher('aes-256-cbc', key);
,使用 256 位长度的加密 Key,CBC 模式,初始向量 IV 为 128 位随机字符串。
AES 加密 Key 的长度一般是 128/192/256 位,唤作: AES128, AES192, AES256。这个好理解,那 iv 是用来做什么,为什么是 128 位呢?
AES 是一种分组加密算法,由比利时密码学家 Joan Daemen 和 Vincent Rijmen 设计,唤作 Rijndael。AES 规定分块长度为固定的 16 bytes,把完整数据按 16 bytes 切分后,每块进行加密,最后再把所有加密后的块拼到一起。解密的时候也一样。
如果原文本身不是 16 bytes 的整数倍时,就需要加上 Padding,而 AES 的 Padding 也有多种不同的方式。node 官方只提供了 setAutoPadding()
方法,使用 CryptoJS 则带有这些方法:
- pad-pkcs7
- pad-ansix923
- pad-iso10126
- pad-iso97971
- pad-zeropadding
- pad-nopadding
最后一个 pad-nopadding
自然是不填充,pad-iso10126
是当明文块少于 16 bytes 的时候,就在末尾补足,其中补足的字符里最后一个数字等于补全的字符数,剩下的就随机。
比如: [1, 2, 3, 4, 5, a, b, c, d, e]
少了 6 bytes,那可能就补成:
[1, 2, 3, 4, 5, a, b, c, d, e, 5, c, 3, G, $, 6]
其他的 padding 算法不再赘述。
接下来我们看加密模式,AES 一共有五种加密模式:
- ECB: 电码本模式(Electronic Codebook Book)
- CBC: 密码分组链接模式(Cipher Block Chaining)
- CTR: 计算器模式(Counter)
- CFB: 密码反馈模式(Cipher FeedBack)
- OFB: 输出反馈模式(Output FeedBack)
其中 ECB
模式比较特别没有用到初始向量 IV,剩下的都比较相近。上面说 AES 是分组加密的,ECB
模式就是每一块独立加密,所以如果有两个块内容一样的话就会出现两个一样的密文。
而 CBC
则是前后两个分块链接的形式进行加密,后一个块跟前一个块加密后的密文进行异或运算,得到的结果再用密钥进行加密。类似于 MD5 加盐,前一块的密文就是后一块的“盐”。这样即使有两块一样的明文也会得到不一样的密文。而第一块所使用的“盐”就是 IV,初始向量(Initialization Vector)。
至此几个关键参数已介绍完毕,只要前后台把 Key, Key length, Mode, IV, Padding 对齐就好。我之前遇到的问题是这样的,node 端加密时用的是老接口:
cipher = crypto.createCipher('aes-256-cbc', key);
这个接口从 node v10.0.0 开始就 deprecated 了,显然没带 IV,那这个 IV 从哪里来的呢?
The password is used to derive the cipher key and initialization vector (IV). The value must be either a 'latin1' encoded string, a Buffer, a TypedArray, or a DataView.
The implementation of crypto.createCipher() derives keys using the OpenSSL function EVP_BytesToKey with the digest algorithm set to MD5, one iteration, and no salt. The lack of salt allows dictionary attacks as the same password always creates the same key. The low iteration count and non-cryptographically secure hash algorithm allow passwords to be tested very rapidly.
In line with OpenSSL's recommendation to use a more modern algorithm instead of EVP_BytesToKey it is recommended that developers derive a key and IV on their own using crypto.scrypt() and to use crypto.createCipheriv() to create the Cipher object. Users should not use ciphers with counter mode (e.g. CTR, GCM, or CCM) in crypto.createCipher(). A warning is emitted when they are used in order to avoid the risk of IV reuse that causes vulnerabilities. For the case when IV is reused in GCM, see Nonce-Disrespecting Adversaries for details.
根据 node 文档,其内部实现会拿 Key 过一遍 OpenSSL 的 EVP_BytesToKey()
然后做一次 MD5 获得 IV,理论上可以被暴力破解,所以 deprecated。(P.S. node 源码的实现是在有点绕,试图寻找 createCipher()
函数对 CBC 的实现而不得[捂脸])
使用 node 原生的这个方法搭配 crypto.createDecipher()
可以解密得出原文,因为解密的 IV 同样可以通过 Key 计算得出。但是用 iOS CommonCrypto 的接口就尴尬了,少了好几个必要参数。