最近自己的一个前端项目里遇到了加密需求,而且要求 Node 上加密之后在浏览器上解密,期间还是有几个坑的,特写此文总结一下。
做了个网页版简历,在开发中想到个人信息这部分应该支持个隐藏功能,毕竟有时候只是想公开展示一下以求修改意见,此时应当避免显示真实的信息。
要处理这种情况,最简单的方法就是用两个URL,一个显示真实的一个显示演示信息,只公开演示的,真正使用时发真实的。
想做到这点也不难,如果你没有强迫症的话。但是我想啊,简历也算个项目,就直接给它传 GitHub 开源了,顺便还能上线到 GitHub Pages 上不是美滋滋。
这就有个问题:真实的信息咋办?肯定是不能一起传上去的,而由于我用了 GitHub Pages 也就是说构建完的内容也要上传到公开仓库,虽然应该不会有人会跑到那里头扒压缩过的 JS,但我是有强迫症的,非得做到完美才行。
所以就需要一个加密上传的方案,在需要真实信息时给 URL 加个参数作为密码,在客户端解密显示真实信息。
我这简历项目使用的是 Next.js,加密自然就用 Node 的crypto
模块,然后就踩了一堆坑……
首先是加密算法的选择,对称加密如果没有什么特殊要求,可以无脑选择AES-GCM
,这个算法没啥缺陷。
然后是AES-GCM
的参数,一个用于防止彩虹表的向量IV
用可以写死,另一个就是密钥,这俩长度都是固定的,我这选择最小的128位,于是完整的算法就是AES-128-GCM
。
首先是密钥的生成,因为 AES 需要固定 128 位长度的密钥,但我们的密码长度是任意的,所以需要先做转换。这里我选择用PBKDF2
密钥生成算法,Node 的代码如下:
import crypto from "crypto";
// 下面三个全局变量可以写死在代码里,在本文后面的代码里还会用到
// 用于防止彩虹表的盐值,随机生成一个即可
export const SALT = "建议至少 16 个字节";
// 迭代次数,用于提高计算量防爆破的
export const iterations = 16;
// AES 的初始向量,随机生成一个即可
export const IV = "ZthGOhiwgZRpq0orJcvDQQ==";
function getKey(password) {
const key = crypto.pbkdf2Sync(
password, // 输入的密码
SALT,
iterations,
16, // 密钥长度,单位字节,128 位密钥就是 16 字节
"sha256"); // 散列算法,用主流的 sha256 即可
}
有了密钥就可以进行加密了:
export function encrypt(password, input) {
const key = getKey(password);
const cipher = crypto.createCipheriv("aes-128-gcm", key, Buffer.from(IV, "base64"));
const firstBlock = cipher.update(input);
const lastBlock = cipher.final();
const authTag = cipher.getAuthTag();
return Buffer.concat([firstBlock, lastBlock, authTag]).toString("base64");
}
代码也很简单,
IV
因为需要打包进JS里选择了 base64 编码所以需要解码一下。input
是要加密的字符串。firstBlock
和lastBlock
是加密后的两块数据。authTag
是校验数据,AES-128-GCM
的长度固定16字节,下面重点讲。最后把三块数据连起来作为加密后的结果返回,这个结果会被打包并上传到公共仓库。
这里有第一个坑点,AES-GCM
具有数据完整性校验功能,简单来说就是防止加密后的数据被篡改从而解密出错误的结果,为了实现此功能,AES-GCM
的输出分为两部分,一个是加密后的密文,另一个是校验数据AuthTag
,用它去验证密文是否被篡改。
(其实我这项目有HTTPS不需要再校验,但是我无脑选择了 GCM)
在其它的一些语言和库里,校验数据已经被自动处理了,比如JAVA 的源码里就是把校验数据直接附加到尾部,但是 Node 没有这么做而是要你手动处理。
所以对于GCM
加密,你不要把AuthTag
给忘了,要记得加密完同时保存下cipher.getAuthTag()
的值,然后解密时通过decipher.setAuthTag()
传入。如果忘了它则解密时会报错:
Error: Unsupported state or unable to authenticate data
这跟密码不对的错误信息是一样的,非常坑爹。
export function decrypt(password, input) {
const key = getKey(password);
// 首先是分离加密数据和校验数据,校验数据长度固定16字节
let data = Buffer.from(input, "base64");
const authTag = data.slice(data.length - 16);
data = data.slice(0, data.length - 16);
// key 和 IV 跟加密的一样
const decipher = crypto.createDecipheriv("aes-128-gcm", key, Buffer.from(IV, "base64"));
decipher.setAuthTag(authTag);
// 返回解密后的数据
return decipher.update(data) + decipher.final();
}
很简单不多说了,因为解密代码要运行在客户端的浏览器上,所以需要使用第三方库。因为我的项目用了 webpack 构建,webpack 自带 Polyfill 功能,当检测到代码使用了 Node 的库时会自动将其替换为第三方实现并打包进去。
这里我遇到了第二个坑:
webpack 的 Polyfill 用了这个库 crypto-browserify/pbkdf2,然而它有 BUG,比如同步的函数 pbkdf2Sync
没有处理 HASH 函数名的大小写,最开始我大写的摘要算法名"SHA256"
在 Node 下正常,上浏览器它就不认识了。
同时这个库没有对该参数做检查,对错误的算法名不会出异常而是生成全0的密钥,结果就是莫名其妙的密码错误,愣是让我排查了半天。
另外这个库的代码质量……我本想提个PR修下的,但是尝试半小时后还是放弃了。最后我只想说:尽量用下面的原生 API 吧,珍爱生命,远离蛇皮库。
加密功能上线后顺便用 Lighthouse 测了一下性能,提示 JS 代码严重影响性能,打开一看加密相关的 Polyfill 竟然有 800K,都占一半体积了。
于是就需要优化一下,首先想到的就是浏览器原生的加密接口window.crypto
。这个接口相关的标准是2012年起草的,现在的主流浏览器基本都支持了可以放心使用。
注意一些浏览器会对这个 API 做限制,比如 Android 版 Firefox 要求必须在 HTTPS 加密的页面上才能使用,如果是 HTTP 则
window.crypto.subtle
为undefined
。
浏览器加密的 API 还是比 Node 复杂一点点,而且不知道是不是前端加密需求少的原因,目前相关的教程不是很多,想了解更多功能的话我推荐看 Node 的官方文档 https://nodejs.org/dist/latest-v16.x/docs/api/webcrypto.html,写的比 MDN 详细。
跟 Node 那头对应,首先还是密钥的生成:
async function getKey(password) {
// 密码和盐都是字符串形式,需要转换为字节类型供加密API使用
const encoder = new TextEncoder();
// WebCryptoAPI 的 API 使用的密钥都是 CryptoKey 类型,所以要先把用密码包装成 CryptoKey
const keyMaterial = await crypto.subtle.importKey(
"raw", // raw 表示密码直接作为原始数据
encoder.encode(password), // 密码
"PBKDF2", // 生成的密钥给下面的 PBKDF2 算法用
false, // 不需要导出
["deriveKey"]); // 生成的密钥将用于 deriveKey 函数
// 跟前面一样,把原始的密码转换为固定长度的密钥给 AES 算法用
return crypto.subtle.deriveKey({
name: "PBKDF2", // 密钥生成算法 PBKDF2
hash: "SHA-256", // 散列算法,跟 Node 那边一样
salt: encoder.encode(SALT), // 盐值,跟 Node 那边一样
iterations, // 迭代次数,跟 Node 那边一样
}, keyMaterial, {
name: "AES-GCM", // 生成的密钥用于 AES-GCM
length: 128, // 输出的密钥长度 128 位
}, false, ["encrypt", "decrypt"]); // 不需要导出,密钥可用于加密和解密
}
参数还不少,比起 Node 一行搞定还是麻烦了许多。
转换好密钥之后就可以用它来加密和解密了:
async function encrypt(password, input) {
const key = await getKey(password);
// 加密算法是 AES-GCM,向量跟 Node 那边的一样
const aesParams = {
name: "AES-GCM",
iv: base64Decode(IV),
};
// 加密数据,input 也要是字节数据类型
const data = await crypto.subtle.encrypt(aesParams, key, input);
// 转换为 base64,跟 Node 那头保持一致
return btoa(new TextDecoder().decode(data));
}
async function decrypt(password, input) {
const key = await getKey(password);
// 解码 base64 字符串,浏览器竟然没有一个原生的 API
const data = Uint8Array.from(atob(input), c => c.charCodeAt(0));
const aesParams = {
name: "AES-GCM",
iv: base64Decode(IV),
};
// 最后调用 decrypt 函数解密
return crypto.subtle.decrypt(aesParams, key, data);
}
这里就有一个问题,AuthTag
是怎么处理的?Node 那边是要自己管,浏览器呢?实际上 WebCryptoAPI 跟 JAVA 一样,也是把AuthTag
直接带在数据后面的,所以本文前面 Node 部分的加密代码返回的数据就是正确的。
特别提醒一下,上面代码里aesParams
还有两个可选属性additionalData
和tagLength
,它们是 GCM 附加校验信息功能使用的,我当时把它们当成AuthTag
传递了进去……浪费我半个小时。
虽然有了原生的 API,但为了兼容性还是留下这个 Polyfill,只要让它按需加载就行。
import * as webCrypto from "@/lib/crypto-web";
async function decrypt(password, data) {
if ("subtle" in window.crypto) {
const decrypted = await webCrypto.decrypt(password, data);
return new TextDecoder().decode(decrypted);
} else {
const nodeCrypto = await import("@/lib/crypto-node");
return nodeCrypto.decrypt(password, data);
}
}
"@/lib/crypto-web"
是 WebCryptoAPI 的文件用同步导入,"@/lib/crypto-node"
是 Polyfill 用import
动态导入,这样体积巨大的 Polyfill 就能被分割出去,只有当 WebCryptoAPI 不可用时才加载。