@kaadon.com/obfuscation
v0.0.2
Published
Frontend code-protection plugin for Vite / Webpack 5 / Rollup (built on unplugin): dual-layer JS + Wasm obfuscation and domain lock, optional X25519 end-to-end encryption, with prebuilt wasm bundled for zero-config use — no Rust toolchain required.
Maintainers
Readme
@kaadon.com/obfuscation
Rust/Wasm 核心计算 + JS 层混淆的双层前端安全构建插件,基于 unplugin 统一适配 Vite / Webpack 5 / Rollup,三端配置完全一致。
提供两块彼此正交、可独立启用的能力:
- 混淆 + 域名锁(默认启用)——JS 层与 Wasm 层双重域名校验,配合 javascript-obfuscator 分档混淆,签名等敏感计算下沉到 Rust/Wasm。
- 端到端加密(按需启用)——X25519 sealed box:上行用构建期注入的固定公钥加密,下行用户私钥在 wasm 内生成、自始至终不出 wasm 内存。
无需任何 Rust 工具链:包内已随附编译好的默认 wasm 产物,npm i 即开箱即用。
目录
- 特性
- 环境要求
- 安装
- 快速开始
- 虚拟模块的 TypeScript 类型
- 域名锁
- 混淆
- 端到端加密(可选)
- 运行时 API
- 构建期密钥 KAADON_SEALED_KEK
- 设计原则
- 配置项完整参考
- 使用自己的 Rust 核心
- 常见问题
- License
特性
| 能力 | 默认 | 说明 |
| --- | --- | --- |
| 双层域名锁 | 开(需配 guard.allowedDomains) | JS 层 + Wasm 层双重校验,非白名单域名下签名/加密全部降级 |
| JS 混淆 | 开 | 字符串数组化 + hex 标识符 + 可选反调试/自防御,支持按路径分档 |
| Wasm 核心计算 | 开 | 签名等敏感逻辑在 Rust/Wasm 内执行,配合 wasm-opt -Oz 与调试段剥离 |
| X25519 端到端加密 | 关(需配 crypto.uplinkPublicKey) | sealed box,私钥留在 wasm 内存,支持跨刷新持久化 |
| 体积预算 | 开(仅 warn) | wasm 体积、JS 混淆增幅超标只告警、不阻断构建 |
| 多打包器 | — | Vite / Webpack 5 / Rollup 同一套配置 |
永不抛异常:所有对外运行时 API 在异常路径统一降级(伪造签名 / 返回 null / false),调用方无需 try/catch。
环境要求
- Node.js >= 18
- 打包器之一:Vite 4/5/6/7、Webpack 5、或 Rollup 3/4(均为可选 peer 依赖,装了哪个用哪个)
默认无需 Rust 工具链。 包内随附编译好的默认 wasm 产物(wasm-default/ 与 wasm-crypto-default/)。只有在用 guard.rustCorePath 编译自己的 Rust 核心时,才需要额外准备:
cargo install wasm-pack
rustup target add wasm32-unknown-unknown
brew install binaryen # 可选,提供 wasm-opt -Oz 体积优化;缺失时自动跳过并 warn,不阻断构建安装
npm i -D @kaadon.com/obfuscation
# 或
pnpm add -D @kaadon.com/obfuscation
# 或
yarn add -D @kaadon.com/obfuscation快速开始
三端使用各自的子入口引入插件,配置项完全一致。
Vite
// vite.config.ts
import { defineConfig } from "vite";
import obfuscation from "@kaadon.com/obfuscation/vite";
export default defineConfig({
plugins: [
obfuscation({
guard: { allowedDomains: ["example.com"] },
}),
],
});Webpack 5
// webpack.config.js
const obfuscation = require("@kaadon.com/obfuscation/webpack").default;
module.exports = {
plugins: [
obfuscation({
guard: { allowedDomains: ["example.com"] },
}),
],
};Rollup
// rollup.config.js
import obfuscation from "@kaadon.com/obfuscation/rollup";
export default {
plugins: [
obfuscation({
guard: { allowedDomains: ["example.com"] },
}),
],
};业务代码里调用
import { safeSign, guardReady } from "virtual-kaadon-guard";
await guardReady; // 永不 reject,无需 try/catch
const signature = await safeSign(payload, secret);
guard.allowedDomains不传或传空数组[]即关闭域名锁(JS 层和 wasm 层都直接放行)。
按能力独立使用
三个能力(obfuscation / guard / crypto)互相正交,按需单独启用或组合:
// 只要混淆
obfuscation({ obfuscation: { include: [/[/\\]src[/\\]/] } });
// 只要域名锁 + 签名
obfuscation({ guard: { allowedDomains: ["example.com"] } });
// 三者齐上
obfuscation({
obfuscation: { include: [/[/\\]src[/\\]/] },
guard: { allowedDomains: ["example.com"] },
crypto: { uplinkPublicKey: "<32 字节 X25519 公钥>" },
});虚拟模块的 TypeScript 类型
virtual-kaadon-guard / virtual-kaadon-crypto 是构建期注入的虚拟模块,没有物理文件,TS 默认认不出来。在项目的 env.d.ts(Vite 项目通常是 src/vite-env.d.ts)里加一行:
/// <reference types="@kaadon.com/obfuscation/client" />或在 tsconfig.json 里声明:
{ "compilerOptions": { "types": ["@kaadon.com/obfuscation/client"] } }域名锁
配置 allowedDomains 后,JS 层与 Wasm 层会双重校验当前运行域名(自动放行其子域名)。非白名单域名下 safeSign 返回一个格式合法的伪造签名、加密 API 降级返回 null——不抛异常、不阻断页面,只是让防护逻辑失效于非法宿主。
obfuscation({
guard: {
// app.example.com、m.example.com 等子域名自动允许
allowedDomains: ["example.com", "trusted-cdn.net"],
},
});传 [] 或不传 = 关闭域名锁,双层直接放行。
混淆
默认只混淆 wasm-bindgen 胶水代码与 guard 运行时。要把业务代码也纳入混淆,用 obfuscation.include(子串或正则):
obfuscation({
obfuscation: {
include: [/src\/core\//, "sign.ts"],
},
});按路径分档
用 obfuscation.profiles 给不同目录设不同强度:命中 include 的文件在全局默认基础上叠加该规则里写明的项(未写的继承全局默认),且命中即隐含纳入混淆,无需再列进 obfuscation.include。多条规则命中同一文件取第一个命中。
obfuscation({
obfuscation: {
// 全局默认走轻量档
stringArrayEncoding: ["base64"],
profiles: [
{
// 核心目录开强档
include: [/src\/core\//],
stringArrayEncoding: ["base64", "rc4"],
stringArrayThreshold: 1,
antiDebug: true,
},
],
},
});强制锁定:
controlFlowFlattening与deadCodeInjection始终锁死为false(规避栈溢出风险),obfuscation.options无法覆盖这两项。
端到端加密(可选)
X25519 sealed box。上行用构建期注入的固定公钥加密(业务侧无法传入或替换,杜绝被篡改);下行用运行时在 wasm 内生成的用户私钥解密,私钥自始至终不出 wasm 内存。
配置上行公钥即启用(32 字节 X25519 公钥,base64 或 hex 字符串):
obfuscation({
guard: { allowedDomains: ["example.com"] },
crypto: {
uplinkPublicKey: "BASE64_OR_HEX_ENCODED_32_BYTE_X25519_PUBLIC_KEY",
},
});不配置 crypto.uplinkPublicKey 时不注入加密运行时,safeSeal 降级返回 null,不阻断构建。
生成上行密钥对
上行是一对 X25519 密钥:公钥填进 crypto.uplinkPublicKey(编译进产物),私钥留服务端解密上行密文,绝不进前端。
用 openssl 生成、导出原始 32 字节:
# 1) 生成 X25519 私钥
openssl genpkey -algorithm X25519 -out uplink_priv.pem
# 2) 上行公钥(32 字节 → base64):填入 crypto.uplinkPublicKey
openssl pkey -in uplink_priv.pem -pubout -outform DER | tail -c 32 | base64
# 3) 服务端私钥(32 字节 → base64):留服务端,务必保密
openssl pkey -in uplink_priv.pem -outform DER | tail -c 32 | base64X25519 的 DER 公钥 = 12 字节头 + 32 字节、私钥 = 16 字节头 + 32 字节,
tail -c 32正好取出原始密钥。想要 hex 把base64换成xxd -p -c 64;uplinkPublicKey接受 base64 或 hex。
或用 libsodium 生成(服务端也用 libsodium 时更一致):
node -e "const s=require('libsodium-wrappers');s.ready.then(()=>{const k=s.crypto_box_keypair();const b=s.base64_variants.ORIGINAL;console.log('public:',s.to_base64(k.publicKey,b));console.log('secret:',s.to_base64(k.privateKey,b))})"把公钥填入配置:
obfuscation({
guard: { allowedDomains: ["example.com"] },
crypto: {
uplinkPublicKey: "iQwvi57/NBkqsXMhzz3REZVqpgY9Wc57pOUK/Y4Vv3Q=", // 32 字节 X25519 公钥
},
});服务端解密
加解密是 libsodium 兼容的 sealed box(RustCrypto crypto_box)。服务端用配套私钥解密客户端 safeSeal 产出的上行密文(Uint8Array → 按业务约定的传输编码传回):
Node(libsodium-wrappers):
const sodium = require("libsodium-wrappers");
await sodium.ready;
const b = sodium.base64_variants.ORIGINAL;
const pub = sodium.from_base64(UPLINK_PUBLIC_B64, b);
const sec = sodium.from_base64(UPLINK_SECRET_B64, b);
// sealed:客户端 safeSeal 上行的密文字节
const plain = sodium.crypto_box_seal_open(sealed, pub, sec); // Uint8ArrayPython(PyNaCl):
import base64
from nacl.public import PrivateKey, SealedBox
sk = PrivateKey(base64.b64decode(UPLINK_SECRET_B64))
plain = SealedBox(sk).decrypt(sealed) # sealed: 客户端上行密文 bytesRust(crypto_box,与 wasm 侧 b3 对应):
use crypto_box::{SecretKey, aead::OsRng};
let sk = SecretKey::from_slice(&uplink_secret_32)?; // 服务端私钥
let plain = sk.unseal(&ciphertext)?; // 客户端上行密文下行加密(服务端 → 客户端)则反过来:用某用户经 genUserKeypair/getUserPublicKey 上报的用户公钥做 sealed box 加密,客户端用 safeOpen 解:
const userPub = sodium.from_base64(reportedUserPublicB64, sodium.base64_variants.ORIGINAL);
const downlink = sodium.crypto_box_seal(message, userPub); // 客户端 safeOpen 解密import {
cryptoReady,
genUserKeypair,
safeSeal,
safeOpen,
getUserPublicKey,
persistKeypair,
restoreKeypair,
clearPersistedKeypair,
} from "virtual-kaadon-crypto";
if (await cryptoReady) {
// 先尝试恢复上次的私钥(页面刷新 / 重新打开)
let userPub = await restoreKeypair();
if (!userPub) {
// 只有确实没有可恢复的封套时才「首次注册」:生成一次 + 上报 + 持久化
userPub = await genUserKeypair();
if (userPub) {
await reportPublicKey(userPub);
await persistKeypair(); // 私钥以 wasm 内 KEK 加密的封套存 localStorage,默认 7 天
}
}
// 上行加密(用构建期注入的上行公钥)
const sealed = await safeSeal(new TextEncoder().encode("secret payload"));
// 下行解密(服务端用 userPub 加密下发)
const plain = await safeOpen(ciphertextFromServer);
}密文与明文一律为
Uint8Array,传输编码(base64 等)由业务层自行决定。
密钥生命周期(重要)
genUserKeypair() 每次调用都生成一把全新的随机密钥对(OsRng → Web Crypto),并覆盖 wasm 内存里的旧私钥。它是「注册 / 轮换」操作,不是「读取」——日常接续会话请用 restoreKeypair(),不要每次进页面都调 genUserKeypair()。
误用后果:重新生成会丢弃旧私钥,用旧公钥加密的下行密文将永久无法解密(safeOpen 返回 null),且服务端仍持旧公钥直到你重新上报。
| 想做的事 | 该调用 |
| --- | --- |
| 页面刷新后接续同一把密钥 | restoreKeypair()(不是 genUserKeypair) |
| 首次注册 / 无可恢复封套 | genUserKeypair() 一次 + 上报公钥 + persistKeypair() |
| 主动轮换密钥 | 再次 genUserKeypair(),并重新上报新公钥,接受旧下行密文作废 |
| 登出清本地 | clearPersistedKeypair()(只清封套,不动内存私钥) |
persistKeypair存的是 wasm 内 KEK(见 KAADON_SEALED_KEK)加密的私钥封套;换 KEK 会让已存封套失效,restoreKeypair返回null。
运行时 API
域名锁 / 签名(virtual-kaadon-guard)
| API | 返回 | 说明 |
| --- | --- | --- |
| guardReady | Promise<unknown> | wasm 就绪态。永不 reject,初始化失败也 resolve |
| safeSign(payload, secret) | Promise<string> | 签名。域名非法 / wasm 失败 / 调用异常 → 返回格式合法的伪造字符串,绝不抛 |
端到端加密(virtual-kaadon-crypto)
| API | 返回 | 说明 |
| --- | --- | --- |
| cryptoReady | Promise<boolean> | wasm 就绪态。永不 reject,失败 resolve(false) |
| genUserKeypair() | Promise<Uint8Array \| null> | 生成密钥对,返回用户公钥。私钥留在 wasm 内存。重复调用会覆盖旧密钥对 |
| safeSeal(plaintext) | Promise<Uint8Array \| null> | 上行加密。未注入公钥 / 未就绪 / 失败 → null |
| safeOpen(ciphertext) | Promise<Uint8Array \| null> | 下行解密。未生成密钥对 / 密文损坏 → null |
| getUserPublicKey() | Promise<Uint8Array \| null> | 从内存私钥即时重算公钥,不轮换私钥 |
| persistKeypair(ttlMs?) | Promise<boolean> | 私钥封套存入 localStorage,默认 7 天过期 |
| restoreKeypair() | Promise<Uint8Array \| null> | 从 localStorage 恢复私钥,返回用户公钥;封套过期/损坏/KEK 不匹配时清掉并返回 null |
| clearPersistedKeypair() | void | 清除持久化封套(登出等),不影响内存中的当前私钥 |
构建期密钥 KAADON_SEALED_KEK
私钥持久化封套(persistKeypair / restoreKeypair)用一把 AES-256-GCM KEK 加密。这把 KEK 在编译 crypto wasm 时由环境变量 KAADON_SEALED_KEK 经 Rust 的 option_env! 注入(源码里没有明文 KEK)。不设时回退源码内的固定测试 KEK,构建脚本会打印 console.warn 提醒。
- 格式:64 位 hex(32 字节)。生成:
openssl rand -hex 32 - 何时需要:仅当你自己重编 crypto wasm(
pnpm build→scripts/build-default-wasm.mjs,需本机 Rust + wasm-pack)时才读取。只用发布好的 npm 包、不重编的话,用的是包内固定测试 KEK,设了也不生效。
配置方式
构建脚本(scripts/build-default-wasm.mjs)内置轻量 .env 加载,不依赖 Node 版本,可直接用 .env / .env.local。文件从 packages/plugin/ 目录读取(脚本运行处),放到仓库根目录不会被读取:
# packages/plugin/.env.local(个人本地,已 gitignore)
KAADON_SEALED_KEK=<openssl rand -hex 32 的输出>pnpm build # 自动读取 .env / .env.local也可用行内环境变量或 CI Secrets(无需 .env 文件):
KAADON_SEALED_KEK=$(openssl rand -hex 32) pnpm build加载优先级(高 → 低):真实 shell env / CI Secrets > .env.local > .env。生产发布建议用 CI Secrets 注入(会覆盖 .env 文件)。仓库提供 .env.example 作为模板。
务必注意
- 同一把 KEK 长期复用:轮换 KEK 会让所有用户已存的 localStorage 私钥封套 GCM 认证失败 →
restoreKeypair降级返回null(等同一次“版本重握手”)。生成一次、妥善保管、别每次构建重生成。 - 它是“隔离”密钥、非机密密钥:KEK 编译进 wasm 二进制、可被提取。作用是把本项目的封套与默认测试 KEK(及其他项目)隔离开,防止跨构建互相解封,不是用来对抗逆向保护私钥机密性。
- 改了自动重编:脚本会
touchrust-crypto/src/lib.rs强制重编,确保新 KEK 一定进产物,无需手动清缓存。
设计原则
- 不回抛异常:
safeSign在域名非法、wasm 初始化失败、签名调用异常时统一返回一个格式合法的伪造字符串;加密侧所有 API 一律降级返回null/false。调用方永远不会因为这层防护拿到 reject/throw。 - 溢出安全:Rust 侧所有整数运算使用
wrapping_*,release profile 关闭 unwind(panic = "abort"),clippy 禁用unwrap/expect/切片索引。 - 体积/性能预算:
.wasm≤ 100KB、混淆后 JS 增幅 < 30% 为默认预算,超出仅console.warn,不会让构建失败(见budget选项)。 - 控制流平坦化锁死:
obfuscatorOptions无法覆盖controlFlowFlattening/deadCodeInjection,两者始终锁定为false(避免栈溢出)。
配置项完整参考
三个能力各自独立命名空间,均可选、可单独启用;下表为各字段默认值。顶层形状:
obfuscation({
obfuscation: { /* 混淆,纯 JS */ },
guard: { /* 域名锁 + 签名,rust-core wasm */ },
crypto: { /* 端到端加密,rust-crypto wasm */ },
budget: { /* 跨能力体积预算 */ },
});obfuscation(混淆,纯 JS)
| 选项 | 默认 | 说明 |
| --- | --- | --- |
| enabled | true | 混淆总开关。false = 跳过 JS 混淆,仅保留 wasm + 域名锁 + 虚拟模块 |
| include | [] | 除 wasm 胶水外还要混淆的文件匹配(子串或正则)。默认只混淆 glue + guard |
| profiles | [] | 按路径分档(见按路径分档) |
| options | {} | 透传 javascript-obfuscator 额外选项(controlFlowFlattening/deadCodeInjection 强制锁死,覆盖无效) |
| stringEncryption | true | 字符串数组化总闸 |
| stringArrayEncoding | ["base64"] | 编码方式 "none"/"base64"/"rc4"。默认不含 RC4(+45% 体积、高熵不可 gzip、对脱壳无实质增益) |
| stringArrayThreshold | 1 | 抽取比例 0~1,越大覆盖越多、体积越大 |
| hexIdentifiers | true | 变量/函数名换成无语义 hex 名 |
| disableConsoleOutput | true | 移除混淆代码里的 console.* |
| antiDebug | false | 防调试 debugger 陷阱。默认关,按需在敏感目录用 profile 单独开 |
| selfDefending | false | 自防御。默认关:本插件 enforce:"post"、后面还有打包器压缩,非最后一层,开启会误判被篡改进入死循环 |
| forceInDev | false | 开发模式默认跳过混淆;true 强制 dev 下也混淆 |
guard(域名锁 + 签名,rust-core wasm)
| 选项 | 默认 | 说明 |
| --- | --- | --- |
| allowedDomains | [] | 域名白名单(自动含子域名)。空数组 = 关闭域名锁,双层直接放行 |
| rustCorePath | null | 自定义 guard 的 Rust crate 目录。null = 用包内置默认 wasm,无需 Rust 工具链;传入才编译自定义 Rust |
| wasmOpt | true | 额外跑 wasm-opt -Oz;本机无 binaryen 时自动跳过并 warn |
| wasmStrip | true | wasm-opt 加 --strip-debug --strip-producers,剥离调试段与工具链指纹 |
crypto(端到端加密,rust-crypto wasm)
| 选项 | 默认 | 说明 |
| --- | --- | --- |
| uplinkPublicKey | — | 上行 X25519 公钥(32 字节 base64 或 hex)。不配置 = 不启用加密注入,safeSeal 降级返回 null |
| wasmOpt | true | 同 guard.wasmOpt,作用于 crypto wasm |
| wasmStrip | true | 同 guard.wasmStrip,作用于 crypto wasm |
budget(体积预算,超标仅 warn,不阻断构建)
| 选项 | 默认 | 说明 |
| --- | --- | --- |
| enabled | true | 是否启用体积预算校验 |
| wasmKB | 100 | .wasm 体积预算(KB),超出仅 warn |
| jsGrowthPercent | 30 | 混淆后 JS 相对混淆前的体积增幅预算(%),超出仅 warn |
使用自己的 Rust 核心
默认 wasm 已内置「防重放签名 + 域名锁」,多数只想要「反调试 + 域名锁 + 混淆」的前端团队无需碰 Rust。当你要写自己的签名/校验算法时,传入 guard.rustCorePath 指向你的 crate 目录:
obfuscation({
guard: {
allowedDomains: ["example.com"],
rustCorePath: "./packages/rust-core",
},
});- 插件会在该目录跑
wasm-pack build编译你的 Rust 代码,此时本机才需要 Rust 工具链(见环境要求)。 - 内置哈希缓存:仅当 Rust 源码变化时才重新编译。
- 建议把编译产物
<rustCorePath>/pkg/提交进仓库,这样团队里不碰 Rust 的成员也能直接复用缓存,无需人人安装 Rust。
常见问题
Q:开发模式下没看到混淆效果?
A:vite dev / webpack mode !== 'production' 默认跳过混淆,只保留 wasm + 域名锁,方便调试。要在 dev 下验证混淆,传 obfuscation: { forceInDev: true }。
Q:开了 selfDefending 后页面卡死/反复刷新?
A:selfDefending 要求本插件是处理该代码的最后一层,而插件是 enforce:"post",其后打包器还会 minify,特征失效即误判被篡改。仅在关掉打包器 minify、确保本插件为最后一层时才开启。
Q:safeSign / safeSeal 返回了伪造值或 null?
A:这是预期的降级行为(域名非法 / wasm 未就绪 / 未配置加密公钥等),而非抛错。请检查 allowedDomains 是否包含当前域名、加密是否配了 crypto.uplinkPublicKey。
Q:构建告警 wasm 体积或 JS 增幅超标?
A:仅 console.warn,不阻断构建。可调 budget.wasmKB / budget.jsGrowthPercent,或设 budget: { enabled: false } 关闭校验。
服务端伴生库
前端 safeSign 请求签名与 safeSeal 上行加密的后端对接,用配套的 PHP Composer 库 kaadon/obfuscation-server:Guard 签名校验(域名锁 + FNV-1a + 时间戳窗口/重放)与 Crypto X25519 sealed box 解密,框架无关核心 + ThinkPHP 中间件/门面/快捷函数。
composer require kaadon/obfuscation-serverLicense
MIT © kaadon
