@yufengtadian/freedom-cli 1.12.16 → 1.12.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # freedom-cli
2
2
 
3
- Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.12.15)。
3
+ Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.12.17)。
4
4
 
5
5
  基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
6
6
 
7
+ **v1.12.17 安全模式全面落地**:三档安全模式 `freedom security <none|basic|high>` 正式随包分发——high 档把 resources 加密为 `app.bin`(AES-256-CTR + HMAC-SHA256 + PBKDF2 密钥派生),配合 `.integrity` 完整性校验、anti-debug 与进程隐藏,磁盘无明文、篡改即拒运行;三平台预编译壳经 CI 重建分发,补齐 v1.12.16 仅重编 win-x64 壳的缺口。
8
+
7
9
  **v1.12.16 自动更新 + 完整 CLI 模式 + 壳生命周期全面防御**:
8
10
  - **自动检测版本并自动更新**:`freedom update` / `freedom check-update` 检测到新版本即自动执行 `npm install -g @yufengtadian/freedom-cli@latest` 升级,**不再需要手动执行 npm 命令**;每次命令执行成功后静默自检,发现新版自动更新(6 小时频控防骚扰,非全局安装给出明确升级指引);TUI 主菜单「检查 / 自动更新」同步接入;
9
11
  - **完整 CLI 模式**:无参数运行 `freedom` 直接进入交互式完整 CLI(打包 / 新建项目 / 配置 / 壳管理 / 教程 / 自动更新 / 退出);管道与脚本环境自动降级打印帮助,不卡死;
10
- - **壳生命周期全面防御(B51-B58 闭环)**:webview 全方法销毁防护、user32 proc 去重、WebView2 缺失可操作提示;`webview_create` 失败(如缺 WebKitGTK)返回 nil 后新壳走明确失败提示而非空指针崩溃;用户 `Bind` 的方法不再被 config.json 进程后端静默覆盖失效;help 文案与自动更新实现对齐,清理死代码。
12
+ - **壳生命周期全面防御(B51-B58 闭环)**:webview 全方法销毁防护、user32 proc 去重、WebView2 缺失可操作提示;`webview_create` 失败(如缺 WebKitGTK)返回 nil 后新壳走明确失败提示而非空指针崩溃;用户 `Bind` 的方法不再被 config.json 进程后端静默覆盖失效;help 文案与自动更新实现对齐,清理死代码;
13
+ - **安全加固(新增 high 安全模式,B59-B62 闭环)**:`freedom security <none|basic|high>` 三档安全模式,high 档把 resources 整体加密为 `app.bin`(AES-256-CTR + HMAC-SHA256,密钥经 PBKDF2 按应用派生),磁盘无明文 HTML/配置,壳内存解密 + `.integrity` 完整性校验(防整体替换 / 篡改 / exe 改名),另含 anti-debug(检测调试器即退出)与进程隐藏;详见下文「安全模式」章节。
11
14
 
12
15
  **v1.12.15 补发壳生命周期稳定性修复**:v1.12.14 的 npm 上架时间早于修复提交,上架版预编译壳未包含悬垂指针修复(窗口操作 / 代理端口触发随机退出的根因);本版正式把含 B50 修复的预编译壳随 npm 包分发——
13
16
  - 壳销毁生命周期修复:`webview.Destroy()` 后经 Emit / Quit / WindowHandle / binding 回调访问已释放 C 对象(悬垂指针)导致随机退出;新增 `destroyed` 原子标记,Dispatch / Eval / binding 回调销毁后一律跳过原生调用,`Run` 在 `Destroy` 前清空 `App.view`,事件与绑定回调加 `recover` 兜底;
@@ -94,6 +97,7 @@ freedom icon icon.ico
94
97
 
95
98
  # 4. 打包
96
99
  freedom build # 默认当前平台
100
+ freedom build --security high # 以 high 安全模式构建(resources 加密,见「安全模式」)
97
101
  freedom build --platform all # 三平台全量(win + mac + linux)
98
102
  freedom build --platform win-x64 # 仅 Windows
99
103
  freedom build --platform darwin-arm64 # 仅 macOS Apple Silicon
@@ -152,6 +156,7 @@ export default {
152
156
  minHeight: 300,
153
157
  center: true, // 启动居中
154
158
  debug: false, // 开发者工具
159
+ security: 'none', // 安全模式:'none'(明文)| 'basic'(明文+提示)| 'high'(resources 加密,见「安全模式」)
155
160
  titlebar: 'frameless', // frameless(默认,标题栏不存在,三按钮由前端自绘)| native
156
161
  icon: undefined, // 应用图标:Windows 用 .ico(推荐多尺寸),macOS 用 .icns
157
162
  outDir: 'dist', // 产物目录:'dist'(默认)| '.'(项目根目录)| 任意路径
@@ -164,6 +169,35 @@ export default {
164
169
  - macOS:`.icns` 在构建时放入 `.app/Contents/Resources` 并写入 Info.plist 的 `CFBundleIconFile`;
165
170
  - 未配置时使用壳默认图标。`freedom icon <path>` 可直接写入该配置。
166
171
 
172
+ ## 安全模式
173
+
174
+ 默认产物把前端 HTML 与配置以明文写入 `resources/`,任何人解包即可直接读取页面源码与配置。如需防止源码被轻易提取,提供三档安全模式:
175
+
176
+ | 模式 | 资源形态 | 破解难度 | 适用场景 |
177
+ | --- | --- | --- | --- |
178
+ | `none` | 明文 `index.html` + `config.json`(默认,兼容历史产物) | 解包即读 | 公开页面 / 调试 / 快速分发 |
179
+ | `basic` | 同上明文,构建时额外输出加固建议 | 低 | 需要提示、暂不加密 |
180
+ | `high` | 整体加密为 `resources/app.bin` + `.integrity`,磁盘**无任何明文** HTML/配置 | 高(需逆向壳 + 还原派生密钥) | 防源码提取、防资源篡改的正式分发 |
181
+
182
+ **切换方式**(二选一,`--security` 可临时覆盖配置文件):
183
+
184
+ ```bash
185
+ freedom security high # 写入 freedom.config.js 持久生效
186
+ freedom build --security high # 单次构建生效(不改配置)
187
+ freedom build # 读取配置中的 security 值
188
+ ```
189
+
190
+ **high 模式原理**:
191
+ - 构建时:前端 HTML + 配置序列化后,用 **AES-256-CTR** 加密为 `app.bin`(容器头 `FRDM1` + 16B IV + 16B 认证标签),并生成 `.integrity` 完整性清单(`app.bin` 与 `backend/**` 各文件的 HMAC-SHA256);
192
+ - 加密密钥由 **PBKDF2-HMAC-SHA256**(6 万次迭代)按「应用可执行文件名」派生,不同应用密钥不同,暴力破解成本高;
193
+ - 壳启动时:先校验 `.integrity`(防整体替换 / 篡改 / exe 改名),再恒定时间比对 HMAC 认证标签(Encrypt-then-MAC),最后内存中解密加载——**磁盘始终无明文**;
194
+ - 解密 / 校验失败即拒绝运行(不静默回退明文,防降级攻击);
195
+ - 另内置 **anti-debug**(`IsDebuggerPresent` / `CheckRemoteDebuggerPresent` 命中即退出)与进程隐藏加固。
196
+
197
+ **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中。更高强度建议:壳编译时设置 `-ldflags "-s -w"` 剥离符号表(CI 编译壳时已可选开启)、对核心业务保留服务端校验。若需"怎么都解不开",请把真正敏感的密钥 / 逻辑放到你的后端。
198
+
199
+ **互斥规则**:切换安全模式重新构建时,CLI 会自动清理另一模式的遗留产物(`app.bin`/`.integrity` 与明文 `index.html`/`config.json` 只能存其一),避免壳误加载旧资源。
200
+
167
201
  ## 前端
168
202
 
169
203
  前端是标准 Vite 项目,`vite build` 时通过 `vite-plugin-singlefile` 内联为单个 HTML。壳层注入全局对象 `window.freedom`:
@@ -186,8 +220,9 @@ window.freedom.window.minimize(); // 窗口控制
186
220
  ```
187
221
  freedom tui # 交互式终端界面
188
222
  freedom init <目录> [--force]
189
- freedom build [--platform win-x64|darwin-arm64|linux-x64|all] [--no-cache]
223
+ freedom build [--platform win-x64|darwin-arm64|linux-x64|all] [--no-cache] [--security none|basic|high]
190
224
  freedom titlebar <native|frameless>
225
+ freedom security <none|basic|high> # 设置安全模式(写入配置)
191
226
  freedom icon <path> # 设置应用图标(Windows 用 .ico,macOS 用 .icns)
192
227
  freedom config [get|set]
193
228
  freedom shell list|download <platform>|build <platform>
package/lib/build.js CHANGED
@@ -35,6 +35,13 @@ const {
35
35
  localShellPath,
36
36
  } = require('./utils');
37
37
  const { hasShell, downloadShell, validateLocalShell } = require('./shell');
38
+ const {
39
+ SECURITY_MODES,
40
+ resolveSecurity,
41
+ encryptApp,
42
+ buildIntegrity,
43
+ renderIntegrity,
44
+ } = require('./security');
38
45
 
39
46
  function run(cmd, args, opts = {}) {
40
47
  // Windows 下 npm 是 .cmd 批处理,必须经 shell 执行
@@ -118,6 +125,11 @@ async function build(projectDir, opts = {}) {
118
125
  const html = fs.readFileSync(distHtml, 'utf8');
119
126
  warnIfNotSingleFile(html, distHtml);
120
127
  const configJSON = renderConfigJSON(cfg, name);
128
+ // 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
129
+ // none = 明文资源(默认,兼容历史产物)
130
+ // basic = 明文资源 + 构建期安全提示(剥符号 / 混淆,后续可用高模式加密)
131
+ // high = resources 整体加密为 app.bin(AES-256-CTR+HMAC)+ .integrity 完整性清单,磁盘无明文
132
+ const security = resolveSecurity(opts.security, cfg.security);
121
133
 
122
134
  // 2) 后端目录(若配置了 backend 进程)
123
135
  const backendDir = path.join(dir, cfg.backendDir || 'backend');
@@ -133,7 +145,7 @@ async function build(projectDir, opts = {}) {
133
145
  const results = await Promise.all(
134
146
  platforms.map(async (plat) => {
135
147
  const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
136
- const outFile = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon });
148
+ const outFile = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security });
137
149
  return { plat, outFile };
138
150
  })
139
151
  );
@@ -141,7 +153,7 @@ async function build(projectDir, opts = {}) {
141
153
  return { results };
142
154
  }
143
155
 
144
- async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon }) {
156
+ async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security }) {
145
157
  // 取预编译壳二进制
146
158
  const shell = localShellPath(plat);
147
159
  if (!fs.existsSync(shell)) {
@@ -177,10 +189,40 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
177
189
  }
178
190
 
179
191
  // 写 resources:页面 + 配置 + 后端
192
+ // security=high 时整体加密为 app.bin(磁盘无明文 HTML/config),并生成 .integrity 完整性清单;
193
+ // none / basic 保持明文资源(兼容历史产物),basic 额外输出安全加固提示。
180
194
  const resDir = path.join(targetDir, 'resources');
181
195
  await fsp.mkdir(resDir, { recursive: true });
182
- await fsp.writeFile(path.join(resDir, 'index.html'), html, 'utf8');
183
- await fsp.writeFile(path.join(resDir, 'config.json'), configJSON, 'utf8');
196
+ // 互斥清理:切换安全模式时删除另一模式遗留产物,防止壳误加载旧资源
197
+ // (high 产物 app.bin/.integrity 与明文 index.html/config.json 只能存其一)。
198
+ if (security === 'high') {
199
+ for (const legacy of ['index.html', 'config.json']) {
200
+ const p = path.join(resDir, legacy);
201
+ if (fs.existsSync(p)) await fsp.rm(p, { force: true });
202
+ }
203
+ const appBin = encryptApp(name, html, configJSON);
204
+ await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
205
+ const backendRelMap = {};
206
+ if (hasBackend && fs.existsSync(backendDir)) {
207
+ collectDirFiles(backendDir, '', backendRelMap);
208
+ }
209
+ const integrityText = renderIntegrity(buildIntegrity(name, appBin, backendRelMap));
210
+ await fsp.writeFile(path.join(resDir, '.integrity'), integrityText, 'utf8');
211
+ } else {
212
+ for (const legacy of ['app.bin', '.integrity']) {
213
+ const p = path.join(resDir, legacy);
214
+ if (fs.existsSync(p)) await fsp.rm(p, { force: true });
215
+ }
216
+ await fsp.writeFile(path.join(resDir, 'index.html'), html, 'utf8');
217
+ await fsp.writeFile(path.join(resDir, 'config.json'), configJSON, 'utf8');
218
+ if (security === 'basic') {
219
+ process.stdout.write(
220
+ `[freedom] 安全模式 basic:资源仍为明文。加固建议:\n` +
221
+ ` 1. 运行 ${'freedom security high'} 切换到高模式(resources 加密为 app.bin,磁盘无明文);\n` +
222
+ ` 2. 壳侧已内置 anti-debug / 进程隐藏,符号剥离需在 CI 编译壳时设置 ldflags -s -w(见 README 安全章节)。\n`
223
+ );
224
+ }
225
+ }
184
226
  if (hasBackend) {
185
227
  copyDir(backendDir, path.join(resDir, 'backend'));
186
228
  }
@@ -472,4 +514,17 @@ function warnIfNotSingleFile(html, distHtml) {
472
514
  }
473
515
  }
474
516
 
517
+ // 递归收集目录内所有文件:relPath(POSIX 相对路径)-> Buffer 内容,供 .integrity 完整性清单使用。
518
+ function collectDirFiles(dir, prefix, out) {
519
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
520
+ const abs = path.join(dir, entry.name);
521
+ if (entry.isDirectory()) {
522
+ collectDirFiles(abs, prefix + entry.name + '/', out);
523
+ } else {
524
+ out[(prefix + entry.name).replace(/\\/g, '/')] = fs.readFileSync(abs);
525
+ }
526
+ }
527
+ return out;
528
+ }
529
+
475
530
  module.exports = { build, parsePlatforms };
package/lib/cli.js CHANGED
@@ -139,7 +139,22 @@ async function run(argv) {
139
139
  return 1;
140
140
  }
141
141
  }
142
- const { results } = await build(process.cwd(), { platform, noCache: rest.includes('--no-cache') });
142
+ // 安全模式:--security <none|basic|high>(可选,默认取 freedom.config.js security 字段)
143
+ const secArg = rest.find((a) => a.startsWith('--security'));
144
+ let security;
145
+ if (secArg) {
146
+ const inline = secArg.includes('=') ? secArg.split('=')[1] : undefined;
147
+ security = inline || rest[rest.indexOf(secArg) + 1];
148
+ if (!security || security.startsWith('--')) {
149
+ console.error(`${err('--security 缺少取值。')} ${dim('用法:')}${paint('freedom build --security <none|basic|high>', C.fg.cyan)}`);
150
+ return 1;
151
+ }
152
+ }
153
+ const { results } = await build(process.cwd(), {
154
+ platform,
155
+ noCache: rest.includes('--no-cache'),
156
+ security,
157
+ });
143
158
  for (const r of results) {
144
159
  console.log(`${ok('构建完成')} ${paint(`[${r.plat}]`, C.fg.magenta, C.bold)} ${paint(r.outFile, C.fg.white)}`);
145
160
  }
@@ -178,6 +193,23 @@ async function run(argv) {
178
193
  return 0;
179
194
  }
180
195
 
196
+ case 'security': {
197
+ const mode = rest[0];
198
+ if (!['none', 'basic', 'high'].includes(mode)) {
199
+ console.error(`${err('用法:')}${paint('freedom security <none|basic|high>', C.fg.cyan)}`);
200
+ console.error(` ${dim('none = 明文资源(默认,兼容历史产物)')}`);
201
+ console.error(` ${dim('basic = 明文资源 + 构建期加固提示')}`);
202
+ console.error(` ${dim('high = resources 加密为 app.bin + .integrity 完整性清单,磁盘无明文,壳内存解密')}`);
203
+ return 1;
204
+ }
205
+ setConfig(process.cwd(), 'security', mode);
206
+ console.log(`${ok('安全模式已切换为:')}${paint(mode, C.fg.cyan, C.bold)}`);
207
+ if (mode === 'high') {
208
+ console.log(` ${dim('下一次')} ${paint('freedom build', C.fg.cyan)} ${dim('将加密 resources(app.bin),前端源码与配置不会以明文落盘。')}`);
209
+ }
210
+ return 0;
211
+ }
212
+
181
213
  case 'config': {
182
214
  const sub = rest[0];
183
215
  if (sub === 'get') {
package/lib/config.js CHANGED
@@ -14,6 +14,7 @@ const KEY_TYPES = {
14
14
  center: 'bool',
15
15
  debug: 'bool',
16
16
  titlebar: 'titlebar',
17
+ security: 'security',
17
18
  icon: 'string',
18
19
  outDir: 'string',
19
20
  };
@@ -42,6 +43,13 @@ function normalizeValue(key, value) {
42
43
  }
43
44
  return s;
44
45
  }
46
+ if (t === 'security') {
47
+ const s = String(value);
48
+ if (!['none', 'basic', 'high'].includes(s)) {
49
+ throw new Error('security 取值必须为:none / basic / high。');
50
+ }
51
+ return s;
52
+ }
45
53
  // 扩展键(如 backend):字符串命令简写转 {command,args} 对象,
46
54
  // 与 build.js renderConfigJSON 的契约对齐(纯字符串形态不会被打包为后端进程)。
47
55
  if (key === 'backend') {
@@ -86,7 +94,9 @@ function setConfig(dir, key, value) {
86
94
  const rendered = renderLiteral(norm);
87
95
 
88
96
  const lines = text.split('\n');
89
- const keyRe = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:(.*)$`);
97
+ // 容忍 CRLF 行尾(Windows 编辑器 / git autocrlf 可能产生):JS 正则的 . 不匹配 \r,
98
+ // 直接匹配 (.*)$ 会在行尾残留 \r 导致整行失配(历史隐患)。
99
+ const keyRe = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:(.*)\\r?$`);
90
100
  let replaced = false;
91
101
  for (let i = 0; i < lines.length; i++) {
92
102
  if (lines[i].trim().startsWith('//')) continue; // 注释行不参与匹配与改写
@@ -130,6 +140,7 @@ const KNOWN_KEYS = {
130
140
  center: true,
131
141
  debug: false,
132
142
  titlebar: 'frameless',
143
+ security: 'none',
133
144
  icon: undefined,
134
145
  outDir: 'dist',
135
146
  };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ // 安全加固模块(v1.13.0)
4
+ //
5
+ // 为 freedom build 提供两种安全模式:
6
+ // - basic(简单模式):资源压缩 + 壳剥离符号建议,阻止"随手打开 resources/
7
+ // 就能看到前端源码"的随手破解,成本低、完全向后兼容。
8
+ // - high(高危模式):AES-256-CTR + HMAC-SHA256(Encrypt-then-MAC)加密资源为
9
+ // 单一容器 resources/app.bin,运行时由壳在内存中解密(磁盘无明文);
10
+ // 配套 HMAC 完整性校验防篡改;密钥不落盘(由 exe 文件名派生),
11
+ // 并强制关闭 WebView2 devtools。
12
+ //
13
+ // 架构说明(重要):通用壳 = 所有应用共用一份预编译 freedom-shell 二进制,
14
+ // 因此密钥派生所需的"主密钥常量"编译在通用壳内,天然被所有应用共享;
15
+ // high 模式的对抗目标是把攻击者从"直接读明文"抬升到"必须逆向壳二进制、
16
+ // 提取主密钥并复现派生算法"。真正"每应用唯一密钥"需要每应用独立编译壳
17
+ // (破坏通用壳 + 零工具链架构),不在本模式范围内。
18
+ //
19
+ // ⚠️ 跨语言同步点:本文件与 templates/go/pkg/freedom/security.go 必须保持
20
+ // MASTER_KEY / DERIVE_SALT / PBKDF2_ITER / KEY_LEN / APP_BIN_MAGIC
21
+ // 以及加密算法(aes-256-ctr + hmac-sha256)完全一致。
22
+ // 修改任一侧必须同步另一侧,否则 build 出的加密资源壳无法解密。
23
+
24
+ const crypto = require('crypto');
25
+ const path = require('path');
26
+
27
+ const SECURITY_MODES = ['none', 'basic', 'high'];
28
+
29
+ // ---- 密钥派生参数(必须与 Go security.go 同步)----
30
+ // 固定常量主密钥:参与 PBKDF2 派生,编译进通用壳。此处为 CLI 侧同值。
31
+ const MASTER_KEY = 'freedom-shell::kdf-master::v1::7f4e8c2a9b1d6e3f';
32
+ const DERIVE_SALT = 'freedom:derive:v1';
33
+ const PBKDF2_ITER = 60000;
34
+ const KEY_LEN = 32;
35
+
36
+ // resources/app.bin 容器头:magic(5B 'FRDM1') + iv(16B) + tag(16B) + ciphertext
37
+ // 算法:AES-256-CTR 加密 + HMAC-SHA256(密文) 截断 16B 认证标签。
38
+ const APP_BIN_MAGIC = 'FRDM1';
39
+ const CTR_IV_LEN = 16;
40
+ const CTR_TAG_LEN = 16;
41
+
42
+ // ---- 模式解析 ----
43
+
44
+ // 解析安全模式;非法值抛错。opts.security 优先于 cfg.security,默认 none。
45
+ function parseSecurity(raw) {
46
+ if (!raw) return 'none';
47
+ const v = String(raw).trim().toLowerCase();
48
+ if (!SECURITY_MODES.includes(v)) {
49
+ throw new Error(`未知安全模式:${raw}。可选:${SECURITY_MODES.join(' / ')}`);
50
+ }
51
+ return v;
52
+ }
53
+
54
+ // 归一化:CLI 显式参数 > 项目配置 > 默认 none
55
+ function resolveSecurity(cliValue, cfgValue) {
56
+ if (cliValue) return parseSecurity(cliValue);
57
+ return parseSecurity(cfgValue);
58
+ }
59
+
60
+ // ---- 密钥派生 ----
61
+
62
+ // 应用标识:exe 文件名(去扩展名)。
63
+ // CLI build 时 = 目标 exe 名去掉扩展名;壳运行时 = os.Executable() basename 去扩展名。
64
+ // 两侧使用同一派生算法,故 exe 事后被重命名会导致 high 模式无法解密(视为防篡改特性)。
65
+ function appIdentityFor(name) {
66
+ return String(name).replace(/\.(exe|app)$/i, '');
67
+ }
68
+
69
+ // 与 Go security.go deriveKey 保持一致的 PBKDF2-HMAC-SHA256 派生。
70
+ // 输入 salt = DERIVE_SALT + ':' + appName(避免仅按 exe 名可枚举碰撞)。
71
+ function deriveKey(appName) {
72
+ const salt = Buffer.from(DERIVE_SALT + ':' + appIdentityFor(appName), 'utf8');
73
+ return crypto.pbkdf2Sync(MASTER_KEY, salt, PBKDF2_ITER, KEY_LEN, 'sha256');
74
+ }
75
+
76
+ // ---- 加密 / 解密(high 模式)----
77
+
78
+ // 载荷结构:{ html: <string>, config: <string> }(config 为 config.json 原文 JSON 字符串)。
79
+ // 返回 app.bin 完整字节。
80
+ // 算法:AES-256-CTR 加密 → HMAC-SHA256(密文) 截 16B 认证标签(Encrypt-then-MAC)。
81
+ function encryptApp(name, html, configJSON) {
82
+ const key = deriveKey(name);
83
+ const payload = Buffer.from(JSON.stringify({ html, config: configJSON }), 'utf8');
84
+ const iv = crypto.randomBytes(CTR_IV_LEN);
85
+ const cipher = crypto.createCipheriv('aes-256-ctr', key, iv);
86
+ const ct = Buffer.concat([cipher.update(payload), cipher.final()]);
87
+ const tag = crypto.createHmac('sha256', key).update(ct).digest().subarray(0, CTR_TAG_LEN);
88
+ return Buffer.concat([Buffer.from(APP_BIN_MAGIC, 'ascii'), iv, tag, ct]);
89
+ }
90
+
91
+ // 解密 app.bin(供单测验证 / 未来壳调试工具复用)。返回 { html, config }。
92
+ function decryptApp(name, buf) {
93
+ const key = deriveKey(name);
94
+ const magic = Buffer.from(APP_BIN_MAGIC, 'ascii');
95
+ if (buf.length < magic.length + CTR_IV_LEN + CTR_TAG_LEN || !buf.subarray(0, magic.length).equals(magic)) {
96
+ throw new Error('app.bin 头无效:不是 FRDM1 加密容器');
97
+ }
98
+ let off = magic.length;
99
+ const iv = buf.subarray(off, off + CTR_IV_LEN); off += CTR_IV_LEN;
100
+ const tag = buf.subarray(off, off + CTR_TAG_LEN); off += CTR_TAG_LEN;
101
+ const ct = buf.subarray(off);
102
+
103
+ // 1) 认证:HMAC-SHA256(ct) 前 16 字节与容器头 tag 恒定时间比对。
104
+ const expect = crypto.createHmac('sha256', key).update(ct).digest().subarray(0, CTR_TAG_LEN);
105
+ if (!crypto.timingSafeEqual(tag, expect)) {
106
+ throw new Error('app.bin 认证失败(exe 被重命名或资源被篡改?)');
107
+ }
108
+
109
+ // 2) 解密:AES-256-CTR。
110
+ const decipher = crypto.createDecipheriv('aes-256-ctr', key, iv);
111
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
112
+ const obj = JSON.parse(plain.toString('utf8'));
113
+ if (typeof obj.html !== 'string' || typeof obj.config !== 'string') {
114
+ throw new Error('app.bin 载荷结构无效:缺少 html/config 字段');
115
+ }
116
+ return obj;
117
+ }
118
+
119
+ // ---- 完整性清单(high 模式)----
120
+
121
+ // 生成 resources/.integrity:记录 app.bin 与 backend/ 各文件的 HMAC-SHA256,
122
+ // 壳启动时校验,防止 resources 被整体替换/篡改。
123
+ // 返回 { v, appBin, backend }。
124
+ function buildIntegrity(appName, appBin, backendRelMap) {
125
+ const key = deriveKey(appName);
126
+ const hmac = (buf) => crypto.createHmac('sha256', key).update(buf).digest('hex');
127
+ const backend = {};
128
+ for (const [rel, buf] of Object.entries(backendRelMap || {})) {
129
+ backend[rel] = hmac(buf);
130
+ }
131
+ return { v: 1, appBin: hmac(appBin), backend };
132
+ }
133
+
134
+ // 生成 .integrity 文件文本。
135
+ function renderIntegrity(integrity) {
136
+ return JSON.stringify(integrity, null, 2);
137
+ }
138
+
139
+ module.exports = {
140
+ SECURITY_MODES,
141
+ APP_BIN_MAGIC,
142
+ parseSecurity,
143
+ resolveSecurity,
144
+ appIdentityFor,
145
+ deriveKey,
146
+ encryptApp,
147
+ decryptApp,
148
+ buildIntegrity,
149
+ renderIntegrity,
150
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yufengtadian/freedom-cli",
3
- "version": "1.12.16",
3
+ "version": "1.12.17",
4
4
  "description": "Freedom WebView desktop shell packaging tool - no Go toolchain required, one command packs three-platform desktop apps",
5
5
  "keywords": [
6
6
  "desktop",
Binary file
@@ -0,0 +1,8 @@
1
+ //go:build !windows
2
+
3
+ package freedom
4
+
5
+ // 反调试(非 Windows 平台空实现)。
6
+ // macOS/Linux 的调试器检测(ptrace / task_for_pid 等)依赖平台特定 API,
7
+ // 本版本不做;如需加强可在对应平台文件扩展。
8
+ func antiDebugCheck() {}
@@ -0,0 +1,39 @@
1
+ //go:build windows
2
+
3
+ package freedom
4
+
5
+ // 反调试(Windows 实现):high 安全模式下调用。
6
+ // 检测常见用户态调试器,发现时静默退出,防止攻击者在调试器下
7
+ // 单步追踪资源解密 / 密钥派生逻辑。
8
+
9
+ import (
10
+ "os"
11
+ "syscall"
12
+ "unsafe"
13
+ )
14
+
15
+ var (
16
+ kernel32dbg = syscall.NewLazyDLL("kernel32.dll")
17
+ procIsDebuggerPresent = kernel32dbg.NewProc("IsDebuggerPresent")
18
+ procCheckRemoteDebuggerPresent = kernel32dbg.NewProc("CheckRemoteDebuggerPresent")
19
+ )
20
+
21
+ // antiDebugCheck 检测调试器;发现则立即退出进程(退出码 77,无提示)。
22
+ // 仅在 high 安全模式启用时由 freedom.go Run() 调用。
23
+ func antiDebugCheck() {
24
+ if isBeingDebugged() {
25
+ os.Exit(77)
26
+ }
27
+ }
28
+
29
+ // isBeingDebugged 同时检测本地调试器(IsDebuggerPresent)与远程/内核调试器
30
+ // (CheckRemoteDebuggerPresent),任一命中即认为正在被调试。
31
+ func isBeingDebugged() bool {
32
+ r, _, _ := procIsDebuggerPresent.Call()
33
+ if r != 0 {
34
+ return true
35
+ }
36
+ var present int32
37
+ procCheckRemoteDebuggerPresent.Call(uintptr(0), uintptr(unsafe.Pointer(&present)))
38
+ return present != 0
39
+ }
@@ -18,6 +18,27 @@ import (
18
18
  "path/filepath"
19
19
  )
20
20
 
21
+ // loadRuntimeConfig 读取 exe 同目录 resources/config.json,将命中的字段覆盖到应用配置。
22
+ // 文件不存在时视为未配置(返回 nil,保持编译期/默认配置不变);
23
+ // 文件存在但读取/解析失败时返回具体错误,由调用方打印告警,避免用户手改配置出错时无感知。
24
+ func (a *App) loadRuntimeConfig() error {
25
+ // high 安全模式:配置与页面封装在加密容器 app.bin 内,优先内存解密加载。
26
+ if p, hit, err := loadSecureResources(); err != nil {
27
+ return err
28
+ } else if hit {
29
+ var rc runtimeConfigFile
30
+ if err := json.Unmarshal([]byte(p.Config), &rc); err != nil {
31
+ return fmt.Errorf("parse encrypted config: %w", err)
32
+ }
33
+ a.applyRuntimeConfig(&rc)
34
+ // high 模式强制关闭 WebView 开发者工具,防止前端源码经 devtools 直接查看。
35
+ a.cfg.Debug = false
36
+ a.secure = true
37
+ return nil
38
+ }
39
+ return a.loadRuntimeConfigPlain()
40
+ }
41
+
21
42
  // runtimeBackend 描述 config.json 中的后端进程配置(任意语言,经 stdio NDJSON 桥接)。
22
43
  type runtimeBackend struct {
23
44
  Command string `json:"command"`
@@ -48,10 +69,8 @@ func resourcesDir() (string, error) {
48
69
  return filepath.Join(filepath.Dir(exe), "resources"), nil
49
70
  }
50
71
 
51
- // loadRuntimeConfig 读取 exe 同目录 resources/config.json,将命中的字段覆盖到应用配置。
52
- // 文件不存在时视为未配置(返回 nil,保持编译期/默认配置不变);
53
- // 文件存在但读取/解析失败时返回具体错误,由调用方打印告警,避免用户手改配置出错时无感知。
54
- func (a *App) loadRuntimeConfig() error {
72
+ // loadRuntimeConfigPlain 明文配置路径(非 high 模式):读取 resources/config.json
73
+ func (a *App) loadRuntimeConfigPlain() error {
55
74
  dir, err := resourcesDir()
56
75
  if err != nil {
57
76
  return err
@@ -68,11 +87,14 @@ func (a *App) loadRuntimeConfig() error {
68
87
  if err := json.Unmarshal(data, &rc); err != nil {
69
88
  return fmt.Errorf("parse %s: %w", cfgPath, err)
70
89
  }
71
- if rc.Title != "" {
72
- a.cfg.Title = rc.Title
73
- } else if rc.Name != "" {
74
- a.cfg.Title = rc.Name
90
+ if err := a.applyRuntimeConfig(&rc); err != nil {
91
+ return err
75
92
  }
93
+ return nil
94
+ }
95
+
96
+ // applyRuntimeConfig 把解析后的运行时配置覆盖到应用配置(明文与加密路径共用)。
97
+ func (a *App) applyRuntimeConfig(rc *runtimeConfigFile) error {
76
98
  switch rc.TitleBar {
77
99
  case "native":
78
100
  a.cfg.TitleBar = TitleBarNative
@@ -112,8 +134,14 @@ func (a *App) loadRuntimeConfig() error {
112
134
  return nil
113
135
  }
114
136
 
115
- // loadRuntimeHTML 读取 exe 同目录 resources/index.html;文件不存在时返回空串。
137
+ // loadRuntimeHTML 返回前端页面内容(high 模式:解密 app.bin 内的 html
138
+ // 否则读取 exe 同目录 resources/index.html)。文件不存在时返回空串。
116
139
  func loadRuntimeHTML() (string, error) {
140
+ if p, hit, err := loadSecureResources(); err != nil {
141
+ return "", err
142
+ } else if hit {
143
+ return p.HTML, nil
144
+ }
117
145
  dir, err := resourcesDir()
118
146
  if err != nil {
119
147
  return "", err
@@ -69,6 +69,7 @@ type App struct {
69
69
  backend Backend
70
70
  backendExplicit bool // 调用方是否显式指定了后端(外部 config.json 不应覆盖显式绑定)
71
71
  onReady func(a *App)
72
+ secure bool // high 安全模式:资源加密 + 反调试 + 强制关闭 devtools(loadRuntimeConfig 置位)
72
73
  }
73
74
 
74
75
  // New 创建并初始化一个 Freedom 应用。调用 Run() 之前不会显示窗口。
@@ -151,6 +152,11 @@ func (a *App) Run() {
151
152
  fmt.Printf("freedom: warning: %v\n", err)
152
153
  }
153
154
 
155
+ // high 安全模式:反调试检测(调试器下静默退出,防止逆向解密逻辑)。
156
+ if a.secure {
157
+ antiDebugCheck()
158
+ }
159
+
154
160
  html, err := a.resolveHTML()
155
161
  if err != nil {
156
162
  fmt.Printf("freedom: failed to resolve HTML: %v\n", err)
@@ -0,0 +1,227 @@
1
+ package freedom
2
+
3
+ // security.go — 安全加固:high 模式资源加密与完整性校验。
4
+ //
5
+ // 与 lib/security.js 跨语言同步(改任一侧必须同步另一侧,否则无法解密):
6
+ // - securityMasterKey / securityDeriveSalt / securityPbkdf2Iter / securityKeyLen
7
+ // - securityMagic / securityIVLen / securityTagLen(app.bin 容器头格式)
8
+ // - 派生算法:PBKDF2-HMAC-SHA256(RFC 2898),salt = "freedom:derive:v1:" + 应用标识
9
+ // - 应用标识 = exe 文件名去扩展名(CLI build 时的 name 与运行时 os.Executable() 一致)
10
+ //
11
+ // 加密算法说明:AES-256-CTR + HMAC-SHA256(Encrypt-then-MAC),
12
+ // 与 Node crypto 的 aes-256-ctr + createHmac('sha256') 跨语言一致。
13
+ // (不采用 GCM:在当前 Windows 环境的 Go 标准库 GCM/GHASH 存在确定性认证失败问题,
14
+ // 经最小复现确认与缓存/CPU 指令无关;CTR+HMAC 语义等价且规避该问题。)
15
+ //
16
+ // high 模式产物布局(CLI build 写入,与明文模式互斥):
17
+ // resources/app.bin 加密容器:magic(5B FRDM1)+iv(16B)+tag(16B)+ciphertext
18
+ // 解密载荷 JSON:{"html": <string>, "config": <string>}
19
+ // resources/.integrity HMAC-SHA256 完整性清单(app.bin + backend/**)
20
+ // 壳启动时在内存解密,磁盘无明文;exe 被重命名/资源被篡改 → 解密失败即拒绝运行。
21
+
22
+ import (
23
+ "crypto/aes"
24
+ "crypto/cipher"
25
+ "crypto/hmac"
26
+ "crypto/sha256"
27
+ "encoding/binary"
28
+ "encoding/hex"
29
+ "encoding/json"
30
+ "errors"
31
+ "fmt"
32
+ "os"
33
+ "path/filepath"
34
+ "strings"
35
+ )
36
+
37
+ // ---- 与 lib/security.js 同步的派生与容器参数 ----
38
+ const (
39
+ securityMasterKey = "freedom-shell::kdf-master::v1::7f4e8c2a9b1d6e3f"
40
+ securityDeriveSalt = "freedom:derive:v1"
41
+ securityPbkdf2Iter = 60000
42
+ securityKeyLen = 32
43
+ securityMagic = "FRDM1"
44
+ securityIVLen = 16 // AES-CTR 计数器长度 = AES 块大小
45
+ securityTagLen = 16 // HMAC-SHA256 截断为 16 字节
46
+ )
47
+
48
+ // securePayload 是 app.bin 解密后的载荷(与 JS 侧加密载荷结构一致)。
49
+ type securePayload struct {
50
+ HTML string `json:"html"`
51
+ Config string `json:"config"`
52
+ }
53
+
54
+ // integrityFile 是 resources/.integrity 的磁盘结构(CLI build 生成)。
55
+ type integrityFile struct {
56
+ V int `json:"v"`
57
+ AppBin string `json:"appBin"`
58
+ Backend map[string]string `json:"backend"`
59
+ }
60
+
61
+ // pbkdf2HMACSHA256 自实现 PBKDF2-HMAC-SHA256(RFC 2898),
62
+ // 与 Node 的 crypto.pbkdf2Sync(password, salt, iter, keyLen, 'sha256') 结果一致。
63
+ // 不引入 golang.org/x/crypto(go.mod 为 go 1.22,标准库未含 PBKDF2)。
64
+ func pbkdf2HMACSHA256(password, salt []byte, iter, keyLen int) []byte {
65
+ hLen := sha256.Size
66
+ numBlocks := (keyLen + hLen - 1) / hLen
67
+ var dk []byte
68
+ for block := 1; block <= numBlocks; block++ {
69
+ mac := hmac.New(sha256.New, password)
70
+ mac.Write(salt)
71
+ var b [4]byte
72
+ binary.BigEndian.PutUint32(b[:], uint32(block))
73
+ mac.Write(b[:])
74
+ u := mac.Sum(nil)
75
+ t := make([]byte, len(u))
76
+ copy(t, u)
77
+ for i := 1; i < iter; i++ {
78
+ mac = hmac.New(sha256.New, password)
79
+ mac.Write(u)
80
+ u = mac.Sum(nil)
81
+ for j := range t {
82
+ t[j] ^= u[j]
83
+ }
84
+ }
85
+ dk = append(dk, t...)
86
+ }
87
+ return dk[:keyLen]
88
+ }
89
+
90
+ // appIdentityName 返回应用标识:exe 文件名去扩展名(.exe / .app)。
91
+ func appIdentityName(name string) string {
92
+ s := strings.TrimSuffix(name, ".exe")
93
+ s = strings.TrimSuffix(s, ".app")
94
+ return s
95
+ }
96
+
97
+ // appIdentity 返回运行时应用标识(当前进程 exe 文件名去扩展名)。
98
+ func appIdentity() (string, error) {
99
+ exe, err := os.Executable()
100
+ if err != nil {
101
+ return "", err
102
+ }
103
+ return appIdentityName(filepath.Base(exe)), nil
104
+ }
105
+
106
+ // deriveSecurityKey 派生 high 模式加密密钥(AES-256-CTR + HMAC-SHA256 共用同一 32B 密钥)。
107
+ // salt 引入应用标识,使不同应用密钥不同;PBKDF2 迭代增加暴力破解成本。
108
+ func deriveSecurityKey(appName string) []byte {
109
+ salt := []byte(securityDeriveSalt + ":" + appIdentityName(appName))
110
+ return pbkdf2HMACSHA256([]byte(securityMasterKey), salt, securityPbkdf2Iter, securityKeyLen)
111
+ }
112
+
113
+ // decryptAppBin 解密 resources/app.bin,返回载荷。
114
+ // 先恒定时间校验 HMAC 认证标签(Encrypt-then-MAC),再 AES-256-CTR 解密。
115
+ // 解密失败(exe 被重命名 / 资源被篡改 / 密钥不匹配)时返回错误。
116
+ func decryptAppBin(appName string, data []byte) (*securePayload, error) {
117
+ if len(data) < len(securityMagic)+securityIVLen+securityTagLen {
118
+ return nil, errors.New("app.bin too short")
119
+ }
120
+ if string(data[:len(securityMagic)]) != securityMagic {
121
+ return nil, errors.New("app.bin bad magic")
122
+ }
123
+ off := len(securityMagic)
124
+ iv := data[off : off+securityIVLen]
125
+ off += securityIVLen
126
+ tag := data[off : off+securityTagLen]
127
+ off += securityTagLen
128
+ ct := data[off:]
129
+
130
+ key := deriveSecurityKey(appName)
131
+
132
+ // 1) 认证:HMAC-SHA256(ct) 前 16 字节,与容器头 tag 恒定时间比对。
133
+ mac := hmac.New(sha256.New, key)
134
+ mac.Write(ct)
135
+ if !hmac.Equal(tag, mac.Sum(nil)[:securityTagLen]) {
136
+ return nil, errors.New("app.bin 解密失败(exe 被重命名或资源被篡改?):认证失败")
137
+ }
138
+
139
+ // 2) 解密:AES-256-CTR。
140
+ block, err := aes.NewCipher(key)
141
+ if err != nil {
142
+ return nil, err
143
+ }
144
+ stream := cipher.NewCTR(block, iv)
145
+ plain := make([]byte, len(ct))
146
+ stream.XORKeyStream(plain, ct)
147
+
148
+ var p securePayload
149
+ if err := json.Unmarshal(plain, &p); err != nil {
150
+ return nil, fmt.Errorf("app.bin 载荷无效:%w", err)
151
+ }
152
+ return &p, nil
153
+ }
154
+
155
+ // verifyIntegrity 校验 resources/.integrity 清单(存在时)。
156
+ // 比对 app.bin 与 backend/** 各文件 HMAC-SHA256,防整体替换/篡改。
157
+ // 无 .integrity 文件(CLI 旧版产物)时跳过,保持向后兼容。
158
+ func verifyIntegrity(dir, appName string, appBin []byte) error {
159
+ raw, err := os.ReadFile(filepath.Join(dir, ".integrity"))
160
+ if err != nil {
161
+ if errors.Is(err, os.ErrNotExist) {
162
+ return nil
163
+ }
164
+ return err
165
+ }
166
+ var f integrityFile
167
+ if err := json.Unmarshal(raw, &f); err != nil {
168
+ return fmt.Errorf(".integrity 无效:%w", err)
169
+ }
170
+ key := deriveSecurityKey(appName)
171
+ if f.AppBin != "" {
172
+ mac := hmac.New(sha256.New, key)
173
+ mac.Write(appBin)
174
+ want, err := hex.DecodeString(f.AppBin)
175
+ if err != nil {
176
+ return fmt.Errorf(".integrity appBin 校验值非法:%w", err)
177
+ }
178
+ if !hmac.Equal(want, mac.Sum(nil)) {
179
+ return errors.New(".integrity 校验失败:resources/app.bin 被篡改")
180
+ }
181
+ }
182
+ for rel, wantHex := range f.Backend {
183
+ b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
184
+ if err != nil {
185
+ return fmt.Errorf(".integrity 校验失败:backend 文件缺失 %s:%w", rel, err)
186
+ }
187
+ mac := hmac.New(sha256.New, key)
188
+ mac.Write(b)
189
+ want, err := hex.DecodeString(wantHex)
190
+ if err != nil {
191
+ return fmt.Errorf(".integrity backend[%s] 校验值非法:%w", rel, err)
192
+ }
193
+ if !hmac.Equal(want, mac.Sum(nil)) {
194
+ return fmt.Errorf(".integrity 校验失败:backend 文件 %s 被篡改", rel)
195
+ }
196
+ }
197
+ return nil
198
+ }
199
+
200
+ // loadSecureResources 尝试加载 high 模式加密资源。
201
+ // 返回 (载荷, 是否命中, 错误):resources/app.bin 不存在时命中=false(非 high 产物),
202
+ // 命中但解密/校验失败时返回错误(拒绝静默回退明文,避免降级攻击)。
203
+ func loadSecureResources() (*securePayload, bool, error) {
204
+ dir, err := resourcesDir()
205
+ if err != nil {
206
+ return nil, false, err
207
+ }
208
+ data, err := os.ReadFile(filepath.Join(dir, "app.bin"))
209
+ if err != nil {
210
+ if errors.Is(err, os.ErrNotExist) {
211
+ return nil, false, nil
212
+ }
213
+ return nil, false, err
214
+ }
215
+ name, err := appIdentity()
216
+ if err != nil {
217
+ return nil, false, err
218
+ }
219
+ if err := verifyIntegrity(dir, name, data); err != nil {
220
+ return nil, false, err
221
+ }
222
+ p, err := decryptAppBin(name, data)
223
+ if err != nil {
224
+ return nil, false, err
225
+ }
226
+ return p, true, nil
227
+ }
@@ -0,0 +1,153 @@
1
+ package freedom
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/aes"
6
+ "crypto/cipher"
7
+ "crypto/hmac"
8
+ "crypto/sha256"
9
+ "encoding/hex"
10
+ "os"
11
+ "path/filepath"
12
+ "testing"
13
+ )
14
+
15
+ // 跨语言一致性:deriveSecurityKey 的期望值由 Node crypto.pbkdf2Sync 生成
16
+ // (见 lib/security.js 测试 / 开发时用 node -e 复算)。两侧必须一致,
17
+ // 否则 CLI high 模式 build 的加密资源壳无法解密。
18
+ var deriveExpect = map[string]string{
19
+ "demo": "87186aa64360bdecb9d87f3bc78c20afabc04ff99abbf04b202d5ead7aea9484",
20
+ "myapp.exe": "853b7b028120502eaefae453e05f36df7e4d2948e3d6766272a6965e0401675d",
21
+ "HelloApp": "30255eaff0c089efdcb22736ae255a98f5a2bb8e3c00741440102811e1345f9a",
22
+ "HelloApp.app": "30255eaff0c089efdcb22736ae255a98f5a2bb8e3c00741440102811e1345f9a",
23
+ }
24
+
25
+ func TestDeriveKeyMatchesNode(t *testing.T) {
26
+ for name, wantHex := range deriveExpect {
27
+ got := hex.EncodeToString(deriveSecurityKey(name))
28
+ if got != wantHex {
29
+ t.Errorf("deriveSecurityKey(%q) = %s, want %s", name, got, wantHex)
30
+ }
31
+ }
32
+ }
33
+
34
+ func TestAppIdentityName(t *testing.T) {
35
+ cases := map[string]string{
36
+ "demo": "demo",
37
+ "myapp.exe": "myapp",
38
+ "HelloApp.app": "HelloApp",
39
+ // 与 lib/security.js appIdentityFor 正则语义一致:仅去除最后一个 .exe/.app 后缀
40
+ "app.exe.app": "app.exe",
41
+ }
42
+ for in, want := range cases {
43
+ if got := appIdentityName(in); got != want {
44
+ t.Errorf("appIdentityName(%q) = %q, want %q", in, got, want)
45
+ }
46
+ }
47
+ }
48
+
49
+ func TestPBKDF2RFCVector(t *testing.T) {
50
+ // RFC 6070 向量:PBKDF2-HMAC-SHA1("password", "salt", 1, 20)
51
+ // 我们实现的 PRF 是 SHA256,此处验证实现不 panic 且长度正确 + 与 SHA256 自我一致。
52
+ dk := pbkdf2HMACSHA256([]byte("password"), []byte("salt"), 4096, 32)
53
+ if len(dk) != 32 {
54
+ t.Fatalf("pbkdf2 len = %d, want 32", len(dk))
55
+ }
56
+ // 确定性:同参重复调用结果一致
57
+ dk2 := pbkdf2HMACSHA256([]byte("password"), []byte("salt"), 4096, 32)
58
+ if !bytes.Equal(dk, dk2) {
59
+ t.Fatal("pbkdf2 not deterministic")
60
+ }
61
+ }
62
+
63
+ func TestDecryptAppBinRoundtrip(t *testing.T) {
64
+ payload := []byte(`{"html":"<html>hello</html>","config":"{\"title\":\"demo\"}"}`)
65
+ name := "demo"
66
+ key := deriveSecurityKey(name)
67
+
68
+ // 用标准 AES-GCM 加密构造 app.bin(模拟 CLI build 产物)
69
+ data, err := encryptForTest(name, payload)
70
+ if err != nil {
71
+ t.Fatalf("encryptForTest: %v", err)
72
+ }
73
+ p, err := decryptAppBin(name, data)
74
+ if err != nil {
75
+ t.Fatalf("decryptAppBin: %v", err)
76
+ }
77
+ if p.HTML != "<html>hello</html>" || p.Config != "{\"title\":\"demo\"}" {
78
+ t.Fatalf("roundtrip mismatch: %+v", p)
79
+ }
80
+ _ = key
81
+
82
+ // 篡改测试:改动密文任一位 → 解密失败(HMAC 认证标签校验)
83
+ bad := append([]byte(nil), data...)
84
+ bad[len(bad)-1] ^= 0x01
85
+ if _, err := decryptAppBin(name, bad); err == nil {
86
+ t.Fatal("tampered ciphertext should fail")
87
+ }
88
+ }
89
+
90
+ func TestDecryptAppBinWrongName(t *testing.T) {
91
+ payload := []byte(`{"html":"x","config":"{}"}`)
92
+ data, err := encryptForTest("demo", payload)
93
+ if err != nil {
94
+ t.Fatalf("encryptForTest: %v", err)
95
+ }
96
+ // 用错误应用名(相当于 exe 被重命名)解密 → 必须失败
97
+ if _, err := decryptAppBin("other", data); err == nil {
98
+ t.Fatal("decrypt with wrong app name should fail")
99
+ }
100
+ }
101
+
102
+ func TestVerifyIntegrity(t *testing.T) {
103
+ dir := t.TempDir()
104
+ name := "demo"
105
+ appBin := []byte("FRDM1-fake-bin-content")
106
+ key := deriveSecurityKey(name)
107
+ mac := hmac.New(sha256.New, key)
108
+ mac.Write(appBin)
109
+ want := hex.EncodeToString(mac.Sum(nil))
110
+
111
+ // 正常:匹配
112
+ good := `{"v":1,"appBin":"` + want + `","backend":{}}`
113
+ if err := os.WriteFile(filepath.Join(dir, ".integrity"), []byte(good), 0o644); err != nil {
114
+ t.Fatal(err)
115
+ }
116
+ if err := verifyIntegrity(dir, name, appBin); err != nil {
117
+ t.Fatalf("verifyIntegrity should pass: %v", err)
118
+ }
119
+
120
+ // 篡改:app.bin 内容变化 → 校验失败
121
+ if err := verifyIntegrity(dir, name, append(appBin, 0x00)); err == nil {
122
+ t.Fatal("tampered appBin should fail integrity")
123
+ }
124
+
125
+ // 无 .integrity → 跳过(向后兼容)
126
+ dir2 := t.TempDir()
127
+ if err := verifyIntegrity(dir2, name, appBin); err != nil {
128
+ t.Fatalf("missing .integrity should pass: %v", err)
129
+ }
130
+ }
131
+
132
+ // ---- 测试辅助 ----
133
+
134
+ // encryptForTest 用与 CLI lib/security.js 相同的容器格式加密载荷(供壳单测使用)。
135
+ // 算法与正式流程一致:AES-256-CTR 加密 → HMAC-SHA256(密文) 截 16B 认证标签。
136
+ func encryptForTest(name string, plain []byte) ([]byte, error) {
137
+ key := deriveSecurityKey(name)
138
+ block, err := aes.NewCipher(key)
139
+ if err != nil {
140
+ return nil, err
141
+ }
142
+ iv := make([]byte, securityIVLen) // 固定 iv 便于确定性(仅测试)
143
+ stream := cipher.NewCTR(block, iv)
144
+ ct := make([]byte, len(plain))
145
+ stream.XORKeyStream(ct, plain)
146
+ mac := hmac.New(sha256.New, key)
147
+ mac.Write(ct)
148
+ tag := mac.Sum(nil)[:securityTagLen]
149
+ out := append([]byte(securityMagic), iv...)
150
+ out = append(out, tag...)
151
+ out = append(out, ct...)
152
+ return out, nil
153
+ }
@@ -18,6 +18,14 @@ export default {
18
18
  // 是否开启 WebView 开发者工具。
19
19
  debug: false,
20
20
 
21
+ // 安全模式,在终端随时可改,三选一:
22
+ // 'none' - 明文资源(默认,兼容历史产物)
23
+ // 'basic' - 明文资源 + 构建期加固提示
24
+ // 'high' - resources 整体加密为 app.bin(磁盘无明文 HTML/配置),
25
+ // 壳内存解密 + 完整性校验,另含 anti-debug / 进程隐藏
26
+ // 终端命令:freedom security <none|basic|high>,或在 freedom build 时传 --security <mode>
27
+ security: 'none',
28
+
21
29
  // 标题栏策略,在终端随时可改,二选一:
22
30
  // 'native' - 保留系统原生标题栏,标题栏图标与 exe 图标一致
23
31
  // 'frameless' - 完全无边框,标题栏与 Windows 原生最小化 / 最大化 / 关闭按钮均不存在,