OpenSSL AES 算法中 Key 和 IV 是如何生成的?

更新历史:

  • 2017-05-30: 初版
  • 2024-08-07: 使用 xxd 命令代替 Perl 脚本生成二进制文件
  • 2024-08-09: 增加 PBKDF2 的介绍;添加小标题使文章结构更清晰

书接上回。在《LDAP 密码加密方式初探》一文中,使用 openssl 命令 AES CBC 算法加密解密时,都用到了 Key 和 IV 参数,那么这两个参数是如何生成的呢?

本文将介绍 OpenSSL 使用的两种密钥派生函数(KDF):

  • EVP: 已被 OpenSSL 标记为过时的密钥派生函数,仅做一次迭代,是 PBKDF1 的超集
  • PBKDF2: 更现代的密钥派生函数,支持多次迭代

EVP

先准备好生成 Key 和 IV 的 passphrase:

$ echo -n "drjom(&)(&)MOJRD" > passphrase

上述回文形式的 passphrase 来自一个神秘的组织 😀

aes-256-cbc + md5 + salt

仍然以 AES-256-CBC 开始探索。将上述 passphrase 传入 openssl 命令生成对应的 Key 和 IV:

$ openssl enc -aes-256-cbc -kfile passphrase -md md5 -P -salt
salt=51D9C4B24C759179
key=BBF4EA0E7A0EBD7C60CCE2024E218A53BBB69CCA65B4D0B705E37080676E5F5D
iv =8E5EC1AC2191167DF9B753BA93A1E7B8

其中的 salt 是随机生成的,因此每次执行的结果并不相同。而 Key 和 IV 的生成方法参考 SuperUser 的一个回答补充及验证如下(注意 md5sum 输出的结果与上述 openssl 命令的输出结果比较):

$ xxd -r -p <<< 51D9C4B24C759179 > salt
$ cat passphrase salt > hash1_128.tmp
$ md5sum hash1_128.tmp
bbf4ea0e7a0ebd7c60cce2024e218a53  hash1_128.tmp
$ xxd -r -p <<< bbf4ea0e7a0ebd7c60cce2024e218a53 > hash1_128
$ cat hash1_128 passphrase salt > hash2_128.tmp
$ md5sum hash2_128.tmp 
bbb69cca65b4d0b705e37080676e5f5d  hash2_128.tmp
$ xxd -r -p <<< bbb69cca65b4d0b705e37080676e5f5d > hash2_128
$ cat hash2_128 passphrase salt > hash3_128.tmp
$ md5sum hash3_128.tmp 
8e5ec1ac2191167df9b753ba93a1e7b8  hash3_128.tmp

可以看出,对于 AES-256-CBC 来说:

hash1_128 = MD5(Passphrase + Salt)
hash2_128 = MD5(hash1_128 + Passphrase + Salt)
hash3_128 = MD5(hash2_128 + Passphrase + Salt)
Key = hash1_128 + hash2_128
IV  = hash3_128

Key 和 IV 分别就是 AES-256-CBC 的 Key 和 IV。

aes-256-cbc + md5 + nosalt

当没有 salt 时,上述过程仍然成立。先使用 openssl 命令带 -nosalt 选项生成 Key 和 IV:

$ openssl enc -aes-256-cbc -kfile passphrase -md md5 -P -nosalt
key=D5E483D8B90C02BD4D470BA8049E1FA61D64EB2BFA444CBF9853CDFB8B24DA7A
iv =304E9E87DB9C1C8101F605ED4DD0B9EB

分步验证如下(注意 md5sum 输出的结果与上述 openssl 命令的输出结果比较):

$ md5sum passphrase
d5e483d8b90c02bd4d470ba8049e1fa6  passphrase
$ xxd -r -p <<< d5e483d8b90c02bd4d470ba8049e1fa6 > hash1_128
$ cat hash1_128 passphrase > hash2_128.tmp
$ md5sum hash2_128.tmp
1d64eb2bfa444cbf9853cdfb8b24da7a  hash2_128.tmp
$ xxd -r -p <<< 1d64eb2bfa444cbf9853cdfb8b24da7a > hash2_128
$ cat hash2_128 passphrase > hash3_128.tmp
$ md5sum hash3_128.tmp 
304e9e87db9c1c8101f605ed4dd0b9eb  hash3_128.tmp

也就是说:

hash1_128 = MD5(Passphrase)
hash2_128 = MD5(hash1_128 + Passphrase)
hash3_128 = MD5(hash2_128 + Passphrase)
Key = hash1_128 + hash2_128
IV  = hash3_128

aes-128-cbc + md5 + nosalt

在此基础上,看看 AES-128-CBC 生成的 Key 和 IV 是什么样子的:

$ openssl enc -aes-128-cbc -kfile passphrase -md md5 -P -nosalt
key=D5E483D8B90C02BD4D470BA8049E1FA6
iv =1D64EB2BFA444CBF9853CDFB8B24DA7A

对比 AES-256-CBC 可以看出,AES-128-CBC 的 Key 和 IV 生成方法进一步简化(以下为没有 salt 时的情况):

hash1_128 = MD5(Passphrase)
hash2_128 = MD5(hash1_128 + Passphrase)
Key = hash1_128
IV  = hash2_128

aes-256-cbc + sha256 + nosalt

在上述验证过程中使用到 openssl 命令时,都用 -md 选项将生成 Key 和 IV 的 hash 函数指定为 md5。那么假如不指定的话,默认的 hash 函数是什么呢?

此问答可知,从 1.1 版本开始,默认的 hash 函数由 MD5 变为 SHA256(可使用 openssl version 命令查看当前版本号),另外也可以通过修改 /etc/ssl/openssl.cnf 配置文件中的 default_md 字段指定默认的 hash 函数

要知道,MD5 生成的 hash 是 128bit 的,而 SHA256 生成的 hash 是 256bit 的,上述 256bit Key 生成时的拼接操作是否有必要呢?

继续验证,仍然回到 AES-256-CBC 并使用 SHA256 作为 hash 函数:

$ openssl enc -aes-256-cbc -kfile passphrase -md sha256 -P -nosalt
key=53A8968B0F53CAA2D21F2694B19EDD0676AF034D4D570651B3689C7827EC84C2
iv =ED889267E14BA02167ED96E226153158

分步看看:

$ sha256sum passphrase
53a8968b0f53caa2d21f2694b19edd0676af034d4d570651b3689c7827ec84c2  passphrase
$ xxd -r -p <<< 53a8968b0f53caa2d21f2694b19edd0676af034d4d570651b3689c7827ec84c2 > hash1_256
$ cat hash1_256 passphrase > hash2_256.tmp
$ sha256sum hash2_256.tmp
ed889267e14ba02167ed96e226153158373dbeff2b1177c12906ab786dd1ebd8  hash2_256.tmp

可以看到,对 passphrase 做一次 SHA256 运算就已经是 256bit Key 了,对 Key 和 passphrase 拼接后再次做 SHA256 运算,截取前 128bit 作为 IV 的值。也就是说:

hash1_256 = SHA256(Passphrase)
hash2_256 = SHA256(hash1_256 + Passphrase)
Key = hash1_256
IV  = First128bit(hash2_256)

aes-128-cbc + sha256 + nosalt

再看看 AES-128-CBC 的情况:

$ openssl enc -aes-128-cbc -kfile passphrase -md sha256 -P -nosalt
key=53A8968B0F53CAA2D21F2694B19EDD06
iv =76AF034D4D570651B3689C7827EC84C2

对 passphrase 做一次 SHA256 运算之后,前 128bit 作为 AES-128-CBC 的 Key 值,后 128bit 作为其 IV 值。写成等式是:

hash1_256 = SHA256(Passphrase)
Key = First128bit(hash1_256)
IV  = Second128bit(hash1_256)

至此,可以看出 AES 算法 Key 和 IV 的生成规律了:将 hash 结果(第一次 hash 运算时为空)、passphrase 和 salt(nosalt 时为空)拼接后循环做 hash 运算,再根据 AES 所需的 Key 和 IV 的 bit 数取值。

更进一步的,从上述生成过程可见,只要生成了足够 bit 位的值,hash 运算就停止了,这称为一个迭代,这正是 OpenSSL 为人所诟病的不足。而 GnuPG 使用了多次迭代。

EVP_BytesToKey()

上文描述的密钥派生算法正是 OpenSSL API 函数 EVP_BytesToKey() 的实现,其文档对此算法做了极其精准又简洁的描述:

关于此算法的名称,当 key 和 IV 的总长度不超过 hash 长度且 hash 函数使用的是 MD5,那么此算法与 PBKDF1 兼容。但 PBKDF1 仅定义了 MD2、MD5 和 SHA-1,而 EVP_BytesToKey() 还支持 SHA-256 等其它 hash 函数。另外我也没找到 OpenSSL 文档中对此算法的命名,不妨称此算法为 EVP。

PBKDF2

当使用较新版本的 OpenSSL 运行上述命令(不指定其它密钥派生函数)时,总会有一个警告:

*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.

EVP_BytesToKey() 函数的文档中也提到,新的应用程序应该使用更现代的算法,比如 PKCS#5 v2.1 中定义的 PBKDF2,由 PKCS5_PBKDF2_HMAC() 函数提供。

那么,这个 PBKDF2 算法具体是如何实现的呢?

RFC 2898 Section 5.2 定义了 PBKDF2 算法。Wikipedia PBKDF2 词条有张图描述了这个算法的迭代过程:

算法实现过程简要描述如下:

  1. 先计算生成 key 和 IV 需要几个块,比如 aes-256-cbc 的 key 长 32 字节,IV 16 字节,而伪随机函数的输出为 32 字节,所以需要两个块。
  2. 对于每一个块,将 salt (nosalt 时为空)拼接当前块号的四字节大端表示,与 passphrase 作为伪随机函数(通常为 HMAC)的输入,产生一个 hash,此为第一次迭代。
  3. 此后的每一次迭代,都将前一次 hash 结果及 passphrase 作为 HMAC 的输入,产生一个 hash 再异或(XOR)前一次 hash 产生新的 hash。
  4. 当所有的迭代完成后的 hash 即是最终的结果,再根据所需要的 key 或 IV 的长度从最终结果中截取。

注意:上述过程中的每一个块都是独立操作的。

接下来以 AES-256-CBC 为例分步验证。

nosalt + 1 iteration

先来看看 openssl 命令的输出:

$ openssl enc -aes-256-cbc -kfile passphrase -md sha256 -P -nosalt -pbkdf2 -iter 1
key=B0BC445D2D47544327D147982B25B86BBDE6A745338D0B9D681DDD61E3AE523F
iv =6EAD332E24753C990A6031E3C9D12B3B

key 和 IV 一共需要 48 字节,而 SHA256HMAC 的输出是 32 字节,所以需要两个块。将块号的四字节大端表示作为原始输入,分步实现(SHA256HMAC 使用 openssl dgst 子命令完成):

$ printf '%08x' "1" | xxd -r -p | openssl dgst -sha256 -hmac "drjom(&)(&)MOJRD" -r
b0bc445d2d47544327d147982b25b86bbde6a745338d0b9d681ddd61e3ae523f *stdin
$ printf '%08x' "2" | xxd -r -p | openssl dgst -sha256 -hmac "drjom(&)(&)MOJRD" -r
6ead332e24753c990a6031e3c9d12b3b11022f945a19077e1bd65a4c95ba4fe1 *stdin

当只做一次迭代时,上述的步骤 3 省略了(没有异或操作),每个块只需一次 SHA256HMAC (IV 只需截取第二个结果的前 16 字节)。

nosalt + 2 iterations

openssl 命令的输出如下:

$ openssl enc -aes-256-cbc -kfile passphrase -md sha256 -P -nosalt -pbkdf2 -iter 2
key=493303142AC221F73E9DBEBCD5DCE6679C82AC657E64975792CF26448002CA28
iv =437B14F62886F7D424AC187044ED5688

在上文一次迭代的基本上,先看第一个块:

$ xxd -r -p <<<b0bc445d2d47544327d147982b25b86bbde6a745338d0b9d681ddd61e3ae523f | openssl dgst -sha256 -hmac "drjom(&)(&)MOJRD" -r
f98f4749078575b4194cf924fef95e0c21640b204de99ccafad2fb2563ac9817 *stdin

再将 b0bc445d2d47544327d147982b25b86bbde6a745338d0b9d681ddd61e3ae523f 异或 f98f4749078575b4194cf924fef95e0c21640b204de99ccafad2fb2563ac9817 即得到 493303142ac221f73e9dbebcd5dce6679c82ac657e64975792cf26448002ca28,正好满足 key 所需的长度。

同样,对于第二个块:

$ xxd -r -p <<<6ead332e24753c990a6031e3c9d12b3b11022f945a19077e1bd65a4c95ba4fe1 | openssl dgst -sha256 -hmac "drjom(&)(&)MOJRD" -r
2dd627d80cf3cb4d2ecc29938d3c7db365b8476e8183d7c7ecd0af5f0e23395c *stdin

6ead332e24753c990a6031e3c9d12b3b11022f945a19077e1bd65a4c95ba4fe1 异或 2dd627d80cf3cb4d2ecc29938d3c7db365b8476e8183d7c7ecd0af5f0e23395c 将得到 437b14f62886f7d424ac187044ed568874ba68fadb9ad0b9f706f5139b9976bd,再截取前 16 个字节即是 IV。

salt + 1 iteration

以 openssl 命令的输出作为基准:

$ openssl enc -aes-256-cbc -kfile passphrase -md sha256 -P -salt -pbkdf2 -iter 1
salt=6EB64134174F5F29
key=4EA8E9DCA74E2A20087FA5E024ECCED727D07333E77FA6577ABF0CEC6CD748E5
iv =F2484CAA76AC033930551027A91545A4

将 salt 与块号的四字节大端表示拼接作为初始输入,分步验证:

$ printf '6EB64134174F5F29%08x' "1" | xxd -r -p | openssl dgst -sha256 -hmac "drjom(&)(&)MOJRD" -r
4ea8e9dca74e2a20087fa5e024ecced727d07333e77fa6577abf0cec6cd748e5 *stdin
$ printf '6EB64134174F5F29%08x' "2" | xxd -r -p | openssl dgst -sha256 -hmac "drjom(&)(&)MOJRD" -r
f2484caa76ac033930551027a91545a47fa91d7c05f780c758287ee9c25f8e37 *stdin

同样,第一个结果为 key,第二个结果的前 16 字节为 IV。

多次迭代的情况与 nosalt 时类似,不再赘述。

其它加密算法如 aes-128-cbc 类似,仅所需的 key 长度不同,按需截取即可。

小结

  • OpenSSL AES 算法默认的密钥派生函数:将 hash 结果(第一次 hash 运算时为空)、passphrase 和 salt(nosalt 时为空)拼接后循环做 hash 运算,再根据 AES 所需的 Key 和 IV 的 bit 数取值。
  • 默认的 hash 函数,从 OpenSSL 1.1 开始由 MD5 变为 SHA256。
  • 可以通过 /etc/ssl/openssl.cnfdefault_md 字段修改默认的 hash 函数。
  • OpenSSL AES 算法默认的密钥派生函数只做一次迭代,GnuPG 使用多次迭代。
  • 较新版本的 OpenSSL 支持更现代的 PBKDF2,算法更复杂,且支持多次迭代。

以上。

comments powered by Disqus