@yufengtadian/freedom-cli 1.12.16 → 1.12.18
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 +38 -3
- package/lib/build.js +132 -12
- package/lib/cli.js +94 -4
- package/lib/config.js +12 -1
- package/lib/init.js +9 -1
- package/lib/security.js +150 -0
- package/lib/utils.js +5 -2
- package/lib/verify.js +236 -0
- package/package.json +39 -39
- package/shell/win-x64/freedom-shell.exe +0 -0
- package/templates/go/build_all.txt +0 -0
- package/templates/go/build_err.txt +0 -0
- package/templates/go/pkg/freedom/anti_debug_other.go +8 -0
- package/templates/go/pkg/freedom/anti_debug_windows.go +39 -0
- package/templates/go/pkg/freedom/assets/freedom.js +19 -16
- package/templates/go/pkg/freedom/backend_proc.go +7 -1
- package/templates/go/pkg/freedom/center_windows.go +36 -18
- package/templates/go/pkg/freedom/center_windows_test.go +32 -0
- package/templates/go/pkg/freedom/configfile.go +56 -7
- package/templates/go/pkg/freedom/freedom.go +70 -8
- package/templates/go/pkg/freedom/security.go +227 -0
- package/templates/go/pkg/freedom/security_test.go +153 -0
- package/templates/go/pkg/freedom/syscap_other.go +10 -0
- package/templates/go/pkg/freedom/syscap_windows.go +653 -0
- package/templates/go/pkg/freedom/tray_other.go +10 -0
- package/templates/go/pkg/freedom/tray_windows.go +506 -0
- package/templates/go/pkg/freedom/window_windows.go +28 -16
- package/templates/go/temp_vet_test/main.go +40 -0
- package/templates/go/test_err.txt +0 -0
- package/templates/go/vet_err.txt +1 -0
- package/templates/go/vet_err2.txt +2 -0
- package/templates/project/freedom.config.js +8 -0
- package/templates/project-minimal/freedom.config.js +58 -0
- package/templates/project-minimal/index.html +41 -0
- package/templates/project-minimal/package.json +14 -0
- package/templates/project-minimal/src/main.js +19 -0
- package/templates/project-minimal/vite.config.js +18 -0
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# freedom-cli
|
|
2
2
|
|
|
3
|
-
Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.12.
|
|
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 执行
|
|
@@ -105,9 +112,13 @@ async function build(projectDir, opts = {}) {
|
|
|
105
112
|
if (useCache && !viteNeedsRebuild(dir)) {
|
|
106
113
|
process.stdout.write('[freedom] 前端源码无改动,复用构建缓存...\n');
|
|
107
114
|
} else {
|
|
108
|
-
|
|
115
|
+
process.stdout.write('[freedom] 前端打包中(npm run build)...\n');
|
|
116
|
+
// 全量构建时 npm/vite 输出必须实时透传(stdio: inherit),
|
|
117
|
+
// 此前 pipe 缓冲会吞掉构建全程输出,Node 主线程被 spawnSync 同步阻塞,
|
|
118
|
+
// 大前端全量构建时终端长时间零输出,表现如"未响应/卡死"。
|
|
119
|
+
const vite = run('npm', ['run', 'build'], { cwd: dir, stdio: 'inherit' });
|
|
109
120
|
if (vite.status !== 0) {
|
|
110
|
-
throw new Error(
|
|
121
|
+
throw new Error(`前端打包失败(退出码 ${vite.status}),详见上方构建输出。`);
|
|
111
122
|
}
|
|
112
123
|
writeViteCacheMarker(dir);
|
|
113
124
|
}
|
|
@@ -118,6 +129,11 @@ async function build(projectDir, opts = {}) {
|
|
|
118
129
|
const html = fs.readFileSync(distHtml, 'utf8');
|
|
119
130
|
warnIfNotSingleFile(html, distHtml);
|
|
120
131
|
const configJSON = renderConfigJSON(cfg, name);
|
|
132
|
+
// 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
|
|
133
|
+
// none = 明文资源(默认,兼容历史产物)
|
|
134
|
+
// basic = 明文资源 + 构建期安全提示(剥符号 / 混淆,后续可用高模式加密)
|
|
135
|
+
// high = resources 整体加密为 app.bin(AES-256-CTR+HMAC)+ .integrity 完整性清单,磁盘无明文
|
|
136
|
+
const security = resolveSecurity(opts.security, cfg.security);
|
|
121
137
|
|
|
122
138
|
// 2) 后端目录(若配置了 backend 进程)
|
|
123
139
|
const backendDir = path.join(dir, cfg.backendDir || 'backend');
|
|
@@ -133,15 +149,42 @@ async function build(projectDir, opts = {}) {
|
|
|
133
149
|
const results = await Promise.all(
|
|
134
150
|
platforms.map(async (plat) => {
|
|
135
151
|
const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
|
|
136
|
-
const outFile = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon });
|
|
152
|
+
const outFile = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security });
|
|
137
153
|
return { plat, outFile };
|
|
138
154
|
})
|
|
139
155
|
);
|
|
140
156
|
|
|
157
|
+
// 5) 产物自检 + 形态树(消除"build 后形态未知 / 需实测"):
|
|
158
|
+
// build 完成后自动校验产物完整性,并打印产物结构;自检发现问题仅告警不阻断,
|
|
159
|
+
// 完整失败信息可用 freedom verify 复核(返回非零退出码,供 CI 使用)。
|
|
160
|
+
const { verifyProduct, renderTree, formatChecks } = require('./verify');
|
|
161
|
+
const v = await verifyProduct(dir);
|
|
162
|
+
if (v.error) {
|
|
163
|
+
process.stdout.write(`[freedom] 产物自检跳过:${v.error}\n`);
|
|
164
|
+
} else if (v.targets.length === 0) {
|
|
165
|
+
process.stdout.write('[freedom] 产物自检跳过:未在产物目录发现可执行文件。\n');
|
|
166
|
+
} else {
|
|
167
|
+
process.stdout.write('\n[freedom] 产物自检:\n');
|
|
168
|
+
for (const t of v.targets) {
|
|
169
|
+
process.stdout.write(` [${t.plat}] ${t.dir}\n`);
|
|
170
|
+
for (const line of formatChecks(t.checks)) {
|
|
171
|
+
process.stdout.write(` ${line}\n`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write('\n[freedom] 产物结构:\n');
|
|
175
|
+
for (const line of renderTree(v.targets).split('\n')) {
|
|
176
|
+
process.stdout.write(` ${line}\n`);
|
|
177
|
+
}
|
|
178
|
+
const failed = v.targets.some((t) => t.checks.some((c) => !c.pass));
|
|
179
|
+
if (failed) {
|
|
180
|
+
process.stdout.write(`\n[freedom] 警告:产物自检存在失败项,请运行 ${'freedom verify'} 复核。\n`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
141
184
|
return { results };
|
|
142
185
|
}
|
|
143
186
|
|
|
144
|
-
async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon }) {
|
|
187
|
+
async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security }) {
|
|
145
188
|
// 取预编译壳二进制
|
|
146
189
|
const shell = localShellPath(plat);
|
|
147
190
|
if (!fs.existsSync(shell)) {
|
|
@@ -172,15 +215,47 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
|
|
|
172
215
|
}
|
|
173
216
|
|
|
174
217
|
// 自定义 exe 图标(仅 Windows PE 支持嵌入 .ico 资源;mac 用 .icns 走 .app 分支)
|
|
218
|
+
// 修复:此前引用未传入 emitPlatform 作用域的 cfg 导致 ReferenceError(cfg is not defined),
|
|
219
|
+
// 配置 icon 后 Windows 构建必然失败;改用 emitPlatform 已解构的 name/version。
|
|
175
220
|
if (isWinPlat(plat) && icon) {
|
|
176
|
-
await applyWindowsIcon(outFile, icon);
|
|
221
|
+
await applyWindowsIcon(outFile, icon, name, version);
|
|
177
222
|
}
|
|
178
223
|
|
|
179
224
|
// 写 resources:页面 + 配置 + 后端
|
|
225
|
+
// security=high 时整体加密为 app.bin(磁盘无明文 HTML/config),并生成 .integrity 完整性清单;
|
|
226
|
+
// none / basic 保持明文资源(兼容历史产物),basic 额外输出安全加固提示。
|
|
180
227
|
const resDir = path.join(targetDir, 'resources');
|
|
181
228
|
await fsp.mkdir(resDir, { recursive: true });
|
|
182
|
-
|
|
183
|
-
|
|
229
|
+
// 互斥清理:切换安全模式时删除另一模式遗留产物,防止壳误加载旧资源
|
|
230
|
+
// (high 产物 app.bin/.integrity 与明文 index.html/config.json 只能存其一)。
|
|
231
|
+
if (security === 'high') {
|
|
232
|
+
for (const legacy of ['index.html', 'config.json']) {
|
|
233
|
+
const p = path.join(resDir, legacy);
|
|
234
|
+
if (fs.existsSync(p)) await fsp.rm(p, { force: true });
|
|
235
|
+
}
|
|
236
|
+
const appBin = encryptApp(name, html, configJSON);
|
|
237
|
+
await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
|
|
238
|
+
const backendRelMap = {};
|
|
239
|
+
if (hasBackend && fs.existsSync(backendDir)) {
|
|
240
|
+
collectDirFiles(backendDir, '', backendRelMap);
|
|
241
|
+
}
|
|
242
|
+
const integrityText = renderIntegrity(buildIntegrity(name, appBin, backendRelMap));
|
|
243
|
+
await fsp.writeFile(path.join(resDir, '.integrity'), integrityText, 'utf8');
|
|
244
|
+
} else {
|
|
245
|
+
for (const legacy of ['app.bin', '.integrity']) {
|
|
246
|
+
const p = path.join(resDir, legacy);
|
|
247
|
+
if (fs.existsSync(p)) await fsp.rm(p, { force: true });
|
|
248
|
+
}
|
|
249
|
+
await fsp.writeFile(path.join(resDir, 'index.html'), html, 'utf8');
|
|
250
|
+
await fsp.writeFile(path.join(resDir, 'config.json'), configJSON, 'utf8');
|
|
251
|
+
if (security === 'basic') {
|
|
252
|
+
process.stdout.write(
|
|
253
|
+
`[freedom] 安全模式 basic:资源仍为明文。加固建议:\n` +
|
|
254
|
+
` 1. 运行 ${'freedom security high'} 切换到高模式(resources 加密为 app.bin,磁盘无明文);\n` +
|
|
255
|
+
` 2. 壳侧已内置 anti-debug / 进程隐藏,符号剥离需在 CI 编译壳时设置 ldflags -s -w(见 README 安全章节)。\n`
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
184
259
|
if (hasBackend) {
|
|
185
260
|
copyDir(backendDir, path.join(resDir, 'backend'));
|
|
186
261
|
}
|
|
@@ -197,9 +272,11 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
|
|
|
197
272
|
return outFile;
|
|
198
273
|
}
|
|
199
274
|
|
|
200
|
-
// 把 .ico 图标注入到 Windows exe 的 PE 资源(RT_ICON + RT_GROUP_ICON
|
|
275
|
+
// 把 .ico 图标注入到 Windows exe 的 PE 资源(RT_ICON + RT_GROUP_ICON),
|
|
276
|
+
// 并设置版本信息(产品名 / 说明 / 版本),使资源管理器详情可读。
|
|
201
277
|
// 依赖 rcedit(随 npm 包分发的预编译 rcedit-x64.exe,无需 Go/资源编译器)。
|
|
202
|
-
|
|
278
|
+
// 产品名取自 cfg.name,版本取自 cfg.version —— 修复"产物无产品名 / 版本无法配置"。
|
|
279
|
+
async function applyWindowsIcon(exePath, iconPath, name, version) {
|
|
203
280
|
const ext = path.extname(iconPath).toLowerCase();
|
|
204
281
|
if (ext !== '.ico') {
|
|
205
282
|
process.stdout.write(
|
|
@@ -214,8 +291,24 @@ async function applyWindowsIcon(exePath, iconPath) {
|
|
|
214
291
|
return;
|
|
215
292
|
}
|
|
216
293
|
const rcedit = require('rcedit'); // 惰性加载,避免未配置 icon 时增加启动开销
|
|
217
|
-
|
|
218
|
-
|
|
294
|
+
const info = { icon: iconPath };
|
|
295
|
+
// rcedit 4.x 参数结构:FileDescription/ProductName 等字符串必须走
|
|
296
|
+
// version-string(--set-version-string 键值对),file-version/product-version
|
|
297
|
+
// 才是独立的 --set-* 单值参数。旧版平铺的 file-description/product-name 会被静默忽略。
|
|
298
|
+
const versionString = {};
|
|
299
|
+
if (name) {
|
|
300
|
+
versionString['FileDescription'] = name;
|
|
301
|
+
versionString['ProductName'] = name;
|
|
302
|
+
}
|
|
303
|
+
if (Object.keys(versionString).length > 0) {
|
|
304
|
+
info['version-string'] = versionString;
|
|
305
|
+
}
|
|
306
|
+
if (version) {
|
|
307
|
+
info['file-version'] = version;
|
|
308
|
+
info['product-version'] = version;
|
|
309
|
+
}
|
|
310
|
+
await rcedit(exePath, info);
|
|
311
|
+
process.stdout.write(`[freedom] 已注入 exe 图标与版本信息:${iconPath} (${name || '?'} ${version || '?'})\n`);
|
|
219
312
|
}
|
|
220
313
|
|
|
221
314
|
// 解析 cfg.icon:相对项目根或绝对路径 -> 绝对路径;未配置返回 null。
|
|
@@ -455,7 +548,11 @@ function intVal(v, dft) {
|
|
|
455
548
|
// 导致页面 JS/CSS 丢失静默空白。检测到此类引用时明确告警,避免用户无感知翻车。
|
|
456
549
|
function warnIfNotSingleFile(html, distHtml) {
|
|
457
550
|
const refs = [];
|
|
458
|
-
|
|
551
|
+
// M4:此前只检查 script/link,漏掉了 img/iframe/video/audio/source/object/embed/track 等
|
|
552
|
+
// 一切带 URL 引用的标签。壳只 SetHtml 单页内存加载,任何相对路径 / 根路径 / 协议相对的
|
|
553
|
+
// 外部引用(JS、CSS、图片、子页面、音视频、内嵌对象)在无服务器环境下都会失效,
|
|
554
|
+
// 需一并告警,避免"脚本内联了但图片仍空白"这类半翻车。
|
|
555
|
+
const re = /<(?:script|link|img|iframe|video|audio|source|object|embed|track)\b[^>]*(?:src|href|data)\s*=\s*["']([^"']+)["']/gi;
|
|
459
556
|
let m;
|
|
460
557
|
while ((m = re.exec(html)) !== null) {
|
|
461
558
|
const url = m[1];
|
|
@@ -463,6 +560,16 @@ function warnIfNotSingleFile(html, distHtml) {
|
|
|
463
560
|
if (/^(?:data:|blob:|https?:|file:)/i.test(url)) continue;
|
|
464
561
|
refs.push(url);
|
|
465
562
|
}
|
|
563
|
+
// img 的 srcset(逗号分隔多候选 URL)单独扫描
|
|
564
|
+
const srcsetRe = /<img\b[^>]*\bsrcset\s*=\s*["']([^"']+)["']/gi;
|
|
565
|
+
while ((m = srcsetRe.exec(html)) !== null) {
|
|
566
|
+
for (const part of m[1].split(',')) {
|
|
567
|
+
const url = part.trim().split(/\s+/)[0];
|
|
568
|
+
if (!url) continue;
|
|
569
|
+
if (/^(?:data:|blob:|https?:|file:)/i.test(url)) continue;
|
|
570
|
+
refs.push(url);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
466
573
|
if (refs.length > 0) {
|
|
467
574
|
console.warn(
|
|
468
575
|
`[freedom] 警告:${path.basename(distHtml)} 引用了外部资源(${refs.join(', ')})。` +
|
|
@@ -472,4 +579,17 @@ function warnIfNotSingleFile(html, distHtml) {
|
|
|
472
579
|
}
|
|
473
580
|
}
|
|
474
581
|
|
|
582
|
+
// 递归收集目录内所有文件:relPath(POSIX 相对路径)-> Buffer 内容,供 .integrity 完整性清单使用。
|
|
583
|
+
function collectDirFiles(dir, prefix, out) {
|
|
584
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
585
|
+
const abs = path.join(dir, entry.name);
|
|
586
|
+
if (entry.isDirectory()) {
|
|
587
|
+
collectDirFiles(abs, prefix + entry.name + '/', out);
|
|
588
|
+
} else {
|
|
589
|
+
out[(prefix + entry.name).replace(/\\/g, '/')] = fs.readFileSync(abs);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
|
|
475
595
|
module.exports = { build, parsePlatforms };
|
package/lib/cli.js
CHANGED
|
@@ -22,12 +22,13 @@ function help() {
|
|
|
22
22
|
L.push(` ${paint('freedom', C.fg.cyan, C.bold)} ${dim('进入完整 CLI 模式(交互式菜单,新建 / 打包 / 配置 / 壳管理)')}`);
|
|
23
23
|
L.push(` ${paint('freedom tui', C.fg.cyan, C.bold)} ${dim('同上(兼容写法,显式进入交互式模式)')}`);
|
|
24
24
|
L.push(section('项目'));
|
|
25
|
-
L.push(` ${paint('freedom init [目录] [--force]', C.fg.cyan)}
|
|
25
|
+
L.push(` ${paint('freedom init [目录] [--force] [--template full|minimal]', C.fg.cyan)} ${dim('新建项目模板(默认 full:含自绘标题栏示例;minimal:极简无自绘标题栏)')}`);
|
|
26
26
|
L.push(` ${paint('freedom tutorial', C.fg.cyan)} ${dim('再次打开安装教程')}`);
|
|
27
27
|
L.push(section('打包'));
|
|
28
28
|
L.push(` ${paint('freedom build [--platform <p>]', C.fg.cyan)} ${dim('前端打包并分发桌面应用(默认当前平台)')}`);
|
|
29
29
|
L.push(` ${dim('--platform win|mac|linux|all')} ${dim('指定目标平台(all = 三平台全量)')}`);
|
|
30
30
|
L.push(` ${dim('--no-cache')} ${dim('忽略前端构建缓存,强制重新打包')}`);
|
|
31
|
+
L.push(` ${paint('freedom verify [--platform <p>]', C.fg.cyan)} ${dim('校验已构建产物完整性并打印产物结构(CI 可依赖退出码)')}`);
|
|
31
32
|
L.push(` ${paint('freedom dmg [--platform <plat>]', C.fg.cyan)} ${dim('将已构建的 .app 打包为 .dmg(需 macOS)')}`);
|
|
32
33
|
L.push(section('外观'));
|
|
33
34
|
L.push(` ${paint('freedom titlebar <native|frameless>', C.fg.cyan)} ${dim('一键切换标题栏策略')}`);
|
|
@@ -113,9 +114,25 @@ async function run(argv) {
|
|
|
113
114
|
|
|
114
115
|
case 'init': {
|
|
115
116
|
const force = rest.includes('--force');
|
|
116
|
-
const
|
|
117
|
-
|
|
117
|
+
const tmplArg = rest.find((a) => a.startsWith('--template'));
|
|
118
|
+
let template;
|
|
119
|
+
if (tmplArg) {
|
|
120
|
+
template = tmplArg.includes('=') ? tmplArg.split('=')[1] : rest[rest.indexOf(tmplArg) + 1];
|
|
121
|
+
if (!template || template.startsWith('--')) {
|
|
122
|
+
console.error(`${err('--template 缺少取值。')} ${dim('用法:')}${paint('freedom init <目录> --template <full|minimal>', C.fg.cyan)}`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const dirArg = rest.filter((a) => a !== '--force' && !a.startsWith('--template'))[0];
|
|
127
|
+
let dir;
|
|
128
|
+
try {
|
|
129
|
+
dir = init(dirArg || '.', { force, template });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(`${err(e.message)}`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
118
134
|
console.log(`${ok('项目已创建:')}${paint(dir, C.fg.cyan, C.bold)}`);
|
|
135
|
+
console.log(` ${dim(`模板:${template === 'minimal' ? 'minimal(极简,无自绘标题栏)' : 'full(完整,含自绘标题栏示例)'}`)}`);
|
|
119
136
|
console.log(` ${dim('下一步:')}`);
|
|
120
137
|
console.log(` ${paint(`cd ${dir}`, C.fg.white)}`);
|
|
121
138
|
console.log(` ${paint('npm install', C.fg.white)}`);
|
|
@@ -139,13 +156,69 @@ async function run(argv) {
|
|
|
139
156
|
return 1;
|
|
140
157
|
}
|
|
141
158
|
}
|
|
142
|
-
|
|
159
|
+
// 安全模式:--security <none|basic|high>(可选,默认取 freedom.config.js 的 security 字段)
|
|
160
|
+
const secArg = rest.find((a) => a.startsWith('--security'));
|
|
161
|
+
let security;
|
|
162
|
+
if (secArg) {
|
|
163
|
+
const inline = secArg.includes('=') ? secArg.split('=')[1] : undefined;
|
|
164
|
+
security = inline || rest[rest.indexOf(secArg) + 1];
|
|
165
|
+
if (!security || security.startsWith('--')) {
|
|
166
|
+
console.error(`${err('--security 缺少取值。')} ${dim('用法:')}${paint('freedom build --security <none|basic|high>', C.fg.cyan)}`);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const { results } = await build(process.cwd(), {
|
|
171
|
+
platform,
|
|
172
|
+
noCache: rest.includes('--no-cache'),
|
|
173
|
+
security,
|
|
174
|
+
});
|
|
143
175
|
for (const r of results) {
|
|
144
176
|
console.log(`${ok('构建完成')} ${paint(`[${r.plat}]`, C.fg.magenta, C.bold)} ${paint(r.outFile, C.fg.white)}`);
|
|
145
177
|
}
|
|
146
178
|
return 0;
|
|
147
179
|
}
|
|
148
180
|
|
|
181
|
+
case 'verify': {
|
|
182
|
+
const platArg = rest.find((a) => a.startsWith('--platform') || a.startsWith('-p'));
|
|
183
|
+
let platform;
|
|
184
|
+
if (platArg) {
|
|
185
|
+
const inline = platArg.includes('=') ? platArg.split('=')[1] : undefined;
|
|
186
|
+
platform = inline || rest[rest.indexOf(platArg) + 1];
|
|
187
|
+
if (!platform || platform.startsWith('--')) {
|
|
188
|
+
console.error(`${err('--platform 缺少取值。')} ${dim('用法:')}${paint('freedom verify [--platform <p>]', C.fg.cyan)}`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const { verifyProduct, renderTree, formatChecks } = require('./verify');
|
|
193
|
+
const v = await verifyProduct(process.cwd(), { platform });
|
|
194
|
+
if (v.error) {
|
|
195
|
+
console.error(`${err('校验失败:')}${v.error}`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
if (v.targets.length === 0) {
|
|
199
|
+
console.error(`${err('未在产物目录发现可执行文件。')} ${dim('请先运行')} ${paint('freedom build', C.fg.cyan)} ${dim('生成产物。')}`);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
let allPass = true;
|
|
203
|
+
for (const t of v.targets) {
|
|
204
|
+
console.log(`\n${section(`[${t.plat}] ${t.dir}`)}`);
|
|
205
|
+
for (const c of t.checks) {
|
|
206
|
+
console.log(` ${c.pass ? ok('通过') : err('失败')} ${c.name}:${c.detail}`);
|
|
207
|
+
}
|
|
208
|
+
allPass = allPass && t.checks.every((c) => c.pass);
|
|
209
|
+
}
|
|
210
|
+
console.log(`\n${dim('产物结构:')}`);
|
|
211
|
+
for (const line of renderTree(v.targets).split('\n')) {
|
|
212
|
+
console.log(` ${line}`);
|
|
213
|
+
}
|
|
214
|
+
if (allPass) {
|
|
215
|
+
console.log(`\n${ok('产物校验全部通过。')}`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
console.log(`\n${err('产物校验存在失败项,请检查上方输出。')}`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
149
222
|
case 'shell':
|
|
150
223
|
return await runShell(rest);
|
|
151
224
|
|
|
@@ -178,6 +251,23 @@ async function run(argv) {
|
|
|
178
251
|
return 0;
|
|
179
252
|
}
|
|
180
253
|
|
|
254
|
+
case 'security': {
|
|
255
|
+
const mode = rest[0];
|
|
256
|
+
if (!['none', 'basic', 'high'].includes(mode)) {
|
|
257
|
+
console.error(`${err('用法:')}${paint('freedom security <none|basic|high>', C.fg.cyan)}`);
|
|
258
|
+
console.error(` ${dim('none = 明文资源(默认,兼容历史产物)')}`);
|
|
259
|
+
console.error(` ${dim('basic = 明文资源 + 构建期加固提示')}`);
|
|
260
|
+
console.error(` ${dim('high = resources 加密为 app.bin + .integrity 完整性清单,磁盘无明文,壳内存解密')}`);
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
setConfig(process.cwd(), 'security', mode);
|
|
264
|
+
console.log(`${ok('安全模式已切换为:')}${paint(mode, C.fg.cyan, C.bold)}`);
|
|
265
|
+
if (mode === 'high') {
|
|
266
|
+
console.log(` ${dim('下一次')} ${paint('freedom build', C.fg.cyan)} ${dim('将加密 resources(app.bin),前端源码与配置不会以明文落盘。')}`);
|
|
267
|
+
}
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
181
271
|
case 'config': {
|
|
182
272
|
const sub = rest[0];
|
|
183
273
|
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
|
-
|
|
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
|
};
|
package/lib/init.js
CHANGED
|
@@ -12,8 +12,16 @@ function init(targetDir, opts = {}) {
|
|
|
12
12
|
throw new Error(`目标目录 ${abs} 非空,请使用空目录,或加 --force 覆盖。`);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// 模板选择:'minimal' 极简(无自绘标题栏 / 无演示内容,默认 native 标题栏);默认完整模板。
|
|
16
|
+
// 非法值快速失败,避免静默回退成默认模板让用户误以为指定成功。
|
|
17
|
+
const template = opts.template || 'full';
|
|
18
|
+
const templateName = template === 'minimal' ? 'project-minimal' : 'project';
|
|
19
|
+
if (template !== 'full' && template !== 'minimal') {
|
|
20
|
+
throw new Error(`未知模板:${template}。可选:full(完整模板,含自绘标题栏示例)/ minimal(极简模板,无自绘标题栏)。`);
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
fs.mkdirSync(abs, { recursive: true });
|
|
16
|
-
copyDir(projectTemplateDir(), abs);
|
|
24
|
+
copyDir(projectTemplateDir(templateName), abs);
|
|
17
25
|
|
|
18
26
|
// 项目名替换到 package.json 与 freedom.config.js
|
|
19
27
|
const name = opts.name || path.basename(abs);
|
package/lib/security.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 安全加固模块(v1.13.10)
|
|
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
|
+
};
|