前言

最近一直在折腾 Golang 的 AES 加密解密,最初的一个小需求只是寻求一个简单直接的加密工具而已,但是找着找着发现里面的坑太深了…

吐槽:对于加密解密,其实我们很多时候并没有特别高的要求(复杂)。一开始,我最直接的一个想法就是:

  1. 调用一个方法,传递一个秘钥,完成加密;
  2. 调用一个方法,传递一个秘钥,完成解密,

就可以了,但事实网上纷繁复杂的实现让我头疼。难道,就没有一个让我最省心、简单、最快、实现一个加解密的方法吗?

目标

  1. 我要一个对称加密,加解密用的 key 一致
  2. 加密后的数据 = 加密方法(数据, key)
  3. 解密后的数据 = 解密方法(数据, key)
    仅此而已,但寻变网络各种类库,没意外,各有各的问题,下面我列举几个我在做的过程中遇到的问题和坑

问题

  1. AES 有各种加密模式 CBC、ECB、CTR、OCF、CFB 选哪个?都安全吗?
  2. AES 在某些加密模式下需要指定 IV 也就是初始向量(那我岂不是又要弄一个配置项?)
  3. AES 对于 key 的长度 和 IV 的长度都有要求 (这个很烦,就像我定一个密码还非得是固定长度的)
  4. AES 需要加密的数据不是16的倍数的时候,需要对原来的数据做padding操作(可以简单理解为补充长度到固定的位数)好嘛,padding还有不同的方式:Zero padding、ANSI X.923、PKCS7…
  5. js 常用 crypto-js 进行加密解密操作(我这边还想有个特别需求能保证 js 加密一致)

上代码

show me your code 先来看下最终实现情况如何,然后再来说原理和问题

Golang 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"

"github.com/LinkinStars/go-scaffold/contrib/cryptor"
)

func main() {
key := "1234"

e := cryptor.AesSimpleEncrypt("Hello World!", key)
fmt.Println("加密后:", e)

d := cryptor.AesSimpleDecrypt(e, key)
fmt.Println("解密后:", d)

iv := cryptor.GenIVFromKey(key)
fmt.Println("使用的 IV:", iv)
}

// 输出
// 加密后: NHlpzbcTvOj686VaF7fU7g==
// 解密后: Hello World!
// 使用的 IV: 03ac674216f3e15c

对,这就是我想要的,输入需要加密的内容和 key,给我出加密后的结果就好

crypto-js 实现

解密也是类似的,这里我就不重复代码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import CryptoJS from 'crypto-js'

var data = "Hello World!"
var keys = [
"1234",
"16bit secret key",
"16bit secret key1234567",
"16bit secret key12345678",
"16bit secret key16bit secret ke",
"16bit secret key16bit secret key",
"16bit secret key16bit secret key1",
]

function aesEncrypt(data, key) {
if (key.length > 32) {
key = key.slice(0, 32);
}
var cypherKey = CryptoJS.enc.Utf8.parse(key);
CryptoJS.pad.ZeroPadding.pad(cypherKey, 4);

var iv = CryptoJS.SHA256(key).toString();
var cfg = { iv: CryptoJS.enc.Utf8.parse(iv) };
return CryptoJS.AES.encrypt(data, cypherKey, cfg).toString();
}

for (let i = 0; i < keys.length; i++) {
console.log(aesEncrypt(data, keys[i]))
}

// 输出
// NHlpzbcTvOj686VaF7fU7g==
// PuMhKY8ZFLnDAwlQ7v/2SQ==
// ZG9JUBvEXrXwSS2RIHvpog==
// pbvDuBOV3tJrlPV0xdmbKQ==
// uAeg71zBzFeUfEMHJqCSxw==
// j9SbFFEEFX4dT9VaDAzsCg==
// j9SbFFEEFX4dT9VaDAzsCg==

问题与解决方案

选择什么加密模式

加密模式有 CBC、ECB、CTR、OCF、CFB,其中 ECB 有安全问题,所以一定不选择,而常用的是 CBC,并且 crypto-js 默认也用了 CBC 所以就无脑选择了 CBC

密钥的长度问题

AES 需要你指定的 密钥长度 必须为 128 位、192 位或256 位,即字符串长度为:16、24 或 32。
对于知道 AES 算法的人来说,其实这很好理解,并且很容易接受,但是对于一个完全不知道你程序或者应用的外部使用者来说,必须写一个长度固定的密码很难理解
所以对与 key(密钥) 我做了如下处理:

  1. 长度超过 32 ,直接截取前面 32
  2. 长度不满足要求的,使用 ZeroPadding 方式补全 (小于 16 的补充到 16,大于 16 小于 24 的补充到 24)

    ZeroPadding 其实实现非常简单,就是将长度不足的末尾补 0 补足就可以

初始向量 IV 的问题

首先来解释为什么需要 IV

AES-CBC
其实很好理解,AES 的加密方式是将原数据拆分成一块一块,每一块单独进行加密,最后组合到一起,而在 ECB 模式下,每块加密使用的 key 都是一样的,所以有安全风险,而为了解决这个问题,和 MD5 类似就是给你的加“盐”,我们知道正常的 hash 容易碰撞被猜到,而加了盐之后,相当于给了一个偏移量,使得结果不可被预测。而 CBC 模式下,第一块加密数据所需的这个盐就是 IV,后面几块加密所需的盐都是通过前面来得到的。

那如何创造 IV 呢?

再次从使用者的角度出发,我既然已经提供了一个 key 去加密了,为什么还要提供一个与 key 类似的东西去加密呢?就相当于我需要记住两个密码,很麻烦。并且通常如果作为配置项出现的话,两个 key 肯定是配置在一起的,配置文件里面一般不会为了安全而特别的将两个密码分开存放。

所以我在思考如何创造一个 IV 呢?

首先,肯定这个 IV 需要从 key 出发,因为解密也需要,随机或固定肯定不可能,所以我的第一想法就是 IV 与 key 一致,当然我相信很多人都有和我一样的想法,但是,抱歉,不行。

📢 注意!!!IV 与 key 一致在某些加密模式下相当于你直接将 key 暴露给了用户

所以我参考了老版本 node 的实现,并且改进了一下

1
2
3
4
5
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 MD5 了一下作为了 IV,那显然 MD5 是容易被碰撞的。那么好,既然 MD5 不行,那我直接 SHA256 总可以了吧(目前理论安全)。

于是,对于 IV 的生成我就采取了 SHA256 的方式,对 key 做了一次 hash 并且由于 IV 长度固定为 16,所以我又做了一次截取,这下你总不可能还原了吧。

原数据处理模式

上面我们知道,AES 使用 CBC 模式进行加密的时候,需要将数据拆分成一块一块的,那么问题就是,每块长度为 16,当拆分到最后长度不足的时候又需要补充,也叫 padding。padding 还有不同的方式:Zero padding、ANSI X.923、PKCS7…

这里,类似的,由于 crypto-js 默认使用 PKCS7 所以就用它了。

其他问题

  • 我在寻找工具的过程中看过很多方法,发现都会在加密的时候返回 error,我就很难受,我也明白他们返回 error 通常是由于 key 长度不满足要求的时候返回,所以我这里直接处理,当 error 出现直接返回空字符串。
  • crypto-js 在使用的时候一定记得需要使用方法转换 CryptoJS.enc.Utf8.parse 否则会导致加密不一致的情况
  • CryptoJS.pad.ZeroPadding.pad(cypherKey, 4); 这里的 4 的原因是内部方法计算时 乘以了 4,其实是 block 的大小也就是 16,这也是一个坑,不看源码也不知道的坑。我一开始传递的就是 16 😭 源码位置:https://github.com/brix/crypto-js/blob/develop/src/pad-zeropadding.js

总结

代码实现在:
https://github.com/LinkinStars/go-scaffold/blob/main/contrib/cryptor/aes.go
如果需要,你不一定需要直接引用,拷贝对应方法到自己的项目中进行使用就可以了,希望能帮助到你。同时也有支持自定义指定 IV 的方法 AesCBCEncrypt,但相对应的你需要自己去保证 key 和 iv 的长度正确了。

最后要提醒一下,虽然我使用了 crypto-js 进行加密,但由于是业务需要,如果你在使用的话一定要注意不要将 key 给前端页面进行解密,毕竟 AES 是对称加密。

参考链接

本文在线运行代码发布于 https://1024code.com/ 在博客内嵌入代码展示非常方便,喜欢的朋友可以尝试看看~