npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 即开箱即用。


目录


特性

| 能力 | 默认 | 说明 | | --- | --- | --- | | 双层域名锁 | 开(需配 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,
      },
    ],
  },
});

强制锁定controlFlowFlatteningdeadCodeInjection 始终锁死为 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 | base64

X25519 的 DER 公钥 = 12 字节头 + 32 字节、私钥 = 16 字节头 + 32 字节,tail -c 32 正好取出原始密钥。想要 hex 把 base64 换成 xxd -p -c 64uplinkPublicKey 接受 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); // Uint8Array

Python(PyNaCl):

import base64
from nacl.public import PrivateKey, SealedBox

sk = PrivateKey(base64.b64decode(UPLINK_SECRET_B64))
plain = SealedBox(sk).decrypt(sealed)  # sealed: 客户端上行密文 bytes

Rust(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 wasmpnpm buildscripts/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(及其他项目)隔离开,防止跨构建互相解封,不是用来对抗逆向保护私钥机密性。
  • 改了自动重编:脚本会 touch rust-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-server

License

MIT © kaadon