WebCrypto 与 Node 的 AES-GCM 踩坑

发布时间:

最后更新:

最近自己的一个前端项目里遇到了加密需求,而且要求 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 的代码如下:

javascript
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 即可
}

有了密钥就可以进行加密了:

javascript
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");
}

代码也很简单,

最后把三块数据连起来作为加密后的结果返回,这个结果会被打包并上传到公共仓库。

AuthTag 的坑 #

这里有第一个坑点,AES-GCM具有数据完整性校验功能,简单来说就是防止加密后的数据被篡改从而解密出错误的结果,为了实现此功能,AES-GCM的输出分为两部分,一个是加密后的密文,另一个是校验数据AuthTag,用它去验证密文是否被篡改。

(其实我这项目有HTTPS不需要再校验,但是我无脑选择了 GCM)

在其它的一些语言和库里,校验数据已经被自动处理了,比如JAVA 的源码里就是把校验数据直接附加到尾部,但是 Node 没有这么做而是要你手动处理。

所以对于GCM加密,你不要把AuthTag给忘了,要记得加密完同时保存下cipher.getAuthTag()的值,然后解密时通过decipher.setAuthTag()传入。如果忘了它则解密时会报错:

Error: Unsupported state or unable to authenticate data

这跟密码不对的错误信息是一样的,非常坑爹。

解密代码 #

javascript
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 吧,珍爱生命,远离蛇皮库。

原生 WebCryptoAPI #

加密功能上线后顺便用 Lighthouse 测了一下性能,提示 JS 代码严重影响性能,打开一看加密相关的 Polyfill 竟然有 800K,都占一半体积了。

打包大小分析打包大小分析

于是就需要优化一下,首先想到的就是浏览器原生的加密接口window.crypto。这个接口相关的标准是2012年起草的,现在的主流浏览器基本都支持了可以放心使用。

注意一些浏览器会对这个 API 做限制,比如 Android 版 Firefox 要求必须在 HTTPS 加密的页面上才能使用,如果是 HTTP 则window.crypto.subtleundefined

浏览器加密的 API 还是比 Node 复杂一点点,而且不知道是不是前端加密需求少的原因,目前相关的教程不是很多,想了解更多功能的话我推荐看 Node 的官方文档 https://nodejs.org/dist/latest-v16.x/docs/api/webcrypto.html,写的比 MDN 详细。

原生加密的兼容性原生加密的兼容性

代码 #

跟 Node 那头对应,首先还是密钥的生成:

javascript
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 一行搞定还是麻烦了许多。

转换好密钥之后就可以用它来加密和解密了:

javascript
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还有两个可选属性additionalDatatagLength,它们是 GCM 附加校验信息功能使用的,我当时把它们当成AuthTag传递了进去……浪费我半个小时。

按需加载 #

虽然有了原生的 API,但为了兼容性还是留下这个 Polyfill,只要让它按需加载就行。

javascript
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 不可用时才加载。

评论加载中