在 Node.js 中获取正确的 256 位 AES GCM 加密标签时遇到问题

Trouble with getting correct tag for 256-bit AES GCM encryption in Node.js

我需要编写如下解密函数的逆向(加密):

const crypto = require('crypto');

let AESDecrypt = (data, key) => {
  const decoded = Buffer.from(data, 'binary');

  const nonce = decoded.slice(0, 16);
  const ciphertext = decoded.slice(16, decoded.length - 16);
  const tag = decoded.slice(decoded.length - 16);

  let decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
  decipher.setAuthTag(tag)
  decipher.setAutoPadding(false);
  try {
    let plaintext = decipher.update(ciphertext, 'binary', 'binary');
    plaintext += decipher.final('binary');
    return Buffer.from(plaintext, 'binary');
  } catch (ex) {
    console.log('AES Decrypt Failed. Exception: ', ex);
    throw ex;
  }
}

上述函数允许我按照规范正确解密加密缓冲区:

| Nonce/IV (First 16 bytes) | Ciphertext | Authentication Tag (Last 16 bytes) |

AESDecrypt 之所以这样写(auth 标记为最后 16 个字节)是因为这是 AES 的默认标准库实现在 Java 和 Go 中加密数据的方式.我需要能够在 Go、Java 和 Node.js 之间双向 decrypt/encrypt。 Node.js 中基于 crypto 库的加密不会将 auth 标记放在任何地方,并且留给开发人员他们希望如何存储它以在解密期间传递给 setAuthTag()。在上面的代码中,我将标签直接烘焙到最终的加密缓冲区中。

所以我写的AES加密函数需要满足上述情况(无需修改AESDecrypt因为它可以正常工作)并且我有以下代码不适合我:

let AESEncrypt = (data, key) => {

  const nonce = 'BfVsfgErXsbfiA00'; // Do not copy paste this line in production code (https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm)
  const encoded = Buffer.from(data, 'binary');

  const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
  try {
    let encrypted = nonce;
    encrypted += cipher.update(encoded, 'binary', 'binary')
    encrypted += cipher.final('binary');
    const tag = cipher.getAuthTag();
    encrypted += tag;
    return Buffer.from(encrypted, 'binary');
  } catch (ex) {
    console.log('AES Encrypt Failed. Exception: ', ex);
    throw ex;
  }
}


我知道对 nonce 进行硬编码是不安全的。我通过这种方式可以更轻松地使用 vbindiff

之类的二进制文件差异程序将正确加密的文件与我损坏的实现进行比较

我从不同的角度看待这个问题的次数越多,这个问题对我来说就越令人困惑。

我实际上非常习惯基于 encryption/decryption 实现 256 位 AES GCM,并且在 Go 和 Java 中有正常工作的实现。此外,由于某些情况,我在 Node.js 个月前实现了 AES 解密。

我知道这是真的,因为我可以在 Node.js 中解密我在 Java 和 Go 中加密的文件。我建立了一个快速存储库,其中包含专门为此目的编写的 Go 服务器的源代码实现和损坏的 Node.js 代码。

为了让了解 Node.js 但不了解 Go 的人轻松访问,我提供了以下 Go 服务器 Web 界面,使用托管在 https://go-aes.voiceit.io/. You can confirm my Node.js decrypt function works just fine by encrypting a file of your choice at https://go-aes.voiceit.io/, and decrypting the file using decrypt.js (Please look at the README 的上述算法进行加密和解密,以获取更多信息如果您需要确认它是否正常工作,如何运行这个。)


此外,我知道这个问题具体与以下几行AESEncrypt有关:

    const tag = cipher.getAuthTag();
    encrypted += tag;

运行 vbindiff 针对在 Go 中加密的同一文件和 Node.js,文件开始仅在最后 16 个字节(写入 auth 标记的位置)显示差异。换句话说,随机数和加密的有效负载在 Go 和 Node.js.

中是相同的

由于 getAuthTag() 非常简单,而且我相信我正在正确使用它,所以我不知道此时我什至可以更改什么。因此,我也考虑了这是标准库中的错误的可能性很小。但是,我想我会先尝试 Whosebug,然后再发布 Github 问题,因为它很可能是我做错了。

我有一个稍微更详细的代码描述,以及我如何知道什么是有效的证明,在repo我试图获得解决这个问题的帮助。

提前谢谢你。

更多信息:节点:v14.15.4 Go:go 版本 go1.15.6 darwin/amd64

在NodeJS代码中,密文生成为binary string, i.e. using the binary/latin1 or ISO-8859-1 encoding. ISO-8859-1 is a single byte charset which uniquely assigns each value between 0x00 and 0xFF to a specific character, and therefore allows the conversion of arbitrary binary data into a string without corruption, see also here

相比之下,cipher.getAuthTag() 不会将身份验证标记作为二进制字符串返回,而是作为缓冲区返回。

当连接两个部分时:

encrypted += tag;

缓冲区默认使用buf.toString(), which applies UTF-8编码隐式转换为字符串。

与 ISO-8859-1 不同,UTF-8 是一个多字节字符集,它定义了分配给字符 s 的 1 到 4 个字节长度的特定字节序列。 UTF-8 table。在任意二进制数据(例如身份验证标签)中,通常存在未针对 UTF-8 定义的字节序列,因此无效。在转换期间,无效字节由代码点为 U+FFFD 的 Unicode 替换字符表示(另请参阅@dave_thompson_085 的注释)。这会破坏数据,因为原始值会丢失。因此UTF-8编码不适合将任意二进制数据转换成字符串。

在后续转换为具有 单字节 字符集 binary/latin1 的缓冲区期间,其中:

return Buffer.from(encrypted, 'binary');

仅考虑替换字符的最后一个字节 (0xFD)。

截图中标记的字节(0xBB、0xA7、0xEA等)都是无效的UTF-8字节序列,s。 UTF-8 table,因此被 0xFD 的 NodeJS 代码替换,导致标签损坏。


修复bug,tag必须经过binary/latin1转换,即与密文的编码一致:

let AESEncrypt = (data, key) => {

    const nonce = 'BfVsfgErXsbfiA00';                                   // Static IV for test purposes only 
    const encoded = Buffer.from(data, 'binary');                        
    const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);

    let encrypted = nonce;
    encrypted += cipher.update(encoded, 'binary', 'binary');             
    encrypted += cipher.final('binary');
    const tag = cipher.getAuthTag().toString('binary');                 // Fix: Decode with binary/latin1!
    encrypted += tag;

    return Buffer.from(encrypted, 'binary'); 
}

请注意,在 update() 调用中,输入编码(第二个 'binary' 参数)被忽略,因为 encoded 是一个缓冲区。

或者,可以连接缓冲区而不是 binary/latin1 转换后的字符串:

let AESEncrypt_withBuffer = (data, key) => {

    const nonce = 'BfVsfgErXsbfiA00';                                   // Static IV for test purposes only 
    const encoded = Buffer.from(data, 'binary');                        
    const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);

    return Buffer.concat([                                              // Fix: Concatenate buffers!
        Buffer.from(nonce, 'binary'),                                     
        cipher.update(encoded), 
        cipher.final(), 
        cipher.getAuthTag()
    ]);   
}   

对于 GCM 模式,出于性能和兼容性原因,NIST 建议使用 12 字节的随机数长度,请参阅 here, chapter 5.2.1.1 and here。 Go 代码(通过 NewGCMWithNonceSize())和 NodeJS 代码应用与此不同的 16 字节的随机数长度。