问题背景
Electron 桌面应用的发布包天然包含 JavaScript 运行时代码。asar、压缩、fuses、源码排除和授权验签分别解决的是不同层面的问题:减少发布包暴露面、关闭常见调试入口、限制加载路径、验证授权令牌来源。它们不能混成一句“防破解”,否则后续很难判断还缺哪一层。
本文讨论一种常见场景:应用不依赖持续联网,用户安装后凭签发方提供的令牌解锁;与此同时,发布包应尽量减少源码、source map、无关 native/vendor 文件和调试入口的暴露。
先说明结论:只要授权判断完全发生在用户控制的设备上,就不存在不可绕过的本地授权。下面这些措施的价值在于提高篡改成本、减少误用入口和建立清晰的能力边界,而不是承诺“无法破解”。
打包层:发布包应该只带运行所需内容
判断安装包是否被清理,先看 electron-builder 的 files 白名单和排除项。关键点是只把构建产物和必要元数据放进去,而不是把工程目录整体塞进安装包。
files: [
"dist/**/*",
"package.json",
"!**/*.map",
"!**/*.ts",
"!**/*.tsx",
"!**/*.d.ts",
"!node_modules/NATIVE_MODULE/doc/**",
"!node_modules/NATIVE_MODULE/vendor/**",
"!node_modules/TOOL_PACKAGE/linux/**",
"!node_modules/TOOL_PACKAGE/mac/**",
"!node_modules/TOOL_PACKAGE/win/arm64/**",
"!node_modules/TOOL_PACKAGE/win/ia32/**",
]这段配置实际处理了三类风险:
*.map会把压缩后的代码映射回源码,应从发布包排除。*.ts、*.tsx、*.d.ts没有运行必要,留下只会帮助阅读实现。- native 依赖的文档、源码、非目标平台二进制没有运行必要,应按平台裁剪。
asarUnpack 只应放必须从真实文件系统加载的内容。下面以 Windows x64 的 native 依赖和外部工具为例:
asar: true,
asarUnpack: [
"node_modules/NATIVE_MODULE/**/*",
"node_modules/TOOL_PACKAGE/win/x64/**/*",
]如果某个库不需要从磁盘路径直接加载,就不应该进入 asarUnpack。具体规则要以依赖的加载方式和实际构建产物为准;electron-builder 也可能自动解包可执行文件或 native 模块。
ASAR 是归档格式,不是加密或代码混淆。归档内的代码仍然可以被提取;它的主要价值是组织发布文件,并为 ASAR integrity 和加载路径约束提供基础。
Electron fuses:关闭不该留给用户的运行入口
fuses 不是混淆工具,它们是在打包阶段写入 Electron 二进制的能力开关。electron-builder 中可配置的关键项示例如下:
electronFuses: {
runAsNode: false,
enableCookieEncryption: true,
enableNodeOptionsEnvironmentVariable: false,
enableNodeCliInspectArguments: false,
enableEmbeddedAsarIntegrityValidation: true,
onlyLoadAppFromAsar: true,
loadBrowserProcessSpecificV8Snapshot: false,
grantFileProtocolExtraPrivileges: false,
}每一项的技术意义:
runAsNode: false:避免打包后的 Electron 被当成普通 Node 运行入口使用。enableNodeOptionsEnvironmentVariable: false:阻断通过NODE_OPTIONS注入调试、require hook 或其他 Node 参数。enableNodeCliInspectArguments: false:阻断通过命令行 inspect 参数打开调试器。enableEmbeddedAsarIntegrityValidation: true:启用嵌入式 ASAR 完整性校验;运行时会校验 ASAR header hash,不匹配时终止应用。onlyLoadAppFromAsar: true:限制主应用从 ASAR 加载,避免通过 Electron 的应用搜索路径绕过完整性校验。官方建议它通常与上一项配套启用。enableCookieEncryption: true:让 Electron cookie 存储启用加密。grantFileProtocolExtraPrivileges: false:避免给 file 协议额外权限。
这些配置解决的是“降低调试注入和替换加载路径”的问题,不解决“攻击者直接 patch 二进制或重打包”的问题。
授权层:令牌验签必须在 main 进程能力边界做
离线授权最重要的不是把激活窗口做得像锁屏,而是能力调用前必须重新验证令牌。Renderer 层只能提供用户体验,不能作为安全边界。
一种直接的 main 进程检查链路如下:
async function getLicenseStatus(): Promise<LicenseStatus> {
const machineId = getMachineId();
const token = await readStoredLicenseToken();
if (!token) {
return { active: false, machineId, reason: "missing" };
}
return licenseTokenResultToStatus(
await verifyLicenseToken({
machineId,
now: new Date(),
publicKey: licensePublicKey,
token,
}),
machineId,
);
}这里没有缓存授权状态。每次检查都重新读取本地 token、取当前机器码、用当前系统时间验过期,再用内置公钥验签。可以缓存的是公钥解析结果,因为公钥是内置常量;不能缓存的是授权状态,因为 token 可能过期、被替换、被删除或 feature 变化。
关键 IPC handler 要显式写出授权需求:
ipcMain.handle("commands:run-protected-command", async (_event, request) => {
await requireLicense(["protected-feature"]);
return runProtectedCommand(request);
});这样读代码时能直接看到某个能力需要哪个 feature。不要把授权检查只放到路由、按钮禁用或弹窗状态里,因为 renderer 可以被修改或绕过。
令牌层:公钥验签,不是公钥解密
签发端用私钥生成 JWT,客户端只内置公钥做验签。验签逻辑需要同时检查算法、issuer、audience、过期时间、机器码和 payload 结构。
const { payload } = await jwtVerify(token.trim(), key, {
algorithms: ["EdDSA"],
audience: "desktop-app",
currentDate: now,
issuer: "example-license-issuer",
});
if (
typeof payload.sub !== "string" ||
typeof payload.exp !== "number" ||
typeof payload.customer !== "string" ||
typeof payload.machineId !== "string" ||
!Array.isArray(payload.features) ||
!payload.features.every((feature) => typeof feature === "string")
) {
return { valid: false, reason: "invalid" };
}
if (payload.machineId !== machineId) {
return { valid: false, reason: "machine-mismatch" };
}这里的核心判断依据:
algorithms: ["EdDSA"]限定算法,避免接受非预期算法。issuer和audience避免其他系统签发的 JWT 被误用。currentDate让过期判断受当前系统时间控制。- payload 结构检查避免缺字段、错类型或伪造 feature 列表。
machineId比对保证同一令牌不能直接复制到另一台机器使用。
本地存储层:safeStorage 保护 token 文件,不保护代码
本地 token 使用系统安全存储加密后写入文件:
export async function readStoredLicenseToken() {
try {
const encryptedToken = await readFile(getLicenseTokenPath());
return safeStorage.decryptString(encryptedToken);
} catch (error) {
if (isMissingFileError(error)) {
return undefined;
}
throw error;
}
}
export async function writeStoredLicenseToken(token: string) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error("当前系统不可用安全存储,无法保存授权令牌");
}
await writeFile(getLicenseTokenPath(), safeStorage.encryptString(token));
}这层的作用是避免 token 以明文直接落盘,并利用操作系统提供的加密能力增加跨用户、跨设备直接复制文件的难度。它不是独立的密钥保险箱:能够在同一用户上下文中运行代码的攻击者,仍可能调用应用或操作系统提供的解密能力。
safeStorage 的可用性还依赖平台和运行环境,尤其应检查 Linux 上是否存在可用的 secret store。新版本 Electron 文档更推荐异步的 encryptStringAsync、decryptStringAsync 和 isAsyncEncryptionAvailable;采用同步 API 时,也应在应用 ready 后先检查 isEncryptionAvailable()。无论使用哪组 API,safeStorage 都不负责阻止程序代码被 patch。如果攻击者修改 main 进程逻辑,让 getLicenseStatus() 永远返回 active,这一存储层无法阻止。
签发输入和输出要可追踪
签发命令应记录 license id、客户名、机器二维码、过期时间和 feature 列表。例如:
npm run license:sign --workspace APP_WORKSPACE -- `
--license-id lic_001 `
--customer CUSTOMER_NAME `
--machine-qr PATH_TO_MACHINE_QR `
--expires 2027-01-01T00:00:00.000Z `
--features protected-feature,another-feature签发脚本的输出至少应落两类文件:
- 给用户的授权令牌文件。
- 签发方管理用的记录文件,记录客户、机器、过期时间、features 和 token 文件路径。
二维码读取失败时,不应让底层库错误直接暴露给使用者。更稳的做法是先保证应用生成二维码时保留标准 quiet zone,再在签发脚本中给输入图片补白并使用成熟二维码库解码。
发布后检查方法
构建通过不等于安装包内容干净。发布前应解包或安装后检查实际产物:
npm run build --workspace APP_WORKSPACE
npm run release:setup --workspace APP_WORKSPACE检查重点:
- 安装目录内不应出现
renderer/、main/、preload/、*.ts、*.tsx、*.map。 resources/app.asar应存在,应用代码应主要在 ASAR 内。resources/app.asar.unpacked只应包含必须 unpack 的 native 二进制或工具。package.json不应保留开发脚本和无关 metadata。- 启动后授权过期、缺失或 feature 不匹配时,main IPC 应拒绝对应能力。
如果要进一步自动化,可以把检查做成脚本:读取安装包或解包目录,枚举禁止出现的后缀和目录,再校验 app.asar.unpacked 白名单。
结论
这套方案的技术边界是:
- 打包配置减少源码和调试入口暴露。
- Electron fuses 限制运行时注入和加载路径。
- safeStorage 加密本地 token 文件。
- JWT 公钥验签保证令牌真实性、过期时间、机器绑定和功能列表。
- main IPC 能力边界保证不是只靠界面锁定。
仍然无法解决的是:攻击者修改本地程序代码、patch Electron 二进制、重打包应用、绕过所有本地逻辑。代码签名可以帮助用户和操作系统识别发布者并发现分发后的篡改,但同样不会让本地授权逻辑变得不可绕过。要实现吊销、设备数限制和强一致授权状态,需要引入可信服务端,并明确离线宽限期和故障策略。