Go 与 Nodejs 中的 AES-CFB

Author Avatar
Equim 2017年8月10日
  • 在其它设备中阅读本文章
Read: 15 minWords: 3,059Last Updated: 18-01-06Written in: AsciiDocLicense: CC-BY-NC-4.0

在 nodejs 中,我习惯使用的流加密方式是 aes-256-cfb。下面是一个简单的样例。

aes-cfb-enc.js
'use strict';

const crypto = require('crypto');

const cipher = crypto.createCipher('aes-256-cfb', 'a secret password with random length');

let encrypted = cipher.update('奇跡も、魔法も、あるんだよ', 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);

console.log(encrypted.toString('hex'));

运行。

$ node aes-cfb-enc.js
92e5eb465964d9af9ad04375e25c11a2f81c8ae0c259b8b34d0341ed19ccdf883b0f7e154977f3

解密的方式也很类似

aes-cfb-dec.js
'use strict';

const crypto = require('crypto');

const decipher = crypto.createDecipher('aes-256-cfb', 'a secret password with random length');

let plain = decipher.update('92e5eb465964d9af9ad04375e25c11a2f81c8ae0c259b8b34d0341ed19ccdf883b0f7e154977f3', 'hex');
plain = Buffer.concat([plain, decipher.final()]);

console.log(plain.toString('utf8'));

运行。

$ node aes-cfb-dec.js
奇跡も、魔法も、あるんだよ

那么在 golang 中要如何实现 aes-256-cfb 呢?

golang 中的 aes-256-cfb

我查阅了文档,发现 golang 的标准库 crypto/aes 只是实现非常原始的 AES 本身,而 cfb、cbc 等分组密码工作模式还要自行调用另外一个标准库 crypto/cipher 来实现。总得来看似乎没有 nodejs 那么方便,但其实这种分法更能让编写者理解这些东西之间的关系。

参照文档中给出的样例,我试着实现了一下类似 nodejs 的 aes-256-cfb 加密,数据按照上面 nodejs 的代码来。

aes-cfb-enc.go
package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"io"
	"log"
)

func main() {
	log.SetFlags(0)

	key := []byte("a secret password with random length")
	plaintext := []byte("奇跡も、魔法も、あるんだよ")

	block, err := aes.NewCipher(key)
	if err != nil {
		log.Fatalln(err)
	}

	ciphertext := make([]byte, aes.BlockSize+len(plaintext))

	iv := ciphertext[:aes.BlockSize]
	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
		log.Fatalln(err)
	}

	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)

	log.Printf("%x\n", ciphertext)
}

代码中可以看出,IV 是随机生成的,然后附在 ciphertext 的前 aes.BlockSize (根据文档是 16) 个字节,后面再紧跟着密文。这也是常见的模式。 可以编译,但运行时报了错误。

$ go run aes-cfb-enc.go
crypto/aes: invalid key size 36
exit status 1

我突然想起,crypto/aes 实现的是最原始的 AES 模式,那么 key 就有严格的长度规则。例如对于 AES-128,key 的长度应该为 16 字节,以此类推。我这里如果想用 AES-256 的话,key 就必须是 32 字节。查阅文档之后,我的想法被证实。

NewCipher creates and returns a new cipher.Block. The key argument should be the AES key, either 16, 24, or 32 bytes to select AES-128, AES-192, or AES-256.

我对上面代码的做了如下修改后,运行就不会报错了。

@@ -11,7 +11,7 @@ import (
 func main() {
		log.SetFlags(0)

-		key := []byte("a secret password with random length")
+		key := []byte("a secret password 0123456789abcd") // len(key) == 32
		plaintext := []byte("奇跡も、魔法も、あるんだよ")

		block, err := aes.NewCipher(key)

运行。

$ go run aes-cfb-enc.go
435104e76d082bc7af7d26baaee3f6ca400f49b8e413905585dbb145e59c99eea1fa78395c620e72036e4f2eb60655159acbf64400f499

与 nodejs 的实现对比

但紧接着我发现,直接用刚刚在 nodejs 里的方法,明显不能解密这段密文。因为我想到 AES 是对称加密,相同的 key 和明文加密的结果一定是相同的(这个想法是不完全正确的,后面会说明)。而 golang 给出的结果从长度上就和 nodejs 给出的不同了。实测之后也是如此。

@@ -4,7 +4,7 @@ const crypto = require('crypto');

 const decipher = crypto.createDecipher('aes-256-cfb', 'a secret password 0123456789abcd');

-let plain = decipher.update('92e5eb465964d9af9ad04375e25c11a2f81c8ae0c259b8b34d0341ed19ccdf883b0f7e154977f3', 'hex');
+let plain = decipher.update('435104e76d082bc7af7d26baaee3f6ca400f49b8e413905585dbb145e59c99eea1fa78395c620e72036e4f2eb60655159acbf64400f499', 'hex');
 plain = Buffer.concat([plain, decipher.final()]);

 console.log(plain.toString('utf8'));

运行。

$ node aes-cfb-dec.js
d�z����&9 �?a� w�X� ����U眙x  j�ٜE2 ��n;2_Jl2x����ᅰ
  1. 为什么 golang 下使用 aes-256-cfb 的时候 key 的长度是确定的 32 字节,而 nodejs 却没有限制?

  2. 为什么 golang 下使用 aes-256-cfb 生成的密文无法用 nodejs 的对应方法解密?

抱着这两个问题,我决定深究一番。

nodejs 中的 aes-256-cfb

我仔细观察了一下,nodejs 使用 aes-256-cfb 生成的密文,其长度和明文是相同的。

aes-cfb-enc.js
'use strict';

const crypto = require('crypto');

const cipher = crypto.createCipher('aes-256-cfb', 'a secret password with random length');
const plain = '奇跡も、魔法も、あるんだよ';

let encrypted = cipher.update('奇跡も、魔法も、あるんだよ', 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);

console.log(encrypted.length === Buffer.from(plain).length);

运行。

$ node aes-cfb-enc.js
true

如此看来,生成的密文中应该没有包含 IV。

接下来,我试着把 golang 生成的 IV 和真正的密文分开查看。

@@ -29,5 +29,6 @@ func main() {
		stream := cipher.NewCFBEncrypter(block, iv)
		stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)

-		log.Printf("%x\n", ciphertext)
+		log.Printf("IV: %x\n", ciphertext[:aes.BlockSize])
+		log.Printf("Encrypted: %x\n", ciphertext[aes.BlockSize:])
 }

运行。

$ go run aes-cfb-enc.go
IV: 482c948379dfdd5ac38631e4732cb6f4
Encrypted: dd2d0766fe48dd1da1f0a50518aa002355d0144df519227ece16a89c8d83f9b9647ec0cb983362

然而,即便剥开了 IV,把密文放在刚刚 nodejs 的代码下也是无法解密的。

@@ -4,7 +4,7 @@ const crypto = require('crypto');

 const decipher = crypto.createDecipher('aes-256-cfb', 'a secret password 0123456789abcd');

-let plain = decipher.update('435104e76d082bc7af7d26baaee3f6ca400f49b8e413905585dbb145e59c99eea1fa78395c620e72036e4f2eb60655159acbf64400f499', 'hex');
+let plain = decipher.update('dd2d0766fe48dd1da1f0a50518aa002355d0144df519227ece16a89c8d83f9b9647ec0cb983362', 'hex');
 plain = Buffer.concat([plain, decipher.final()]);

 console.log(plain.toString('utf8'));

运行。

$ node aes-cfb-dec.js
��y}_�:�7�!���垳�a���Hȁa�[*����2�{�

于是我又查看了 nodejs 这边的文档,发现除了crypto.createDecipher(algorithm, password)以外,还有一个方法叫做crypto.createDecipheriv(algorithm, key, iv),后者顾名思义就是可以显式地指定 IV。我试着改用这个方法。

aes-cfb-dec.js
'use strict';

const crypto = require('crypto');

const iv = Buffer.from('482c948379dfdd5ac38631e4732cb6f4', 'hex');
const decipher = crypto.createDecipheriv('aes-256-cfb', 'a secret password 0123456789abcd', iv);

let plain = decipher.update('dd2d0766fe48dd1da1f0a50518aa002355d0144df519227ece16a89c8d83f9b9647ec0cb983362', 'hex');
plain = Buffer.concat([plain, decipher.final()]);

console.log(plain.toString('utf8'));

运行。

$ node aes-cfb-dec.js
奇跡も、魔法も、あるんだよ

成功了,果然奇迹和魔法都是存在的呀!( 这样第二个问题算是解决了。

crypto.createDeciphercrypto.createDecipheriv

如此看来,crypto.createDecipher(algorithm, password)应该是对crypto.createDecipheriv(algorithm, key, iv)的一个封装。我也注意到了,前者用的是password,后者用的是key,这两者存在着什么区别?

根据 nodejs 的文档。

The implementation of crypto.createDecipher() 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.

crypto

终于真相大白。使用crypto.createDecipher(algorithm, password)的时候,会调用 OpenSSL 的EVP_BytesToKey函数,根据password计算出 key 和 IV,再以此调用crypto.createDecipheriv(algorithm, key, iv)方法的。这里的 key 就是严格长度的 key,我也尝试了一下,如果 key 的长度不是 16, 24 或者 32 字节的话,调用crypto.createDecipheriv(algorithm, key, iv)也会报错。

crypto.js:265
  this._handle.initiv(cipher, toBuf(key), toBuf(iv));
               ^

Error: Invalid key length

那么再进一步,OpenSSL 的EVP_BytesToKey函数又是如何根据password计算出 key 和 IV 的呢?

根据 OpenSSL 的文档,该方法的签名为

#include <openssl/evp.h>

int EVP_BytesToKey(const EVP_CIPHER *type,const EVP_MD *md,
                      const unsigned char *salt,
                      const unsigned char *data, int datal, int count,
                      unsigned char *key,unsigned char *iv);

对于具体的迭代算法,说明如下

KEY DERIVATION ALGORITHM

The key and IV is derived by concatenating D_1, D_2, etc until enough data is available for the key and IV. D_i is defined as:

        D_i = HASH^count(D_(i-1) || data || salt)

where || denotes concatentaion, D_0 is empty, HASH is the digest algorithm in use, HASH^1(data) is simply HASH(data), HASH^2(data) is HASH(HASH(data)) and so on.

The initial bytes are used for the key and the subsequent bytes for the IV.

算法已经越来越清晰。EVP_BytesToKey对传入的password进行了哈希迭代,算出 key 和 IV。哈希算法是确定的,根据同一个password生成的 key 和 IV 也是对应相同的,利用它们加密的结果也一定是相同的,所以在 nodejs 中调用crypto.createCipher(algorithm, password)时结果的密文中没有显式地给出 IV,而且每次得出的密文都相同。相比之下,golang 中的 key 是直接指定的,IV 是随机的,每次加密的结果都不相同,所以有必要在结果前面附上 IV。

nodejs 文档说明了在调用EVP_BytesToKey时并没有加盐。但是 nodejs 在调用这个方法的时采用的是哪种 HASH ,文档中却没有说明,于是我查看了源码。在 src/node_crypto.cc 的第 3331 行,我找到了EVP_BytesToKey的调用,确认了 HASH 为 MD5,也证实了确实没有加盐。

src/node_crypto.cc
int key_len = EVP_BytesToKey(cipher,
                             EVP_md5(),
                             nullptr,
                             reinterpret_cast<const unsigned char*>(key_buf),
                             key_buf_len,
                             1,
                             key,
                             iv);

一切都连在了一起,现在可以试着在 golang 下实现 nodejs 中 crypto.createCipher(algorithm, password) algorithm 为 aes-256-cfb 的情况了。

golang 版的 crypto.createCipher

aes-cfb-enc.go
package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/md5"
	"log"
)

func main() {
	log.SetFlags(0)

	// password 不限制长度
	password := "a secret password (not key) with random length"
	plaintext := []byte("奇跡も、魔法も、あるんだよ")

	// AES256 的 key 为 32 字节,正好是两个 md5sum 的输出长度
	// IV 长度为 16 字节(aes.BlockSize),正好是一个 md5sum 的输出长度
	key, iv := byteToKey(password)

	block, err := aes.NewCipher(key)
	if err != nil {
		log.Fatalln(err)
	}

	ciphertext := make([]byte, len(plaintext))

	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(ciphertext, plaintext)

	log.Printf("%x\n", ciphertext)
}

func byteToKey(password string) ([]byte, []byte) {
	pass := []byte(password)

	hash0 := []byte{}
	hash1 := md5.Sum(append(hash0, pass...))
	hash2 := md5.Sum(append(hash1[:], pass...))
	hash3 := md5.Sum(append(hash2[:], pass...))

	key := append(hash1[:], hash2[:]...)
	iv := hash3[:]

	return key, iv
}

运行。

$ go run aes-cfb-enc.go
64331d6bce93e266cabc6622f9a6a15c88350bae20d1536c31cc853e8036aeabf39b5e2c7001d9

将输出的密文套用到 nodejs 的解密函数中。

aes-cfb-dec.js
'use strict';

const crypto = require('crypto');

const decipher = crypto.createDecipher('aes-256-cfb', 'a secret password (not key) with random length');

let plain = decipher.update('64331d6bce93e266cabc6622f9a6a15c88350bae20d1536c31cc853e8036aeabf39b5e2c7001d9', 'hex');
plain = Buffer.concat([plain, decipher.final()]);

console.log(plain.toString('utf8'));

运行。

$ node aes-cfb-dec.js
奇跡も、魔法も、あるんだよ

完美解决。

AES-128 与 AES-192

上面说的都是 AES-256,那么 AES-128 和 AES-192 呢?根据EVP_BytesToKey的文档,HASH 迭代只进行到生成出的长度能满足 key 和 IV 的长度为止,那么只要对上面的byteToKey作出调整,就可以处理 AES-128 和 AES-192 的情况了。

aes-cfb-enc.go
func byteToKey(password string, keylen int) ([]byte, []byte) {
	pass := []byte(password)

	prev := []byte{}

	key := []byte{}
	iv := []byte{}

	remain := 0
	for len(key) < keylen {
		hash := md5.Sum(append(prev, pass...))
		remain = keylen - len(key)
		if remain < 16 {
			key = append(key, hash[:remain]...)
		} else {
			key = append(key, hash[:]...)
		}
		prev = hash[:]
	}

	hash := md5.Sum(append(prev, pass...))
	if remain < 16 {
		iv = append(prev[remain:], hash[:remain]...)
	} else {
		iv = hash[:]
	}

	return key, iv
}

然后将调用的地方修改一下,改为生成 24 字节长的 key,实现的就是 aes-192-cfb 了。

@@ -16,7 +16,7 @@ func main() {

		// AES256 的 key 为 32 字节,正好是两个 md5sum 的输出长度
		// IV 长度为 16 字节(aes.BlockSize),正好是一个 md5sum 的输出长度
-		key, iv := byteToKey(password)
+		key, iv := byteToKey(password, 24)

		block, err := aes.NewCipher(key)
		if err != nil {

运行。

$ go run aes-cfb-enc.go
cbb313086e676cb9aff74da3c5c13bade926330ac75f6e5648944aef2c938c282190fdc8dcda06

使用 nodejs 解密。

aes-cfb-dec.js
'use strict';

const crypto = require('crypto');

const decipher = crypto.createDecipher('aes-192-cfb', 'a secret password (not key) with random length');

let plain = decipher.update('cbb313086e676cb9aff74da3c5c13bade926330ac75f6e5648944aef2c938c282190fdc8dcda06', 'hex');
plain = Buffer.concat([plain, decipher.final()]);

console.log(plain.toString('utf8'));

运行。

$ node aes-cfb-dec.js
奇跡も、魔法も、あるんだよ

对于 aes-128-cfb,只需将 24 改为 16 即可,这里就不示范了。

使用 pbkdf2

值得注意的是,根据 nodejs 文档的说法,crypto.createCipher(algorithm, password)crypto.createDecipher(algorithm, password)都算是“偷懒”的方法,EVP_BytesToKey也并不被 OpenSSL 所推荐,更安全的替代品是 PBKDF2 (RFC 2898)。

In line with OpenSSL’s recommendation to use pbkdf2 instead of EVP_BytesToKey it is recommended that developers derive a key and IV on their own using crypto.pbkdf2() and to use crypto.createCipheriv() to create the Cipher object.

crypto

下面是一个使用 pbkdf2(sha256, 32 位, 2333 次迭代) + aes-256-cfb,golang 加密,nodejs 解密的例子:

aes-cfb-enc.go
package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"io"
	"log"

	"golang.org/x/crypto/pbkdf2"
)

func main() {
	log.SetFlags(0)

	password := []byte("めぐみん")
	salt := []byte("MDZZ Aqua")
	plaintext := []byte("この素晴らしい世界に祝福を!")

	ciphertext := make([]byte, aes.BlockSize+len(plaintext))
	key := pbkdf2.Key(password, salt, 2333, 32, sha256.New)
	iv := ciphertext[:aes.BlockSize]
	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
		log.Fatalln(err)
	}

	block, err := aes.NewCipher(key)
	if err != nil {
		log.Fatalln(err)
	}

	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext)

	log.Printf("%x\n", ciphertext)
}
$ go run aes-cfb-enc.go
bf814075e0925618076b5b6ab522ab398650283d3fcf839df51eb8d86ea497fda66655c7ca85880bfe78d8e31b3a322f615c177d2272a1ec
aes-cfb-dec.js
'use strict';

const crypto = require('crypto');

const password = 'めぐみん';
const salt = 'MDZZ Aqua';

const ciphertext = Buffer.from('bf814075e0925618076b5b6ab522ab398650283d3fcf839df51eb8d86ea497fda66655c7ca85880bfe78d8e31b3a322f615c177d2272a1ec', 'hex');
const iv = ciphertext.slice(0, 16);
const key = crypto.pbkdf2Sync(password, salt, 2333, 32, 'sha256');

const decipher = crypto.createDecipheriv('aes-256-cfb', key, iv);
const plain = Buffer.concat([decipher.update(ciphertext.slice(16)), decipher.final()]);

console.log(plain.toString('utf8'));
$ node aes-cfb-dec.js
この素晴らしい世界に祝福を!

下面是一个使用 pbkdf2(sha256, 32 位, 2333 次迭代) + aes-256-cfb,nodejs 加密,golang 解密的例子:

aes-cfb-enc.js
'use strict';

const crypto = require('crypto');

const password = 'めぐみん';
const salt = 'MDZZ Aqua';
const plain = 'この素晴らしい世界に祝福を!';

const iv = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(password, salt, 2333, 32, 'sha256');

const cipher = crypto.createCipheriv('aes-256-cfb', key, iv);
const ciphertext = Buffer.concat([iv, cipher.update(plain, 'utf8'), cipher.final()]);

console.log(ciphertext.toString('hex'));
$ node aes-cfb-enc.js
778dec089bf242288cfed3007e9ab2dbccdd832c84aa845a9b1889ef74bea838e8860ad1816621d8c15c49bd0ebb80f1c62303af52df2d16
aes-cfb-dec.go
package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/sha256"
	"encoding/hex"
	"log"

	"golang.org/x/crypto/pbkdf2"
)

func main() {
	log.SetFlags(0)

	password := []byte("めぐみん")
	salt := []byte("MDZZ Aqua")
	ciphertext, _ := hex.DecodeString("778dec089bf242288cfed3007e9ab2dbccdd832c84aa845a9b1889ef74bea838e8860ad1816621d8c15c49bd0ebb80f1c62303af52df2d16")

	plaintext := make([]byte, len(ciphertext)-aes.BlockSize)
	key := pbkdf2.Key(password, salt, 2333, 32, sha256.New)
	iv := ciphertext[:aes.BlockSize]

	block, err := aes.NewCipher(key)
	if err != nil {
		log.Fatalln(err)
	}

	stream := cipher.NewCFBDecrypter(block, iv)
	stream.XORKeyStream(plaintext, ciphertext[aes.BlockSize:])

	log.Printf("%s\n", plaintext)
}
$ go run aes-cfb-dec.go
この素晴らしい世界に祝福を!

知识共享许可协议
本文采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可。

本文链接:https://ekyu.moe/article/aes-cfb-in-golang-and-nodejs/