在现代 Web 应用开发中,密钥的使用几乎是不可避免的,无论是加解密本地敏感数据、调用第三方 SDK 还是网络请求签名等场景都需要用到密钥。
如何相对安全、灵活地管理密钥一直是一个令人头疼的问题,我们既希望在开发环境可以方便地修改、调试和注入密钥,又不希望这些密钥在构建产物中被明文暴露,以免被有心之人轻松获取。
通常情况,我们会先手动将密钥通过特定的算法混淆拆分成多份放入源码中,运行时再通过逆运算将这些片段合并还原得到密钥原文,这样在构建产物中密钥就不会以明文的形式暴露。
例如下面这样:
javascript
// 假设 chunk1、chunk2、chunk3 是密钥混淆拆分后的片段
const chunk1 = "abc";
const chunk2 = "123";
const chunk3 = "!@#";
// 运行时通过逆运算合并还原得到密钥原文
const key = combine(chunk1, chunk2, chunk3);
console.log(key); // iamxiaohe
但是如果需要添加或者修改密钥,我们就得针对新的密钥再重复手动混淆拆分这个操作。众所周知,手动操作既低效又容易出错,那么我们能不能编写一个插件来自动完成这个过程呢?
插件设计
在开始设计之前,我们先整理一下需求,思考这个插件需要帮我们完成什么工作,简单梳理如下:
- 能够直接使用明文配置密钥
- 针对配置的密钥能够自动混淆拆分
- 运行时自动合并还原密钥
- 密钥支持简单的导入使用
API 设计
需求整理完成,然后需要再设想一下期望的插件使用方式,这有利于技术选型以及后续的插件实现工作。
我们希望这个插件的使用尽可能贴合开发者的直觉,最理想的使用方式是这样的:只需要在构建工具的配置中简单引入插件,传入一份密钥配置表,便可以在业务代码中通过特定的模块路径导入密钥,而无需关心密钥的具体构建逻辑与混淆拆分细节。
Vite 是一个超快的前端构建工具,现在大多数项目都使用 Vite 构建,所以我们的插件也考虑为 Vite 提供优先支持。通过查阅 Vite 的 插件 API 文档,可以知道 Vite 内部由 Rollup 驱动,如果插件不涉及 Vite 独有的 hook(例如开发服务器相关),那么就可以编写一个 Rollup 插件同时支持 Vite 和 Rollup 使用。
至此,我们可以初步构思出插件的 API 设计如下(以 Vite 为例):
javascript
// vite.config.(js|ts)
import CryptoKey from "rollup-plugin-crypto-key";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
CryptoKey({
keys: {
DEMO_KEY1: "iamxiaohe",
DEMO_KEY2: "ilovexiaohe"
}
})
]
});
javascript
import { DEMO_KEY1, DEMO_KEY2 } from "crypto-key";
console.log(DEMO_KEY1); // iamxiaohe
console.log(DEMO_KEY2); // ilovexiaohe
虚拟模块
我们设想从 crypto-key
中导入插件配置的密钥,但是这个 crypto-key
并不是已安装的模块或者某个真实的文件,而是通过插件动态生成对应的代码并从内存中加载使用,通过查阅文档发现 虚拟模块 可以满足这个需求。
虚拟模块是一种很实用的模式,使你可以对使用 ESM 语法的源文件传入一些编译时信息。
javascript
export function myPlugin() {
const virtualModuleId = "virtual:my-module";
const resolvedVirtualModuleId = "\0" + virtualModuleId;
return {
name: "my-plugin",
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const msg = "from virtual module";`;
}
}
};
}
这使得可以在 JavaScript 中引入这些模块:
javascript
import { msg } from "virtual:my-module";
console.log(msg);
虚拟模块在 Vite(以及 Rollup)中都以 virtual:
为前缀,作为面向用户路径的一种约定。
如果可能的话,插件名应该被用作命名空间,以避免与生态系统中的其他插件发生冲突。举个例子,vite-plugin-posts
可以要求用户导入一个 virtual:posts
或者 virtual:posts/helpers
虚拟模块来获得编译时信息。
在内部,使用了虚拟模块的插件在解析时应该将模块 ID 加上前缀 \0
,这一约定来自 Rollup 生态。这避免了其他插件尝试处理这个 ID(比如 node 解析),而例如 sourcemap 这些核心功能可以利用这一信息来区别虚拟模块和正常文件。\0
在导入 URL 中不是一个被允许的字符,因此我们需要在导入分析时替换掉它们。一个虚拟 ID 为 \0{id}
在浏览器中开发时,最终会被编码为 /@id/__x00__{id}
。这个 id 会被解码回进入插件处理管线前的样子,因此这对插件钩子的代码是不可见的。
所以,根据约定我们也使用 virtual:
作为导入前缀,修改如下:
javascript
import { DEMO_KEY1, DEMO_KEY2 } from "virtual:crypto-key";
模块划分
完成了插件的 API 设计,我们还需要思考插件的模块如何划分。
现在,回想之前梳理出的插件需求,如果直接把所有逻辑都装进一个插件模块里,很快会发现一些问题:
- 逻辑耦合严重:密钥的混淆拆分与还原算法和构建工具相关的插件逻辑混杂在一起,不利于测试和维护。
- 可复用性差:如果将来希望适配其他构建工具而不局限于 Rollup / Vite,则无法直接复用。
既然如此,不妨把核心算法与构建工具适配的逻辑分开,让它们互相独立、各司其职,但又能通过清晰的接口相互协作。
所以我们可以将插件拆分为以下两个模块:
-
crypto-splitter
这是插件的核心,负责密钥的混淆拆分与还原,它不关心上层的构建工具。
-
rollup-plugin-crypto-key
作为插件的桥梁 ,它负责在 Rollup 中调用
crypto-splitter
的能力实现虚拟模块相关的逻辑。
插件设计其实是插件开发全流程中最困难的部分,我们已经顺利完成,可以说是轻舟已过万重山。
插件实现
有了清晰的设计思路,现在就可以进入到大家最擅长的编码环节啦!
crypto-splitter
首先需要选择一种密钥拆分还原算法,这是插件最核心的部分,也是最复杂的部分,所以我们 一定要全栈自研、从心出发 让 AI 帮忙写一个。
那么目前主流的密钥拆分还原算法有哪些呢?AI 回答如下:
Shamir 密钥共享(Shamir's Secret Sharing, SSS)
- 基于多项式插值(Lagrange 插值)原理。
- 将密钥 <math xmlns="http://www.w3.org/1998/Math/MathML"> K K </math>K 作为一个域上的常数项,构造一个随机多项式 <math xmlns="http://www.w3.org/1998/Math/MathML"> f ( x ) = K + a 1 x + a 2 x 2 + ⋯ + a t − 1 x t − 1 f(x) = K + a_1 x + a_2 x^2 + \dots + a_{t-1} x^{t-1} </math>f(x)=K+a1x+a2x2+⋯+at−1xt−1。
- 每个参与者得到多项式在不同 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 点的值作为份额。
- 只要 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t 个份额,就可以通过 Lagrange 插值还原原始多项式,从而得到密钥 <math xmlns="http://www.w3.org/1998/Math/MathML"> K K </math>K。
Blakley 密钥共享
- 基于几何原理:在 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t 维空间中,每个份额对应一个超平面。
- 原始密钥对应空间中的一个点。
- 至少 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t 个超平面交点才能唯一确定该点。
Asmuth-Bloom 密钥共享
- 基于中国剩余定理(CRT)。
- 选择一组互质整数 <math xmlns="http://www.w3.org/1998/Math/MathML"> m 1 < m 2 < . . . < m n m_1 < m_2 < ... < m_n </math>m1<m2<...<mn。
- 密钥通过模运算生成份额,每个份额 <math xmlns="http://www.w3.org/1998/Math/MathML"> s i = K m o d m i s_i = K \bmod m_i </math>si=Kmodmi。
- 至少 <math xmlns="http://www.w3.org/1998/Math/MathML"> t t </math>t 个份额即可用 CRT 还原 <math xmlns="http://www.w3.org/1998/Math/MathML"> K K </math>K。
XOR 分割
- 将密钥按比特或字节拆分成若干随机序列。
- 最后一个份额通过 XOR 得到,使所有份额 XOR 后等于原密钥。
- 例如: <math xmlns="http://www.w3.org/1998/Math/MathML"> K = S 1 ⊕ S 2 ⊕ ⋯ ⊕ S n K = S_1 \oplus S_2 \oplus \dots \oplus S_n </math>K=S1⊕S2⊕⋯⊕Sn。
简单起见,我们选择最容易的 XOR 分割算法来实现我们的插件核心。
既然是密钥拆分和还原,那么接下来就编写对应的两个方法,split
用于混淆拆分密钥,combine
用于合并还原密钥。
typescript
/**
* 拆分配置项
*/
export interface SplitOptions {
/** 拆分片段数量,默认为 4 */
segments?: number;
}
/**
* 将输入的密钥按指定片段数量进行拆分,生成可重组的随机化片段数组。
*
* @param key 密钥原文
* @param options 配置项
* @returns 随机化片段数组
*/
export function split(key: string, options: SplitOptions = {}): string[] {
const {
segments = 4
} = options;
// 如果 key 为空,则直接返回空数组
if (key.length <= 0) {
return [];
}
const chunks: string[] = [];
// 生成前 segments - 1 个随机化片段
for (let i = 0; i < segments - 1; i += 1) {
chunks.push(
[...key]
.map((char) => {
return String.fromCharCode(
char.charCodeAt(0) ^ Math.floor(Math.random() * 256)
);
})
.join("")
);
}
// 生成最后一个片段,保证能通过逆运算还原出密钥原文
chunks.push(
[...key]
.map((char, index) => {
return String.fromCharCode(
char.charCodeAt(0) ^ chunks.reduce((acc, it) => {
return acc ^ it.charCodeAt(index);
}, 0)
);
})
.join("")
);
return chunks;
}
split
方法将输入的密钥 key
按照指定 segments
数量拆分成若干加密片段 。前 segments - 1
个片段通过随机数异或生成,第 segments
个片段通过异或前面的所有片段与 key
生成,这样可以保证用所有片段才能还原出原始的 key
。
typescript
/**
* 将拆分后的随机化片段数组重新合并还原成原始密钥。
*
* @param chunks 随机化片段数组
* @returns 原始密钥
*/
export function combine(chunks: string[]): string {
// 如果没有片段,则直接返回空字符串
if (chunks.length <= 0) {
return "";
}
return [...chunks[0]]
.map((_, index) => {
return String.fromCharCode(
// 按位异或所有片段对应字符
chunks.reduce((acc, it) => {
return acc ^ it.charCodeAt(index);
}, 0)
);
})
.join("");
}
combine
方法接收由 split
方法生成的随机化片段数组,通过逐字符按位异或的方式恢复原始字符串。它要求必须传入完整的片段数组,否则无法保证恢复结果的正确性。
至此,我们完成了 crypto-splitter
模块的开发。
rollup-plugin-crypto-key
现在我们开始编写 Rollup 插件,将 crypto-splitter
的能力接入到 Rollup 中。
typescript
import type { Plugin } from "rollup";
import { getCode } from "./code";
export interface Options {
/**
* 密钥映射表,例如 { KEY1: "xxxx", KEY2: "yyyy" }
*/
keys?: Record<string, string>;
}
// 虚拟模块标识符,供用户在代码中导入使用
const VIRTUAL_MODULE_ID = "virtual:crypto-key";
// 内部使用的虚拟模块标识符(带 \0 前缀,避免其他插件尝试处理这个 ID)
const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
export default function cryptoKey(options: Options = {}): Plugin {
const {
keys = {}
} = options;
return {
name: "crypto-key",
resolveId(source) {
// 只关心 virtual:crypto-key,其他模块不处理
if (source !== VIRTUAL_MODULE_ID) {
return null;
}
// 返回内部标识符用于 load 阶段判断使用
return RESOLVED_VIRTUAL_MODULE_ID;
},
load(id) {
// 过滤其他模块
if (id !== RESOLVED_VIRTUAL_MODULE_ID) {
return null;
}
// 返回密钥处理代码以供运行时调用
return getCode(keys);
}
};
}
接下来,我们需要实现 getCode
方法返回密钥处理代码。在开始编码之前,先要知道期望的结果是什么,稍加思索后可以得出如下代码:
javascript
import { combine } from "crypto-splitter";
const $1 = ["111", "aaa", "!@#"];
const $2 = ["$%^", "bbb", "222"];
export const KEY1 = combine($1);
export const KEY2 = combine($2);
其中 $1
、$2
是通过 crypto-splitter
的 split
方法拆分后的随机化片段数组,然后通过 combine
方法运行时还原为密钥原文并导出 KEY1
和 KEY2
。
那么 getCode
方法需要做的事情就是遍历 keys
调用 split
方法拆分密钥,然后根据上述模板生成并返回 JavaScript 代码。
typescript
// code.ts
import { split } from "crypto-splitter";
export function getCode(keys: Record<string, string>): string {
const values = Object.entries(keys)
.map(([key, value]) => {
return {
key,
chunks: split(value)
};
});
return `import { combine } from "crypto-splitter";
${
values
.map((item, index) => {
return `const $${index + 1} = ${JSON.stringify(item.chunks)};`;
})
.join("\n")
}
${
values
.map((item, index) => {
return `export const ${item.key} = combine($${index + 1});`;
})
.join("\n")
}`;
}
到这里,我们完成了插件的核心实现:用一个独立的 crypto-splitter
模块实现了简单可复用的拆分/合并算法,再通过 rollup-plugin-crypto-key
模块把这套机制以虚拟模块的形式接入到 Rollup / Vite 中,最终用户只需在配置中添加密钥即可在代码中像导入普通模块一样使用它们。
插件使用
既然完成了插件的实现,最后当然是要体验使用一下自己编写的插件啦!
javascript
// vite.config.(js|ts)
import CryptoKey from "rollup-plugin-crypto-key";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
CryptoKey({
keys: {
DEMO_KEY1: "iamxiaohe",
DEMO_KEY2: "ilovexiaohe"
}
})
]
});
javascript
import { DEMO_KEY1, DEMO_KEY2 } from "virtual:crypto-key";
console.log(DEMO_KEY1); // iamxiaohe
console.log(DEMO_KEY2); // ilovexiaohe
还是以 Vite 为例,我们可以看到插件的使用非常直观:只需在 Vite 配置中传入明文密钥,业务代码中即可像导入普通模块一样获取密钥值,而不必关心拆分、混淆或运行时还原的具体实现。这样既保证了开发时的便捷性,又避免了在构建产物中明文暴露密钥,极大地降低了开发与安全管理的复杂度。同时,这也验证了虚拟模块的设计思路:插件自动生成模块内容,用户只需关注导入和使用,而不需要额外手动操作密钥。
🚨 注意
由于浏览器环境的特殊性,任何客户端的保护措施都是 "防君子不防小人",只能增加破解难度,并不能保证绝对的安全!如果需要提高安全性,应该与其他防护措施相结合。
源码
插件的完整代码可以在 virtual-crypto-key 仓库中查看。赠人玫瑰,手留余香,如果对你有帮助可以给我一个 ⭐️ 鼓励,这将是我继续前进的动力,谢谢大家 🙏!
下一步
现在插件已经可以顺利使用并且符合预期效果,我们达成了第一个里程碑!
但是细心的同学会发现,我们的插件在 TypeScript 中使用时会报如下错误信息:
Cannot find module
virtual:crypto-key
or its corresponding type declarations.
这是因为我们没有为 virtual:crypto-key
提供类型定义,所以 TypeScript 的编译器并不认识这个模块。这将会导致类型检查不通过,并且 IDE 也不知道应该如何提示代码,让用户的开发体验大大降低。
作为一个现代的插件,我们当然要将用户的开发体验放在第一位,所以下一章我们将会一起实现对 TypeScript 的支持!