关于 Node.JS 中 AES 加密库“crypto-js”的问题

Question about AES encryption library "crypto-js" in Node.JS

提问人:MengCheng Wei 提问时间:11/17/2023 最后编辑:MengCheng Wei 更新时间:11/17/2023 访问量:107

问:

这是我的示例代码

const CryptoJS = require('crypto-js')

const plaintext = "hello world"
const passphrase = "my_passphrase"

const encrypted = CryptoJS.AES.encrypt(plaintext, passphrase)
console.log("plaintext =", plaintext)
console.log("passphrase =", passphrase)
console.log("-----------------------------------------------------------")
console.log("key =", encrypted.key+'')
console.log("iv =", encrypted.iv+'')
console.log("salt =", encrypted.salt+'')
console.log("encrypted =", encrypted+'')

console.log("-----------------------------------------------------------")

var decrypted = CryptoJS.AES.decrypt(encrypted.toString(), passphrase);
console.log("decrypted =", decrypted.toString(CryptoJS.enc.Utf8))

输出如下:

plaintext = hello world
passphrase = my_passphrase
-----------------------------------------------------------
key = 7bb8ed7c0c9ad5b714a57073068f441dfbf032173e60bf61deea2f9a5ea2ad3a
iv = 72b8e7e60fbcf1328fd1994ea2cc7f06
salt = 03a04d2d438b4cac
encrypted = U2FsdGVkX18DoE0tQ4tMrKBbK/veZm1k0vGmFxl6sow=
-----------------------------------------------------------
decrypted = hello world

据我所知,从输出中可以看出,在内部加密过程中,自动从密码短语中派生出带有随机盐和随机 IV 值的实际密钥,然后使用派生的密钥对明文进行加密。crypto-js

我的问题是,既然盐和IV是随机生成的,那么为什么解密函数只能使用密钥密码获得相同的AES密钥?会不会是加密数据中嵌入的盐和IV?如果是这样,用不用盐并不重要,对吧?

节点.js AES

评论

1赞 President James K. Polk 11/17/2023
这看起来像是用于密码密钥派生的旧 openssl 专有格式。是的,盐嵌入在密文中。盐与密码/密码一起用于派生密钥和 IV。最好认为该方法已被弃用,并使用像 argon2 这样的现代算法。
0赞 dave_thompson_085 11/17/2023
@President:是的,这被设计为与 OpenSSL 相同(从 OpenSSL 从 MD5 切换到 SHA256 的 1.1.0 之前)

答:

-1赞 saidtechnology 11/17/2023 #1

是的,没错,但我将这些问题总结为两点的答案,我希望你能解释一下

第一个,在加密密码时,CryptoJS 会生成一个随机盐和初始化向量 (IV)。这些用于派生加密密钥。然后,盐、IV 和加密消息被组合成最终的密文。

在第二种情况下,在解密密码时,CryptoJS 从密文中提取 salt 和 IV,并将它们与密码一起使用来派生原始加密密钥。然后,此密钥用于将密文的其余部分解密回原始明文。

评论

2赞 Topaco 11/17/2023
这只是部分正确。CryptoJS 在加密过程中会生成一个随机的 8 字节盐,但没有 IV。IV 与密钥一起派生(通过 OpenSSL 专有密钥派生函数,并将密码和盐作为输入)。EVP_BytesToKey()
1赞 dave_thompson_085 11/17/2023
不,只有盐是随机的并存储在文件中;key 和 IV 源自 password plus salt。请参阅对 Q 和 crypto.stackexchange.com/questions/8776/what-is-u2fsdgvkx1 的评论,并详细了解 crypto.stackexchange.com/questions/3298/#35614security.stackexchange.com/questions/20628
1赞 Topaco 11/17/2023
此外,CryptoJS 返回一个对象。只有 it(当与字符串连接时在代码中式调用)以 Base64 编码的 OpenSSL 格式返回结果,该格式包含作为前缀的 ASCII 编码(因此在 Base64 编码时始终恒定的开始)、salt 和密文。IV 不包含(因为它是派生的)。CipherParamstoString()encryptedSalted__U2FsdGVkX1
1赞 Topaco 11/17/2023 #2

据我所知,从输出中可以看出,crypto-js 在内部加密过程中自动从密码中派生出带有随机盐和随机 IV 值的实际密钥,然后使用派生的密钥对明文进行加密。

这只是部分正确。当密钥材料作为字符串传递时,CryptoJS 确实使用密钥派生函数。但是,在这种情况下,加密期间只会生成一个随机的 8 字节盐,而不是 IV。IV 与密钥一起使用密钥派生函数派生,并根据使用的算法(默认为 AES-256)将密码和盐作为输入。然后应用派生的密钥和 IV(默认使用 CBC 模式和 PKCS#7 填充)执行加密。
请注意,随机盐对于安全性很重要,因为它会为每种加密生成不同的密钥/IV 对。重用密钥/IV 对将意味着或多或少的严重漏洞,具体取决于模式。

我的问题是,既然盐和IV是随机生成的,那么为什么解密函数只能使用密钥密码获得相同的AES密钥?

它不能。使用密钥派生函数时,盐必须以某种形式传递到解密端(但不是 IV,因为它是派生的)。然后,在解密过程中,使用密钥派生函数使用 salt 和 password 来重建密钥和 IV。最后,使用以这种方式派生的密钥和 IV 进行解密。

会不会是加密数据中嵌入的盐和IV?

盐可以以不同的方式传递(如前所述,IV 在派生时不会传递),例如封装在 CipherParams 对象中(包装密钥、iv、盐和密文)。
A 也是由 生成的,因此返回值可以直接传递给 。但是,如果对象仅包含盐和密文,则足以进行解密。
或者,可以从对象中提取密文和盐,并以任何所需的格式传递到解密端。
一种特殊的格式是 Base64 编码的 OpenSSL 格式,它按此顺序连接 、 salt 和密文的 ASCII 编码。Base64 编码的 OpenSSL 格式的特点是,由于前缀不变,它总是以开头。CryptoJS 直接通过对象的函数支持这种格式。
CryptoJS.AES.decrypt()CipherParamsCryptoJS.AES.encrypt()CryptoJS.AES.decrypt()CipherParamsCipherParamsSalted__U2FsdGVkX1toString()CipherParams

以下脚本演示了这一点:

var password = "test passphrase"
var plaintext = "The quick brown fox jumps over the lazy dog"

// test 1: pass CipherParams object directly from encrypt() to decrypt()
var dataCP = CryptoJS.AES.encrypt(plaintext, password)
console.log("test 1:", CryptoJS.AES.decrypt(dataCP, password).toString(CryptoJS.enc.Utf8))

// test 2: pass a fresh CipherParams object to decrypt() that contains only salt and ciphertext
var dataCP_onlySaltAndCiphertext = CryptoJS.lib.CipherParams.create({ salt: dataCP.salt, ciphertext: dataCP.ciphertext })
console.log("test 2:", CryptoJS.AES.decrypt(dataCP_onlySaltAndCiphertext, password).toString(CryptoJS.enc.Utf8))
console.log("       ", CryptoJS.AES.decrypt({ salt: dataCP.salt, ciphertext: dataCP.ciphertext }, password).toString(CryptoJS.enc.Utf8)) // or more compact

// test 3: pass data to decrypt() in Base64 encoded OpenSSL format using toString()
var dataOpenSSL = dataCP.toString()
console.log("test 3:", CryptoJS.AES.decrypt(dataOpenSSL, password).toString(CryptoJS.enc.Utf8))

// test 4: pass data to decrypt() in explicitly generated Base64 encoded OpenSSL format 
var dataOpenSSL_explicit = CryptoJS.enc.Utf8.parse("Salted__").concat(dataCP.salt).concat(dataCP.ciphertext).toString(CryptoJS.enc.Base64)
console.log("test 4:", CryptoJS.AES.decrypt(dataOpenSSL_explicit, password).toString(CryptoJS.enc.Utf8))
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"></script>

如果是这样,用不用盐并不重要,对吧?

这个问题我不太清楚。如果你的意思是 CryptoJS 隐式处理盐,那么是的(例如,在 OpenSSL 格式中)。如果你问是否可以在密钥派生过程中禁用盐:不,这在 CryptoJS 中是不可能的(与 OpenSSL 不同)。当然,出于安全原因,也不建议禁用盐。


密钥派生函数的详细信息:

CryptoJS 应用 OpenSSL 专有密钥派生函数 EVP_BytesToKey(),迭代计数为 1,MD5 作为摘要。结合加密数据的 OpenSSL 格式,这实现了与 OpenSSL 的兼容性(只要 MD5 用作 OpenSSL 摘要)。
MD5 是旧版 OpenSSL 中的默认摘要。但是,从 v1.1.0 开始,OpenSSL 切换到 SHA-256 作为默认摘要。因此,只有在 OpenSSL 语句中使用 将摘要显式指定为 MD5 时,CryptoJS 才与较新的 OpenSSL 版本兼容。
注意:现在被认为是不安全的,特别是因为迭代次数为 1 并且使用了损坏的摘要 MD5。相反,对于新的实现,至少应该应用 PBKDF2(CryptoJS 和 OpenSSL 都支持)或者,如果可用,则应用 Argon2。
-mdEVP_BytesToKey()

请注意,如果密钥材料作为 传递,则不执行密钥派生,但密钥材料直接用作密钥,此处为 s。然后,必须将 IV 显式指定为(对于使用一个 IV 的所有模式)。WordArrayWordArray

以下脚本显式执行内置密钥派生,并显示两个结果是等效的:

// 1. Encrypt with built-in key derivation
var password = "test passphrase"
var plaintext = "The quick brown fox jumps over the lazy dog"
var encryptedCP = CryptoJS.AES.encrypt(plaintext, password) // keymaterial is a string => encryption with key derivation
var saltWA = encryptedCP.salt
console.log("salt", saltWA.toString())
console.log("ciphertext, OpenSSL format, built-in", encryptedCP.toString())

// 2. Encrypt with explicit key derivation
// - Generate 32 bytes key key and 16 bytes IV using EVP_BytesToKey using password and salt from above
var keySize = 8; // key size for AES-256: 8 words (a 4 bytes) = 32 bytes
var ivSize = 4;  // iv size for AES: 4 words (a 4 bytes) = 16 bytes
var keyIvWA = CryptoJS.EvpKDF(password, saltWA, {keySize: keySize + ivSize, iterations: 1, hasher: CryptoJS.algo.MD5})
var keyWA = CryptoJS.lib.WordArray.create(keyIvWA.words.slice(0, keySize), keySize * 4)
var ivWA = CryptoJS.lib.WordArray.create(keyIvWA.words.slice(keySize), ivSize * 4)
// - Encrypt with AES-256 in CBC mode (default) and PKCS#7 padding (default) without built-in key derivation
var encryptedCP = CryptoJS.AES.encrypt(plaintext, keyWA, {iv: ivWA}) // keymaterial is a WordArray => encryption without key derivation
var encryptedCPOpenSSL = CryptoJS.enc.Utf8.parse("Salted__").concat(saltWA).concat(encryptedCP.ciphertext).toString(CryptoJS.enc.Base64)
console.log("ciphertext, OpenSSL format, explicit", encryptedCPOpenSSL)

// 3. Decrypt
var decrypted = CryptoJS.AES.decrypt(encryptedCPOpenSSL, password)
console.log(decrypted.toString(CryptoJS.enc.Utf8))
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"></script>


请注意,CryptoJS的开发最近已停止,并且该库不再维护。推荐的替代方案是 WebCrypto 或 NodeJS 的加密模块。

评论

0赞 MengCheng Wei 11/18/2023
谢谢你,我现在很清楚了,但仍然有几个问题。由于从给定的 Salt 和密码中派生 Key 和 IV 需要许多参数,例如算法 (MD5/Sha1/Sha256) 和迭代编号。那么如何让接收者仅通过已知的 Salt 和密码获得相同的 IV 和 Key?我应该明确告诉他我使用的派生函数的参数吗?我正在尝试通过 NodeJS (crypto-js) 加密消息并通过 Python 解密以进行倾斜,因为我认为这是一个标准,应该是无语言的。但是我仍然无法从 Python 代码中派生出正确的 Key 和 IV。
0赞 MengCheng Wei 11/18/2023
另一个问题是,当我使用该函数时,用于派生 IV 和 Key 的默认算法和迭代是什么?CryptoJS.AES.encrypt(plaintext, passphrase)
0赞 MengCheng Wei 11/18/2023
啊,我从 stackoverflow.com/questions/36762098/ 那里找到了答案......关键思想是“你必须实现OpenSSL的EVP_BytesToKey”。
1赞 Topaco 11/18/2023
@MengChengWei - 回复 1:双方必须就密钥派生函数和使用的参数达成一致,这些都不是秘密。评论2:在我的回答中,CryptoJS使用MD5作为摘要,迭代计数为1。评论3:如前所述,是不安全的,只能用于令人信服的理由,例如,如果遗留应用程序需要它。这似乎不适用于您的用例。我建议使用像 PBKDF2 这样的标准(由 CryptoJS 以及各种 Python 库支持,这也保存了 Python 实现)。EVP_BytesToKey()