JavaScript 中对称加密的 4 种方法 / 如何使用 JavaScript 实现 AES
4个平台
处理的是数据,而不是文本
密码不是密钥
数据哈希
从密码到钥匙!
什么是运行模式?
让我们加密一些东西。
更新
赞!
参考资料和实用链接
大多数情况下,安全的互联网系统都使用HTTPS协议(基于SSL/TLS的HTTP协议),因此所有从浏览器发送到服务器的数据,包括路径,都会被加密传输到服务器端,并在服务器端解密。同样,所有从服务器端返回的数据也会在浏览器端进行加密和解密。这类系统可以保护我们免受连接过程中的拦截,通常来说,这已经足够了。
但是,想象一下,你不能将明文存储在数据库中。你想在发送前通过浏览器进行加密,因为你不想接触或承担明文安全责任。或者,你只是想在上传文件后再进行解密,甚至只是想在浏览器中加密或解密电子邮件。这类加密的应用场景不胜枚举。
本文将介绍如何使用 JavaScript 在浏览器端,甚至在服务器端(使用 Node.js)实现最常用的对称加密算法。关于非对称加密,我以后可以再写,但本文篇幅已经足够了。
4个平台
目前至少有 4 个重要的平台可以使用 JavaScript 构建加密系统。
-
自 2015 年左右的 0.10.x 版本起,Node 原生实现了该功能,并已更新至最新版本(请查看最新文档:https://nodejs.org/api/crypto.html#crypto_crypto);
-
这是 W3C 自 2012 年以来一直推荐的名为 Web Cryptography API 的 API 的原生实现(查看 2017 年的最新推荐:https://www.w3.org/TR/WebCryptoAPI/),并且已被所有浏览器支持(https://caniuse.com/#feat=cryptography ) (您也可以在此处查看浏览器中的实现细节:https://diafygi.github.io/webcrypto-examples/)。这是目前为止推荐的解决方案,它解决了使用 JavaScript 处理密码系统时的一些经典问题;
-
这是一个非常出色且完整的纯 JavaScript 实现,最初于 2009 年发布!它在 2013 年被放弃维护,4 个月后又被重新采用。它被称为 CryptoJS,目前仍有约 92,000 个项目在 GitHub 上使用它;
-
此外,还有一个非常强大且现代化的纯 JavaScript 实现,名为 Forge。它于 2013 年首次发布,至今仍在更新,GitHub 上有 196.5 万个项目正在使用它!
-
此外,该 gist 上维护着一个庞大的 JavaScript 加密库列表:https://gist.github.com/jo/8619441;以及该页面:http://cryptojs.altervista.org/。
总的来说,就发布版本而言,加密技术和 JavaScript 都是一个相当新的领域,尤其与其他语言及其通常内置于标准库中的 OpenSSL 封装器相比更是如此。我找到了一篇对过去 10 年所有与 JavaScript 加密相关的主要讨论的精彩总结,值得一看:http://blog.kotowicz.net/2014/07/js-crypto-goto-fail.html。
在使用密码系统时,务必清楚自己在做什么,并了解最新的漏洞和建议。密码系统就像一条链子,系统的强度始终取决于其中最薄弱的环节。
在本文中,我将演示如何使用这些工具进行比较,并解释 JavaScript 中对称加密的一些概念。
处理的是数据,而不是文本
在密码学中,我们处理的是数据,而非文本。最终,这些数据必须通过纯文本字段传输,因此也需要以文本形式表示。UTF-8 字符由 1 到 4 个字节组成,而且 UTF-8 编码中还有大量字节无法表示(例如控制字符),因此 UTF-8 并非高效的数据表示方式。十六进制是处理数据最易读的方式,而且便于共享,因为它每个字节使用两个字符!目前,Base64 是共享字符数据的最佳方式。
让我们来看看如何使用 Node.js 工具、浏览器 Forge 和 CryptoJS,通过 JavaScript 来浏览数据表示。
Node.js 提供了一个很好的接口来处理这些格式,它叫做 Buffer:
Buffer.from('hello world')
// <Buffer 68 65 6c 6c 6f 20 77 6f 72 6c 64>
Buffer.from('hello world').toString('hex')
// '68656c6c6f20776f726c64'
Buffer.from('hello world').toString('base64')
// 'aGVsbG8gd29ybGQ='
Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString()
// 'hello world'
Buffer.from('68656c6c6f20776f726c64', 'hex').toString()
// 'hello world'
[...Buffer.from('hello world')]
// [ 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 ]
在浏览器端,我们有 TextEncoder 用于文本格式之间的转换,还有 atob 和 btoa 函数用于 Base64 编码之间的转换。遗憾的是,要处理十六进制数,我们只能借助 toString 和 parseInt 函数进行一些简单的映射:
new TextEncoder().encode('hello world')
// Uint8Array(11) [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]
new TextDecoder().decode(new Uint8Array([104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]))
// "hello world"
[...(new TextEncoder().encode('hello world'))]
.map(b => b.toString(16).padStart(2, "0")).join('')
// "68656c6c6f20776f726c64"
"68656c6c6f20776f726c64".match(/.{1,2}/g)
.map(e => String.fromCharCode(parseInt(e, 16))).join('')
// 'hello world'
btoa('hello world')
// "aGVsbG8gd29ybGQ="
atob('aGVsbG8gd29ybGQ=')
// "hello world"
CryptoJS 使用的接口与 Node.js 的 Buffer 非常相似。在各种表示形式之间切换非常容易。最终,CryptoJS 使用内部表示形式来处理单词数组(32 位):
var CryptoJS = require('crypto-js')
CryptoJS.enc.Utf8.parse('hello world')
// { words: [ 1751477356, 1864398703, 1919706112 ], sigBytes: 11 }
CryptoJS.enc.Utf8.parse('hello world').toString()
// '68656c6c6f20776f726c64'
CryptoJS.enc.Utf8.parse('hello world').toString(CryptoJS.enc.Base64)
// 'aGVsbG8gd29ybGQ='
CryptoJS.enc.Base64.parse('aGVsbG8gd29ybGQ=').toString(CryptoJS.enc.Utf8)
// 'hello world'
CryptoJS.enc.Hex.parse('68656c6c6f20776f726c64').toString(CryptoJS.enc.Utf8)
// 'hello world'
Forge 使用原生 Uint8Array 来表示数据,而且在不同格式之间转换也非常简单:
var forge = require('node-forge')
forge.util.text.utf8.encode('hello world')
// Uint8Array [ 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 ]
forge.util.binary.hex.encode('hello world')
// '68656c6c6f20776f726c64'
forge.util.binary.base64.encode(new Uint8Array([ 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 ]))
// aGVsbG8gd29ybGQ=
forge.util.binary.base64.decode('aGVsbG8gd29ybGQ=')
// Uint8Array [ 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 ]
forge.util.binary.hex.decode('68656c6c6f20776f726c64')
// Uint8Array [ 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100 ]
正如我们所见,在浏览器中,如果不借助任何工具,转换并非易事,尤其是在十六进制与整数之间进行转换时。顺便一提,在处理数据时,了解如何轻松转换进制以及理解每一步所需的格式至关重要。本文将贯穿这些概念。
密码不是密钥
浏览https://github.com/brix/crypto-js上的未解决问题时,我发现许多人提出了非常类似的问题,即关于对称加密以及如何处理密码学元素。事实上,这些问题极大地启发了我撰写本文。我想首先解释一下这些密码学元素是什么,我们需要注意哪些事项,以及如何在我们的系统中使用它们。特别是关于密钥和密码的常见误解。
所有密码系统都至少有一个密钥。对称加密使用同一个密钥进行加密和解密,而非对称加密使用两个密钥,一个用于加密,另一个用于解密。此外,还有一些基于密钥的认证系统,通过密钥可以验证数据块的真实性。哈希算法是密码系统中非常重要的组成部分,它们本身不使用密钥(尽管它们被用于构建使用密钥的系统,详见下一节)。
密钥长度并非指字符数,而是指比特数,始终如此。所有加密密钥都由一系列比特组成,这些比特不一定对应字符;而密码长度则以字符数为单位,通常密码也是由字符构成的。加密系统对密钥长度有着非常严格的要求,因为密钥长度直接影响算法的实现,例如增加或减少轮数、步数,甚至改变数据块的长度。密码通常有最小和最大长度限制,这仅仅与存储字段或防止暴力破解有关,因为密码通常用于输入哈希算法,其作用与加密密钥完全不同。
数据哈希
哈希算法是一种将数据块转换为预先设定大小的不可预测数据块的函数。一旦经过哈希处理,数据内容就永远无法还原为原始数据。此外,哈希算法必须具有抗碰撞性,即找到两个完全匹配的数据块几乎是不可能的。
最早广泛使用的哈希算法是 MD(消息摘要算法),它先后被 MD2、MD3、MD4 和 MD5 取代。MD5 在本世纪初首次被破解(这里有一个关于其弱点的演示:https://www.mscs.dal.ca/~selinger/md5collision/)。之后,基于 MD4 的 SHA1(安全哈希算法)被开发出来,但也很快被破解(您可以在这里查看一些漏洞:https://shattered.io/)。目前我们使用 SHA2,它是一系列能够生成 224 位、256 位、384 位或 512 位哈希值的算法。如今所有最重要的加密系统都基于 SHA2 的安全机制运行!
哈希函数几乎被所有加密系统所使用。此外,哈希函数还有一些与加密无关的用途,例如:Git 使用 SHA1 对提交的参数和提交内容进行哈希处理,以此作为提交引用。比特币使用 SHA2 的 256 位模式对整个交易区块进行两次哈希处理,并在哈希值后附加一个随机数(nonce),以确保工作量证明。在数据库中存储密码时,必须将密码哈希处理后再存储,而不能以明文形式存储。
针对哈希值最常见的攻击是彩虹表。彩虹表是预先计算好的表格,其中包含值及其对应的哈希结果。例如,尝试将以下哈希值输入8BB0CF6EB9B17D0F7D22B456F121257DC1254E1F01665370476383EA776DF414到这个哈希表中:https://md5decrypt.net/Sha256。我们只需 0.1 秒就能得到结果!防御方法是在内容末尾附加一段随机数据,然后将其与哈希值一起进行哈希运算。
防止彩虹表攻击主要有两种技术:加盐和加胡椒。盐是在原始内容后附加一段非秘密的随机数据,而胡椒也是在原始内容后附加一段随机数据,但这段数据是秘密的。盐对于每个哈希值都必须是唯一的,并且通常与内容一起存储,因为它不是秘密信息。胡椒可以在同一个应用程序中重复使用,但需要存储在存储盐和哈希结果的数据库之外。通过添加胡椒,暴力破解将变得不切实际,因为胡椒的数据是未知的。
本文提到的所有四个平台都实现了最相关的哈希函数:所有长度的 SHA1 和 SHA2。由于存在安全漏洞,MD5 从未被 Web 加密所支持。
从密码到钥匙!
通常我们使用密码来生成密钥,这个过程称为密钥派生函数(KDF)。简单来说,密码会反复经过一些哈希算法或对称加密算法的处理。
在讨论密钥派生函数 (KDF) 之前,让我先介绍另一个概念:消息认证码 (MAC)。它本质上是附加在内容上的一个代码,用于证明内容的真实性。HMAC 是基于哈希的消息认证码。它内部使用一种主要的哈希函数,通常是 SHA1,并以一种非常特定的方式分别对密码和密钥进行哈希处理。这样,只要知道密钥,我们就可以计算消息的 HMAC 值,并将其与给定的 MAC 值进行比较,就足以证明内容的完整性和真实性。我们很快就会用到 HMAC,但并非用于其最初的用途,而是用它来根据给定的密码和盐值生成一些字节。
目前最常用且最安全的密钥派生函数 (KDF) 算法之一是 PBKDF2(基于密码的密钥派生函数 2,由 RFC-8018 描述和规范:https://tools.ietf.org/html/rfc8018#section-5.2)。它可以通过增加哈希迭代次数来显著提高安全性。通常,PBKDF2 使用 HMAC 进行哈希运算,以密码作为内容,盐值作为密钥。迭代次数是指每个数据块在输出之前经过哈希运算(HMAC)的次数,之后开始对链中的下一个数据块进行哈希运算,并重复多次迭代,直到派生出足够多的数据块。这样,PBKDF2 可以生成任意数量的看似随机但实际上可复现的数据,前提是您知道密码和盐值。
让我们使用Node.js生成一个长度为256的密钥:
var crypto = require('crypto');
derivedKey = crypto.pbkdf2Sync('my password', 'a salt', 1000, 256/8, 'sha1');
console.log(derivedKey.toString('hex'));
// 8925b9320d0fd85e75b6aa2b2f4e8ecab3c6301e0e2b7bd850a700523749fbe4
还有 CryptoJS:
var CryptoJS = require('crypto-js');
CryptoJS.PBKDF2('my password', 'a salt', { keySize: 256/32, iterations: 1000 }).toString();
// 8925b9320d0fd85e75b6aa2b2f4e8ecab3c6301e0e2b7bd850a700523749fbe4
使用 Forge:
var forge = require('node-forge');
forge.util.binary.hex.encode(forge.pkcs5.pbkdf2('my password', 'a salt', 1000, 256/8))
// '8925b9320d0fd85e75b6aa2b2f4e8ecab3c6301e0e2b7bd850a700523749fbe4'
让我们在浏览器上使用 webcrypto 来尝试一下:
// firstly we need to importKey
window.crypto.subtle.importKey(
//the format that we are input
"raw",
//the input in the properly format
new TextEncoder().encode("my password"),
//the kind of key (in that case it's a password to derive a key!)
{name: "PBKDF2"},
//if I permit that this material could be exported
false,
//what I permit to be processed against that (password to derive a) key
["deriveBits", "deriveKey"]
// the derive key process
).then(keyMaterial => window.crypto.subtle.deriveKey(
{
"name": "PBKDF2",
salt: new TextEncoder().encode("a salt"),
"iterations": 1000,
"hash": "SHA-1"
},
// it should be an object of CryptoKey type
keyMaterial,
// which kind of algorithm I permit to be used with that key
{ "name": "AES-CBC", "length": 256},
// is that exportable?
true,
// what is allowed to do with that key
[ "encrypt", "decrypt" ]
)
// exporting...
).then(key => crypto.subtle.exportKey("raw", key)
).then(key => console.log(
// finally we have a ArrayBuffer representing that key!
[...(new Uint8Array(key))]
.map(b => b.toString(16).padStart(2, "0"))
.join("")
));
//8925b9320d0fd85e75b6aa2b2f4e8ecab3c6301e0e2b7bd850a700523749fbe4
如您所见,直接在浏览器中使用 WebCrypto 时,涉及密钥及其权限的诸多问题和限制。保护密钥固然重要,但这并不方便用户使用。
这些信息可以安全分享:
- 盐
- 互动
- 关键长度
- 哈希算法
增加交互次数会增加算法需要执行的基本哈希运算次数。以 HMAC 为例,每次交互至少会进行两次 SHA1 哈希运算(或者你设置的其他哈希算法)。这会导致运算速度变慢,速度必须慢到可以接受运行一两次,但又很难被暴力破解,尽量别让你的浏览器卡死哈哈!
好的盐必须随机选择,我们也可以在4个平台上进行选择:
Node.js:
const crypto = require('crypto');
crypto.randomBytes(8);
CryptoJS:
const CryptoJS = require('crypto-js');
CryptoJS.lib.WordArray.random(8);
锻造:
const forge = require('node-forge');
forge.random.getBytesSync(8);
WebCrypto(浏览器):
window.crypto.getRandomValues(new Uint8Array(8));
什么是运行模式?
目前最常用的对称加密算法是AES(高级加密标准)。AES是一种分组密码系统,可以使用128、192和256位密钥长度,其中每个密钥作用于128位明文块,生成128位密文。
AES加密几乎无处不在。从保护在亚马逊购买的电子书,到通过SSL加密连接,再到保护存储在浏览器中的会话cookie,以及加密手机上的数据……到处都有它的身影!
在使用 AES 等分组密码系统时,我们应该对明文进行填充,以便在解密时可以从明文中移除填充部分。最常用的填充方式是 PKSC#5/PKSC#7(也发布于 RFC-8018 https://tools.ietf.org/html/rfc8018)。
例如,一个 11 字节的十六进制数,填充 16 字节:
h e l l o w o r l d — 11 bytes
68 65 6c 6c 6f 20 77 6f 72 6c 64
68 65 6c 6c 6f 20 77 6f 72 6c 64 05 05 05 05 05 — 16 bytes
|___padding____|
我们只需重复打印需要连接的字节数来进行填充即可。(请查看我的实现:https://github.com/halan/aes.js/blob/master/src/padding.js)
顺便一提,使用分组加密时,我们需要将明文分割成大小相同的块(AES 为 128 位),然后选择一种操作模式来处理这些块,并使用密钥对其进行加密。正因如此,有时最后一个块的大小可能不足以进行加密。
本文将向您展示一种名为 CBC 的操作模式。
CBC算法首先对第一个明文块和一个称为IV(初始化向量)的特殊块进行异或运算(特殊或运算),然后用密钥加密生成第一个加密块。接着,用第一个加密块对第二个明文块进行异或运算,再用密钥加密生成第二个加密块,以此类推……更改其中一个块会导致后续块发生连锁反应,因此,即使使用相同的密钥和明文,只要确保IV是随机且不可预测的,最终结果也会完全不同。
解密时,它会执行相反的过程。首先解密第一个数据块,然后与初始化向量 (IV) 进行异或运算,得到第一个明文数据块。第二个明文数据块是通过解密第二个加密数据块并与第一个加密数据块进行异或运算得到的,依此类推……
注意,IV 必须是不可预测的,它可以是随机的,并且不需要保密。通常,它会预先与加密数据连接起来或存储在附近。而且,IV 的大小始终与数据块的长度相同。(请查看我实现的版本:https://github.com/halan/aes.js/blob/master/src/opModes.js#L12-L24)
让我们加密一些东西。
最后,我们可以将这些概念结合起来,对来自浏览器或 Node.js 的任何数据进行加密/解密。
我们的加密系统将采用以下方案:
- 使用 256 位密钥,采用 CBC 模式的 AES 加密
- 使用 PBKDF2 哈希算法和 HMAC-SHA512 算法,经过 10 万次交互,并使用 16 字节的随机盐值生成密钥。
- 随机生成的IV
- 最终格式:base64(盐 + IV + 数据)
- 我从 Enpass 的实际实现中复制了部分架构图,链接如下:https://www.enpass.io/docs/security-whitepaper-enpass/EnpassSecurityWhitepaper.pdf
请注意,除非您直接传递从 PBKDF2 派生的原始密钥,否则此方案与 openssl enc 命令行不兼容。如上所述,openssl enc 使用 EVP_BytesToKey 从加密数据前面的盐值派生密钥和初始化向量 (IV)。
Node.js
const crypto = require('crypto');
salt = crypto.randomBytes(16);
iv = crypto.randomBytes(16);
key = crypto.pbkdf2Sync('my password', salt, 100000, 256/8, 'sha256');
cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
cipher.write("That is our super secret text");
cipher.end()
encrypted = cipher.read();
console.log({
iv: iv.toString('base64'),
salt: salt.toString('base64'),
encrypted: encrypted.toString('base64'),
concatenned: Buffer.concat([salt, iv, encrypted]).toString('base64')
});
/*
{ iv: 'JaTFWNAEiWIPOANqW/j9kg==',
salt: '4DkmerTT+FXzsr55zydobA==',
encrypted: 'jE+QWbdsqYWYXRIKaUuS1q9FaGMPNJko9wOkL9pIYac=',
concatenned:
'4DkmerTT+FXzsr55zydobCWkxVjQBIliDzgDalv4/ZKMT5BZt2yphZhdEgppS5LWr0VoYw80mSj3A6Qv2khhpw==' }
*/
简单易行,我们来解密4DkmerTT+FXzsr55zydobCWkxVjQBIliDzgDalv4/ZKMT5BZt2yphZhdEgppS5LWr0VoYw80mSj3A6Qv2khhpw==。已知该数据由盐值 + 初始化向量 + 加密数据组成:
const crypto = require('crypto');
encrypted = Buffer.from('4DkmerTT+FXzsr55zydobCWkxVjQBIliDzgDalv4/ZKMT5BZt2yphZhdEgppS5LWr0VoYw80mSj3A6Qv2khhpw==', 'base64');
const salt_len = iv_len = 16;
salt = encrypted.slice(0, salt_len);
iv = encrypted.slice(0+salt_len, salt_len+iv_len);
key = crypto.pbkdf2Sync('my password', salt, 100000, 256/8, 'sha256');
decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
decipher.write(encrypted.slice(salt_len+iv_len));
decipher.end();
decrypted = decipher.read();
console.log(decrypted.toString());
// That is our super secret text
该API存在一些问题:
- 所有数据都可以表示为缓冲区、字符串、类型化数组或数据视图。write() 函数的第二个参数用于定义输入格式:utf8、hex 或 base64。read() 函数的第一个参数用于定义输出格式。
- `end()` 函数会添加填充并加密密码的最后一个数据块。在此之前调用 `read()` 函数会输出除最后一个数据块之外的所有数据块。`final()` 函数的作用与 `end()` 类似,但它也会输出最后一个数据块。如果在 `final()` 之前或之后运行 `read()` 函数,则会输出除最后一个数据块之外的所有数据块。`final()` 函数的第一个参数用于定义输出格式,就像我们在 `read()` 函数中看到的那样。
- 有一个 update() 函数,它的作用是添加输入并返回输出。它不会输出任何之前使用 write() 加密的数据。但是,如果通过 update() 插入的数据少于一个数据块,它将输出一个空缓冲区,并将该数据与下一个 update() 或 final() 函数连接起来。update() 函数的第二个和第三个参数分别用于指定输入和输出格式。
- Cipher 和 Decipher 也支持通过 on() 函数触发事件。我们可以监听 'readable' 和 'end' 事件。
- 所有步骤都有对应的异步函数(write()/read()、final()/end() 和 update() 除外),详情请查看文档。
锻造
const forge = require('node-forge');
const salt = forge.random.getBytesSync(16);
const iv = forge.random.getBytesSync(16);
const key = forge.pkcs5.pbkdf2('my password', salt, 100000, 256/8, 'SHA256');
const cipher = forge.cipher.createCipher('AES-CBC', key);
cipher.start({iv: iv});
cipher.update(forge.util.createBuffer('That is our super secret text'));
cipher.finish();
const encrypted = cipher.output.bytes();
console.log({
iv: forge.util.encode64(iv),
salt: forge.util.encode64(salt),
encrypted: forge.util.encode64(encrypted),
concatenned: forge.util.encode64(salt + iv + encrypted)
});
/*
{ iv: '2f0PCR5w/8a4y/5G4SGiLA==',
salt: 'sYoCiGLJ9xuH3qBLoBzNlA==',
encrypted: '9LYfj1wUrkro8+a+6f6rglHlVX9qj8N4EMC8ijMjp7Q=',
concatenned:
'sYoCiGLJ9xuH3qBLoBzNlNn9DwkecP/GuMv+RuEhoiz0th+PXBSuSujz5r7p/quCUeVVf2qPw3gQwLyKMyOntA==' }
*/
进而:
const forge = require('node-forge');
const encrypted = forge.util.binary.base64.decode('sYoCiGLJ9xuH3qBLoBzNlNn9DwkecP/GuMv+RuEhoiz0th+PXBSuSujz5r7p/quCUeVVf2qPw3gQwLyKMyOntA=='
);
const salt_len = iv_len = 16;
const salt = forge.util.createBuffer(encrypted.slice(0, salt_len));
const iv = forge.util.createBuffer(encrypted.slice(0+salt_len, salt_len+iv_len));
const key = forge.pkcs5.pbkdf2('my password', salt.bytes(), 100000, 256/8, 'SHA256');
const decipher = forge.cipher.createDecipher('AES-CBC', key);
decipher.start({iv: iv});
decipher.update(
forge.util.createBuffer(encrypted.slice(salt_len + iv_len))
);
decipher.finish();
console.log(decipher.output.toString());
// That is our super secret text
重要提示:
- pbkdf2() 函数需要字符串作为密码和盐值。因此,如果您使用的是伪造缓冲区,则必须先调用 bytes() 函数。
- cipher.update()/decipher.update() 需要一个缓冲区。
CryptoJS
const CryptoJS = require('crypto-js');
const salt = CryptoJS.lib.WordArray.random(16);
const iv = CryptoJS.lib.WordArray.random(16);
const key = CryptoJS.PBKDF2('my password', salt, { keySize: 256/32, iterations: 10000, hasher: CryptoJS.algo.SHA256});
const encrypted = CryptoJS.AES.encrypt('That is our super secret text', key, {iv: iv}).ciphertext;
const concatenned = CryptoJS.lib.WordArray.create().concat(salt).concat(iv).concat(encrypted)
console.log({
iv: iv.toString(CryptoJS.enc.Base64),
salt: salt.toString(CryptoJS.enc.Base64),
encrypted: encrypted.toString(CryptoJS.enc.Base64),
concatenned: concatenned.toString(CryptoJS.enc.Base64)
});
/*
{ iv: 'oMHnSEQGrr04p8vmrKU7lg==',
salt: 'OkEt2koR5ChtmYCZ0dXmHQ==',
encrypted: 'jAOb0LwpmaX51pv8SnTyTcWm2R14GQj0BN7tFjENliU=',
concatenned:
'OkEt2koR5ChtmYCZ0dXmHaDB50hEBq69OKfL5qylO5aMA5vQvCmZpfnWm/xKdPJNxabZHXgZCPQE3u0WMQ2WJQ==' }
*/
解密:
const CryptoJS = require('crypto-js');
const encrypted = CryptoJS.enc.Base64.parse('OkEt2koR5ChtmYCZ0dXmHaDB50hEBq69OKfL5qylO5aMA5vQvCmZpfnWm/xKdPJNxabZHXgZCPQE3u0WMQ2WJQ==');
const salt_len = iv_len = 16;
const salt = CryptoJS.lib.WordArray.create(
encrypted.words.slice(0, salt_len / 4 )
);
const iv = CryptoJS.lib.WordArray.create(
encrypted.words.slice(0 + salt_len / 4, (salt_len+iv_len) / 4 )
);
const key = CryptoJS.PBKDF2(
'my password',
salt,
{ keySize: 256/32, iterations: 10000, hasher: CryptoJS.algo.SHA256}
);
const decrypted = CryptoJS.AES.decrypt(
{
ciphertext: CryptoJS.lib.WordArray.create(
encrypted.words.slice((salt_len + iv_len) / 4)
)
},
key,
{iv: iv}
);
console.log(decrypted.toString(CryptoJS.enc.Utf8));
// That is our super secret text
重要提示:
- 如果将字符串作为密钥传递给 `encrypt()` 函数,它将进入与 OpenSSL 兼容的基于密码的加密模式(假设前 8 个字节是字符串“Salted__”,接下来的 8 个字节是用于派生初始化向量 (IV) 和密钥的盐值。这种派生方式与基于参数的密钥派生函数 (PBKDF) 不兼容,并且使用 MD5 作为核心哈希函数,因此并不安全!)。如果密钥是字符串形式,`encrypt()` 函数将忽略作为选项发送的 IV。
- 那个界面太让人困惑了,我在Github上发现了好几个与这个界面相关的问题。
- 要解密,我们需要发送一个对象,该对象具有一个名为 ciphertext 的属性,其中包含一个 WordArray(由 CryptoJS.lib 提供的一种类型)。
- WordArray 是一个长度为 4 字节的数字数组。我们可以通过 'words' 直接访问该数组。因此,切片总是被 4 分割,因为每个单词的长度是 4。
Web 加密 API
const encoder = new TextEncoder();
const toBase64 = buffer =>
btoa(String.fromCharCode(...new Uint8Array(buffer)));
const PBKDF2 = async (
password, salt, iterations,
length, hash, algorithm = 'AES-CBC') => {
keyMaterial = await window.crypto.subtle.importKey(
'raw',
encoder.encode(password),
{name: 'PBKDF2'},
false,
['deriveKey']
);
return await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(salt),
iterations,
hash
},
keyMaterial,
{ name: algorithm, length },
false, // we don't need to export our key!!!
['encrypt', 'decrypt']
);
}
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const plain_text = encoder.encode("That is our super secret text");
const key = await PBKDF2('my password', salt, 100000, 256, 'SHA-256');
const encrypted = await window.crypto.subtle.encrypt(
{name: "AES-CBC", iv },
key,
plain_text
);
console.log({
salt: toBase64(salt),
iv: toBase64(iv),
encrypted: toBase64(encrypted),
concatennated: toBase64([
...salt,
...iv,
...new Uint8Array(encrypted)
])
});
/*
{ salt: "g9cGh/FKtMV1LhnGvii6lA==",
iv: "Gi+RmKEzDwKoeDBHuHrjPQ==",
encrypted: "uRl6jYcwHazrVI+omj18UEz/aWsdbKMs8GxQKAkD9Qk=",
concatennated:
"g9cGh/FKtMV1LhnGvii6lBovkZihMw8CqHgwR7h64z25GXqNhzAdrOtUj6iaPXxQTP9pax1soyzwbFAoCQP1CQ=="}
*/
虽然很脏,但管用。我们来解密一下:
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const fromBase64 = buffer =>
Uint8Array.from(atob(buffer), c => c.charCodeAt(0));
const PBKDF2 = async (
password, salt, iterations,
length, hash, algorithm = 'AES-CBC') => {
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
encoder.encode(password),
{name: 'PBKDF2'},
false,
['deriveKey']
);
return await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: encoder.encode(salt),
iterations,
hash
},
keyMaterial,
{ name: algorithm, length },
false, // we don't need to export our key!!!
['encrypt', 'decrypt']
);
};
const salt_len = iv_len = 16;
const encrypted = fromBase64('g9cGh/FKtMV1LhnGvii6lBovkZihMw8CqHgwR7h64z25GXqNhzAdrOtUj6iaPXxQTP9pax1soyzwbFAoCQP1CQ==');
const salt = encrypted.slice(0, salt_len);
const iv = encrypted.slice(0+salt_len, salt_len+iv_len);
const key = await PBKDF2('my password', salt, 100000, 256, 'SHA-256');
const decrypted = await window.crypto.subtle.decrypt(
{ name: "AES-CBC", iv },
key,
encrypted.slice(salt_len + iv_len)
);
console.log(decoder.decode(decrypted));
需要考虑以下几点:
- importKey()、deriveKey() 和 encrypt()/decrypt() 都是异步函数。importKey() 既用于从字节数组中导入密钥,也用于导入密码以供 deriveKey() 使用。
- deriveBits() 也可用于派生密钥。如果您想要同时派生初始化向量 (IV) 和密钥,通常会使用此方法。实际上,您需要派生多个字节,然后从中取出一部分作为种子数据,以原始模式导入 importKey,从而用于加密或解密数据。
- deriveKey() 或 importKey() 的最后一个参数是允许链式调用的函数列表。
暂时就讲到这里。我希望已经介绍了足够的概念,让大家能够理解如何使用 JavaScript 加密纯文本或字符串化的 JSON 对象。
更新
- 2022年7月,“哈希函数几乎被所有加密系统所使用。此外,它还有一些与加密无关的用途。” 最初版本中,我写的是“密码学”(cryptography )而不是“加密”(cryption )。虽然密码学是一门科学,但加密是密码学内部的一个分支。当我们创建签名和哈希值来确保内容的完整性时,这并非加密,但绝对是密码学的一个范畴。
赞!
- 感谢Luan Gonçalves在我撰写本文期间给予的良好讨论,并积极审阅本文。
- 感谢Elias Rodrigues 的出色审阅,包括对代码示例的重要修复。
参考资料和实用链接
- 密码学与网络安全:原理与实践,作者:William Stallings - http://williamstallings.com/Cryptography/
- https://www.w3.org/TR/WebCryptoAPI/
- https://nodejs.org/api/crypto.html#crypto_crypto
- https://en.wikipedia.org/wiki/PBKDF2
- https://github.com/halan/aes.js - 我出于教学目的实现的 AES 加密算法
- https://tonyarcieri.com/whats-wrong-with-webcrypto
- https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/august/javascript-cryptography-considered-harmful/
- https://tankredhase.com/2014/04/13/heartbleed-and-javascript-crypto/
- https://vnhacker.blogspot.com/2014/06/why-javascript-crypto-is-useful.html
- http://blog.kotowicz.net/2014/07/js-crypto-goto-fail.html?m=1
- https://hal.inria.fr/hal-01426852/document
- https://www.slideshare.net/Channy/the-history-and-status-of-web-crypto-api
- https://www.w3.org/wiki/NetflixWebCryptoUseCase