@yufengtadian/freedom-cli 1.13.3 → 1.14.0-preview

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,15 @@
1
1
  # freedom-cli
2
2
 
3
- Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.13.3)。
3
+ Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.14.0-preview)。
4
4
 
5
5
  基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
6
6
 
7
+ **v1.14.0-preview 加密代际断代(FRDM2 → FRDM3)+ 发布密钥体系**:① `.integrity` 改为 **ed25519 签名的 v3 清单**,签名对象覆盖 `app.bin` 摘要、应用标识、容器盐与 exe 自身摘要,公钥构建期注入壳——没有发布方私钥就签不出合法清单;② 容器主密钥从"随 npm 包分发的全域常量"改为**每产物随机密钥**(`.freedom/keys/<app>.key`,`freedom keygen` 生成,永不进包),1.13.x 那条"持有 CLI 者可离线解密任意产物"的结构缺陷(台账 B-20260925-054)就此封堵;③ **high 收为 Tier B 专属**——加密产物必须由本机现编的应用专属壳承载(需 Go 工具链、不支持交叉编译),零工具链通用壳(Tier A)请用 `basic`;④ 安全校验失败从"打印告警后返回"改为**退出码 70**,脚本与 CI 从此能区分"拒跑"与"正常退出";⑤ `freedom keygen` 支持 `--dir <项目目录>`(此前静默按 cwd 写,会把密钥落到错误位置)。**FRDM1/FRDM2 旧产物在新壳上明确拒绝运行(不静默降级),须用 1.14.0-preview 重新 `freedom build`**;三平台壳随本版本经 CI 重编发布。
8
+
7
9
  **v1.13.x 框架线回归 + 稳定性收口**(v1.13.0 功能,v1.13.1 为文档勘误发布):
8
10
  - **freedom-cli 源码回归主仓库**:本 CLI 与框架源码同仓维护(`freedom-cli/` 目录,`templates/go` 为框架源码快照),npm 包与 GitHub Release 一一对应,历史"源码丢失停在 1.12.18"的断档已修复;
9
11
  - **三平台通用壳经 tag CI 自动发布**:推送 `vX.Y.Z` tag 即由 `build.yml` 在 win / mac(Apple Silicon)/ linux runner 上编译通用壳并自动创建 GitHub Release,资产命名 `freedom-shell-<plat>`;`freedom shell download <plat>` 直接拉取对应版本资产(默认定位 `v<包版本>`,可用 `FREEDOM_SHELL_TAG` 覆盖),包内另自带三平台壳兜底,均随包分发;
10
- - **运行时资源层回归**:壳从 exe 同目录 `resources/` 读取 `config.json`(窗口 / 后端配置覆盖)与前端页面,high 模式下前端页面、配置与 **`backend/**` 后端源码**统一封进加密 `app.bin`(FRDM2 容器:构建期随机盐 + PBKDF2 60 万次派生 + Encrypt-then-MAC 覆盖头部)+ `.integrity` 清单,磁盘不留明文后端源码;JS 侧构建加密 → Go 壳内存解密已有跨语言黄金向量与端到端互验(改名 / 篡改 / 整体替换即拒绝运行);
12
+ - **运行时资源层回归**:壳从 exe 同目录 `resources/` 读取 `config.json`(窗口 / 后端配置覆盖)与前端页面,high 模式下前端页面、配置与 **`backend/**` 后端源码**统一封进加密 `app.bin`(FRDM3 容器:构建期随机盐 + PBKDF2 60 万次派生 + Encrypt-then-MAC 覆盖头部)+ **ed25519 签名的 `.integrity` 清单**,磁盘不留明文后端源码;JS 侧构建加密 → Go 壳内存解密已有跨语言黄金向量与端到端互验(改名 / 篡改 / 整体替换 / 换公钥文件即拒绝运行,退出码 70)。**边界如实说明**:high 是"每产物专属密钥 + 发布方私钥签名"的加密加固,强度目标是"读不到明文、改不动内容、重打包跑不起来",不是 DRM 也不是访问控制——运行期密钥仍在壳进程内存里,能读内存或愿意自编壳的对手不在防护范围内(详见根仓库 [SECURITY.md](../SECURITY.md) 的 Tier A / Tier B 表);
11
13
  - **多窗口(M2)随 v1.13.0 壳可用**:前端 `window.freedom.window.create / close / list / focus` 开二级窗口,Go 侧 `App.NewWindow / Window.Close`;次级窗口独立消息泵、页面源支持内联 HTML / URL;
12
14
  - **销毁竞态收口**:修复 webview2 在 `Destroy` 中泵出滞留 dispatch 回调导致的随机崩溃(0xc0000005,多窗口 / 快速关闭场景),回收后所有排队回调按拆除旗标自我作废,并配套红绿回归测试;
13
15
  - **v1.13.1**:文档同步(本 README 更新至 v1.13.x 真实现状、壳 CI 章节勘误),无功能与壳二进制变更。
@@ -139,6 +141,8 @@ mac 用户解压 `.app.zip` 即得 `.app`,拖入 `/Applications` 即可直接
139
141
 
140
142
  **预编译壳的来源与 CI**:`webview_go` 依赖各系统自带 WebView 框架,**无法交叉编译**,三平台壳必须在对应平台本机编译。自 v1.13.0 起由仓库 `.github/workflows/build.yml` 统一承担:推送 `vX.Y.Z` tag → 三平台 runner 编译通用壳 → `release` job 自动创建 GitHub Release 并上传资产 `freedom-shell-<plat>`,供 `freedom shell download` 按「`v<包版本>` tag + 同名资产」拉取(Intel Mac 已不支持,见 `nativePlatform()` 的明确报错)。发版顺序:先推 GitHub tag、等 Release 资产就绪,再 `npm publish` 同版本——保证 shell 下载默认源始终可用(紧急时可设 `FREEDOM_SHELL_TAG` 指向既有 tag)。
141
143
 
144
+ **发布代际预检(`npm publish` 前置门)**:`package.json` 的 `prepublishOnly` 会跑 `lib/shell.js` 的 `preflightBundledShells()`——任一随包壳(`shell/<plat>`)的生成时间早于 `templates/go` 内最新框架源即**拒绝发布**。防的是「改了框架源、忘了重新同步壳」这类混合代 tarball:新 CLI 产的 FRDM3 产物会被旧壳直接拒跑(`npm publish --ignore-scripts` 可绕过,仅限确知安全时)。
145
+
142
146
  **平台名写法与下载回退**:`--platform` 与 `freedom shell download/build` 接受别名与架构后缀——
143
147
  `win` / `windows` / `mac` / `macos` / `osx` / `ubuntu` / `linux-x86_64` / `mac-arm64` 等统一归一到
144
148
  `win-x64` / `darwin-arm64` / `linux-x64`;无法支持的组合(`win-x86`、`darwin-x64`、`plan9`)直接报「未知平台」并给出可选值,不做猜测。
@@ -202,7 +206,18 @@ export default {
202
206
  | --- | --- | --- | --- |
203
207
  | `none` | 明文 `index.html` + `config.json`(默认,兼容历史产物) | 解包即读 | 公开页面 / 调试 / 快速分发 |
204
208
  | `basic` | 同上明文,构建时额外输出加固建议 | 低 | 需要提示、暂不加密 |
205
- | `high` | 前端 HTML + 配置 + **后端源码**整体加密为 `resources/app.bin` + `.integrity`,磁盘**无任何明文** | 高(需逆向壳 + 还原派生密钥) | 防源码提取、防资源篡改的正式分发 |
209
+ | `high` | 前端 HTML + 配置 + **后端源码**整体加密为 `resources/app.bin` + **签名** `.integrity`,磁盘**无任何明文** | 高(需逆向壳 + 拿到发布方私钥与每产物主密钥) | 防源码提取、防重打包的正式分发;**仅 Tier B(自编译壳)** |
210
+
211
+ **两档壳,强度不同(1.14.0-preview 起的口径,务必对号入座)**:
212
+
213
+ | 档 | 壳怎么来 | 能跑 `high` 产物吗 | 说明 |
214
+ | --- | --- | --- | --- |
215
+ | **Tier A** | 通用预编译壳(`freedom build` 零工具链,`none` / `basic` 用的就是它:随包分发或按需下载) | ❌ | 壳是公共二进制,没有构建期注入点 ⇒ 每产物主密钥与验签公钥无处安放,硬放进产物等于让攻击者自签自验 |
216
+ | **Tier B** | 本应用自编译壳(`freedom build --security high` 自动走这条:内部调 `freedom shell build` 现编,需 Go 工具链) | ✅ | 主密钥与 ed25519 公钥编译期内嵌进**这个应用专属的壳**,签名清单与它一一绑定 |
217
+
218
+ 所以 `high` 一档自带三个门槛,缺一个即拒绝构建(不静默降级):`.freedom/keys/<app>.key` 每产物主密钥、
219
+ `.freedom/keys/update_ed25519` 签名私钥(两者由 `freedom keygen` 生成)、以及**在本机为目标平台编译**
220
+ (webview_go 依赖目标平台系统 WebView,无法交叉编译,跨平台 high 会直接报明)。
206
221
 
207
222
  **切换方式**(二选一,`--security` 可临时覆盖配置文件):
208
223
 
@@ -212,16 +227,24 @@ freedom build --security high # 单次构建生效(不改配置)
212
227
  freedom build # 读取配置中的 security 值
213
228
  ```
214
229
 
215
- **high 模式原理(FRDM2 容器)**:
216
- - 容器布局:`FRDM2`(5B) + `salt`(16B,**每次构建随机**) + `iv`(16B) + `tag`(16B) + 密文;密钥 = **PBKDF2-HMAC-SHA256**(60 万次迭代,主密钥经字节表掩码内置于壳与 CLI)按「应用可执行文件名 + 容器随机盐」派生,再由固定标签做 **HMAC 域分离**得到独立的加密钥与认证钥;
217
- - 认证为 **Encrypt-then-MAC 且覆盖容器头部**(magic/salt/iv 参与计算),故改盐、改 IV、翻转任意一字节都会认证失败;`.integrity` 清单绑定同一盐值,**整体替换成另一个合法容器**同样拒绝运行;
230
+ **high 模式原理(FRDM3 容器 + 签名清单)**:
231
+ - 容器布局:`FRDM3`(5B) + `salt`(16B,**每次构建随机**) + `iv`(16B) + `tag`(16B) + 密文;密钥 = **PBKDF2-HMAC-SHA256**(60 万次迭代)以「**每产物主密钥**(`.freedom/keys/<app>.key`,32B 随机,`freedom keygen` 生成;不进 npm 包、不以明文落进 `resources/`,只以"本产物专属装配码"的形式编进本应用专属壳(见下))+ 应用标识 + 容器随机盐」派生,再由固定标签做 **HMAC 域分离**得到独立的加密钥与认证钥;
232
+ - 完整性为 **Encrypt-then-MAC 且覆盖容器头部**(magic/salt/iv 参与计算),故改盐、改 IV、翻转任意一字节都会认证失败;`resources/.integrity` 是 **ed25519 签名的 v3 清单**(签名对象是清单里 base64 那段确切的字节,跨语言不做 JSON 规范化), claims 绑定 `app.bin` 摘要、应用标识、容器盐与 **exe 自身摘要**——换内容、换身份、换盐、**给 exe 改名**全部当场拒;
233
+ - **验签顺序不可换**:先比对清单公钥与壳内信任锚(锚必须在被校验对象之外,否则攻击者换对钥匙即自证合法)→ 验签 → 复查 claims 绑定 → exe 自摘要 → 才解密;
218
234
  - **后端源码不再明文落盘**:`backend/**`(含 POSIX 权限位)作为条目进入容器,磁盘上不再有 `resources/backend/`;壳启动解密到仅属主可访问的一次性临时目录(0700 / 文件 0600,执行位按容器记录还原)供子进程执行,进程退出即删除;若被强杀或崩溃来不及删,**下次启动会按目录名内嵌的 PID 自动回收残留**;
219
- - 构建期抹除前端产物中的 `sourceMappingURL` 引用(防 source map 还原源码),壳二进制一律 `-trimpath -ldflags "-s -w"`(Windows 另加 `-H windowsgui`);
220
- - 解密 / 校验失败即拒绝运行(不静默回退明文,防降级攻击;旧版 `FRDM1` 容器明确报"版本不支持");
235
+ - **运行期明文只有一份、派生钥不驻留**:一份产物在进程内**只解密一次**(配置与页面两处加载共用同一份已校验载荷;验签链每次照跑,缓存不参与判定),
236
+ PBKDF2 派生出的加密钥与认证钥**只在解密那几毫秒内存在、返回即抹零**,不写进任何跨调用缓存——
237
+ 否则对手只要做一次内存取样拿到派生钥,就能对磁盘上的 `app.bin` 永久离线解密(容器盐与 IV 都在容器头里,除派生钥外无第二个秘密输入);
238
+ 后端源文写进私有临时目录后,其在内存里的那份副本同样立即抹零。诚实边界:前端页面与配置的明文必须交给 webview,
239
+ **必然常驻内存**,这一层擦不掉;
240
+ - 构建期抹除前端产物中的 `sourceMappingURL` 引用(防 source map 还原源码),壳二进制一律 `-trimpath -ldflags "-s -w"`(Windows 另加 `-H windowsgui`);**主密钥没有 `-X` 通道**:每次 `freedom build --security high` 现场生成一份**只属于本产物**的装配码(`keyslot_<tag>.go`,分片数 / 切法 / 变换 / 参数全随机),经 `go build -overlay` 虚拟进壳的包目录后随编译落入 exe、临时文件即删(`strings` 与十六进制扫描都定位不到那 32 字节,且从 A 产物逆出的提取流程对 B 产物无效);
241
+ - 解密 / 校验失败即**拒绝运行并以退出码 70 结束**(不静默回退明文,防降级攻击;拒跑必须非零,否则脚本与 CI 眼里"拒跑"与"正常退出"同形);旧代际 `FRDM1`/`FRDM2` 容器明确报"旧代际、请重新打包";
221
242
  - 另内置 **anti-debug**:`IsDebuggerPresent`、`CheckRemoteDebuggerPresent`、直读 `PEB.BeingDebugged`、`NtQueryInformationProcess` 的 `ProcessDebugPort` / `ProcessDebugObjectHandle` / `ProcessDebugFlags` 共六道独立信号,任一确证即静默退出(退出码 77),在资源解密前后各检测一次;所有探测遵循"取不到即视为未命中",杜绝误杀;
222
243
  - 进程隐藏加固(`-H windowsgui`,无控制台窗口)。
223
244
 
224
- **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中,反调试只抬升动态分析成本、不是不可绕过的墙。真正敏感的密钥与业务逻辑仍应留在服务端。**误报可关**:反调试在 CI、自动化测试、远程桌面、部分虚拟化环境可能把正常运行判成被附加调试器
245
+ **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中,反调试只抬升动态分析成本、不是不可绕过的墙;Tier B 也挡不住"攻击者自换一对密钥再编一份自己的壳"(那已经是整个应用归攻击者,再上一层只有 OS 级出版商信任 / Authenticode)。真正敏感的密钥与业务逻辑仍应留在服务端。
246
+ 两条当前已知边界(如实列出,勿当作"已修"):① 主密钥的装配码在壳里、生成器在公开源里,故静态逆向出某个壳的人可以拼回那把主密钥——但 1.14.0-preview 起装配码**每个产物一份**(分片与变换随机),针对 A 产物得到的脱壳流程不能直接复用到 B 产物,抬的是自动化与批量脱壳的成本,不是"逆向就是拿不到";② 前端页面与配置的明文必须交给 webview,能读进程内存者始终看得到那一份(后端源码已在物化后擦除,见上)。
247
+ **密钥即发布权**:`.freedom/keys/`(每产物主密钥 + ed25519 私钥)丢失后**无法再为该应用产出可运行的 high 产物**(既有产物不受影响),请把它们当作发布证书备份;反之它们一旦外泄,拿到者就能为你的应用签出合法篡改清单——勿入库、勿分发(`freedom keygen` 会自动往 `.gitignore` 补行)。**误报可关**:反调试在 CI、自动化测试、远程桌面、部分虚拟化环境可能把正常运行判成被附加调试器
225
248
  (命中即静默退出,码 77)。这类场景设 `FREEDOM_DISABLE_ANTIDEBUG=1`,或在壳层源码里 `Config{DisableAntiDebug: true}`
226
249
  后 `freedom shell build`——只关探测,容器解密与 `.integrity` 完整性校验照旧生效(那两道才是源码保护的本体)。
227
250
  `resources/config.json` 里刻意不提供此开关:打包产物里的配置运行期可被第三方替换,给开关等于递刀。
package/lib/build.js CHANGED
@@ -23,6 +23,7 @@
23
23
  const fs = require('fs');
24
24
  const fsp = fs.promises;
25
25
  const path = require('path');
26
+ const crypto = require('crypto');
26
27
  const { spawnSync } = require('child_process');
27
28
  const { copyDir } = require('./utils');
28
29
  const {
@@ -35,13 +36,19 @@ const {
35
36
  normalizePlatform,
36
37
  localShellPath,
37
38
  } = require('./utils');
38
- const { hasShell, downloadShell, validateLocalShell } = require('./shell');
39
+ const { buildShell, downloadShell, validateLocalShell } = require('./shell');
39
40
  const {
40
41
  SECURITY_MODES,
41
42
  resolveSecurity,
42
- encryptApp,
43
- buildIntegrity,
44
- renderIntegrity,
43
+ encryptAppV3,
44
+ signIntegrityV3,
45
+ renderManifestV3,
46
+ shellInject,
47
+ keySlotForBuild,
48
+ loadProductKey,
49
+ signingKeyPath,
50
+ rawPubHexFromKey,
51
+ sha256hex,
45
52
  } = require('./security');
46
53
 
47
54
  function run(cmd, args, opts = {}) {
@@ -107,6 +114,14 @@ async function build(projectDir, opts = {}) {
107
114
  const platforms = parsePlatforms(opts.platform);
108
115
  const autoDownload = opts.autoDownload !== false;
109
116
 
117
+ // 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
118
+ // none = 明文资源(默认,兼容历史产物)
119
+ // basic = 明文资源 + 构建期安全提示(剥符号 / 混淆)
120
+ // high = FRDM3:resources 加密为 app.bin + 发布方私钥签名的 .integrity + 本应用专属壳
121
+ // 判定与发布方资产门都前置于前端打包:high 缺密钥属"注定失败的构建",不该先跑几分钟 vite。
122
+ const security = resolveSecurity(opts.security, cfg.security);
123
+ const secrets = security === 'high' ? await prepareHighSecrets(dir, name) : null;
124
+
110
125
  // 1) 前端打包(带缓存:源码未变更时复用上次 vite 产物,跳过 npm run build)
111
126
  // cfg.staticHtml:静态单文件页面直通——不跑 npm install / vite,用于 freedom 自己的
112
127
  // Desktop 界面自举打包(以及纯静态页项目),零网络、零依赖。
@@ -142,11 +157,6 @@ async function build(projectDir, opts = {}) {
142
157
  warnIfNotSingleFile(html, distHtml);
143
158
  }
144
159
  const configJSON = renderConfigJSON(cfg, name, version);
145
- // 安全模式:--security 优先于 freedom.config.js 的 security 字段,默认 none。
146
- // none = 明文资源(默认,兼容历史产物)
147
- // basic = 明文资源 + 构建期安全提示(剥符号 / 混淆,后续可用高模式加密)
148
- // high = resources 整体加密为 app.bin(AES-256-CTR+HMAC)+ .integrity 完整性清单,磁盘无明文
149
- const security = resolveSecurity(opts.security, cfg.security);
150
160
 
151
161
  // 2) 后端目录(若配置了 backend 进程)
152
162
  const backendDir = path.join(dir, cfg.backendDir || 'backend');
@@ -162,7 +172,7 @@ async function build(projectDir, opts = {}) {
162
172
  const results = await Promise.all(
163
173
  platforms.map(async (plat) => {
164
174
  const targetDir = multi ? path.join(outDirPath, plat) : outDirPath;
165
- const emitted = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer: opts.installer === true });
175
+ const emitted = await emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer: opts.installer === true, secrets });
166
176
  return { plat, ...emitted };
167
177
  })
168
178
  );
@@ -197,34 +207,73 @@ async function build(projectDir, opts = {}) {
197
207
  return { results };
198
208
  }
199
209
 
200
- async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer }) {
201
- // 取预编译壳二进制
202
- const shell = localShellPath(plat);
203
- if (!fs.existsSync(shell)) {
204
- if (!autoDownload) {
210
+ // prepareHighSecrets —— high 模式(Tier B)的发布方资产门。
211
+ //
212
+ // 两样东西缺一不可:
213
+ // 每产物主密钥 .freedom/keys/<app>.key —— 编译进本应用专属壳,容器 KEK 的输入;
214
+ // 发布方私钥 .freedom/keys/update_ed25519 —— 签 resources/.integrity(与自更新清单同一把钥匙)。
215
+ // 缺失一律拒绝构建:退回 FRDM2 那把随 npm 包公开的全域主密钥,等于签出一份谁都能伪造的清单
216
+ // (台账 B-20260925-054)。私钥只用于签名,公钥才是注入壳的信任锚,私钥本身永不进产物。
217
+ async function prepareHighSecrets(dir, name) {
218
+ const master = loadProductKey(dir, name);
219
+ const privPath = signingKeyPath(dir);
220
+ if (!fs.existsSync(privPath)) {
221
+ throw new Error(`缺少发布方签名私钥:${privPath}\n请先运行 freedom keygen(私钥是发布方资产,勿入库、勿分发)`);
222
+ }
223
+ const privateKey = crypto.createPrivateKey(await fsp.readFile(privPath));
224
+ return { master, anchorPubHex: rawPubHexFromKey(privateKey), privateKey };
225
+ }
226
+
227
+ async function emitPlatform({ plat, name, version, targetDir, html, configJSON, backendDir, hasBackend, autoDownload, icon, security, installer, secrets }) {
228
+ const exeName = platformExeName(plat, name);
229
+ const outFile = path.join(targetDir, exeName);
230
+ await fsp.mkdir(targetDir, { recursive: true });
231
+
232
+ // 取壳:high 模式(Tier B)编译本应用专属壳,把每产物主密钥与信任锚编进 exe;
233
+ // 其余模式沿用包内/下载的通用预编译壳(明文资源,无需任何注入)。
234
+ if (security === 'high') {
235
+ if (!secrets) throw new Error('internal: high 模式缺少 secrets');
236
+ if (plat !== nativePlatform()) {
205
237
  throw new Error(
206
- `缺少平台 ${plat} 的壳二进制:${shell}\n` +
207
- `可运行 freedom shell download ${plat} 下载,或 freedom shell build ${plat} 本地编译。`
238
+ `high 模式需要在本机编译 ${plat} 的专属壳(webview_go 依赖目标平台系统 WebView,无法交叉编译)。` +
239
+ `当前平台是 ${nativePlatform()}:请在 ${plat} 机器上执行 freedom build --security high,` +
240
+ `或该平台改用 --security basic。`
208
241
  );
209
242
  }
210
- process.stdout.write(`[freedom] 本地无 ${plat} 壳,尝试自动下载...\n`);
211
- await downloadShell(plat);
243
+ process.stdout.write(
244
+ `[freedom] high 模式(Tier B):编译 ${name} 的专属壳,注入信任锚公钥 + 本次构建专属的主密钥装配码(需 Go 工具链)...\n`
245
+ );
246
+ // 主密钥不再有 -X 通道(固定掩码 = 一份脱壳器通吃所有产物,台账 B-20260925-060):
247
+ // 每次构建现场生成一份只属于本产物的装配码,经 overlay 虚拟进壳的包目录,编完即弃。
248
+ const keySlot = keySlotForBuild(secrets.master);
249
+ buildShell(plat, {
250
+ dest: outFile,
251
+ inject: shellInject(secrets.anchorPubHex),
252
+ stageGoFiles: { [keySlot.fileName]: keySlot.source },
253
+ });
254
+ if (!isWinPlat(plat)) await fsp.chmod(outFile, 0o755);
212
255
  } else {
213
- // 壳格式校验:防止 mac/linux 平台误用 Windows 假壳被静默分发(历史缺陷:shell/<darwin-*>/<linux-*> 曾误填 Windows PE 副本)。
214
- const formatIssue = validateLocalShell(plat);
215
- if (formatIssue) {
216
- throw new Error(formatIssue);
256
+ const shell = localShellPath(plat);
257
+ if (!fs.existsSync(shell)) {
258
+ if (!autoDownload) {
259
+ throw new Error(
260
+ `缺少平台 ${plat} 的壳二进制:${shell}\n` +
261
+ `可运行 freedom shell download ${plat} 下载,或 freedom shell build ${plat} 本地编译。`
262
+ );
263
+ }
264
+ process.stdout.write(`[freedom] 本地无 ${plat} 壳,尝试自动下载...\n`);
265
+ await downloadShell(plat);
266
+ } else {
267
+ // 壳格式校验:防止 mac/linux 平台误用 Windows 假壳被静默分发(历史缺陷:shell/<darwin-*>/<linux-*> 曾误填 Windows PE 副本)。
268
+ const formatIssue = validateLocalShell(plat);
269
+ if (formatIssue) {
270
+ throw new Error(formatIssue);
271
+ }
272
+ }
273
+ await fsp.copyFile(shell, outFile);
274
+ if (!isWinPlat(plat)) {
275
+ await fsp.chmod(outFile, 0o755);
217
276
  }
218
- }
219
-
220
- await fsp.mkdir(targetDir, { recursive: true });
221
-
222
- // 壳二进制 -> 应用可执行文件
223
- const exeName = platformExeName(plat, name);
224
- const outFile = path.join(targetDir, exeName);
225
- await fsp.copyFile(shell, outFile);
226
- if (!isWinPlat(plat)) {
227
- await fsp.chmod(outFile, 0o755);
228
277
  }
229
278
 
230
279
  // 自定义 exe 图标(仅 Windows PE 支持嵌入 .ico 资源;mac 用 .icns 走 .app 分支)
@@ -262,8 +311,21 @@ async function emitPlatform({ plat, name, version, targetDir, html, configJSON,
262
311
  if (stripped > 0) {
263
312
  process.stdout.write(`[freedom] high 模式:已抹去前端页内 ${stripped} 处 source map 引用(可能含原始源码)。\n`);
264
313
  }
265
- const appBin = encryptApp(name, secureHtml, configJSON, backendFiles); await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
266
- await fsp.writeFile(path.join(resDir, '.integrity'), renderIntegrity(buildIntegrity(name, appBin)), 'utf8');
314
+ // FRDM3 容器:KEK 由「每产物主密钥 + 应用标识 + 容器随机盐」派生,主密钥只在发布方
315
+ // 仓库与本应用专属壳里,公开 npm 源上没有任何一把能解开它。
316
+ const appBin = encryptAppV3(secrets.master, name, secureHtml, configJSON, backendFiles);
317
+ await fsp.writeFile(path.join(resDir, 'app.bin'), appBin);
318
+ // self 绑定 exe 此刻的字节,故哈希必须在图标注入之后、安装包/.app 组装之前取
319
+ //(后续步骤不再改写 exe 本体)。事后改写 exe(尤其补做 Authenticode 签名)会让壳拒启动。
320
+ const selfHash = sha256hex(await fsp.readFile(outFile));
321
+ const manifest = signIntegrityV3({
322
+ name,
323
+ appBin,
324
+ signingKey: secrets.privateKey,
325
+ selfHash,
326
+ built: new Date().toISOString(),
327
+ });
328
+ await fsp.writeFile(path.join(resDir, '.integrity'), renderManifestV3(manifest), 'utf8');
267
329
  } else {
268
330
  for (const legacy of ['app.bin', '.integrity']) {
269
331
  const p = path.join(resDir, legacy);
@@ -736,4 +798,4 @@ function stripSourceMapRefs(html) {
736
798
  return { html: out, stripped };
737
799
  }
738
800
 
739
- module.exports = { build, parsePlatforms, renderConfigJSON };
801
+ module.exports = { build, parsePlatforms, renderConfigJSON, prepareHighSecrets };
package/lib/cli.js CHANGED
@@ -37,7 +37,7 @@ function help() {
37
37
  L.push(` ${paint('freedom dmg [--platform <plat>]', C.fg.cyan)} ${dim('将已构建的 .app 打包为 .dmg(需 macOS)')}`);
38
38
  L.push(section('开发与更新'));
39
39
  L.push(` ${paint('freedom dev [--port <n>|--url <u>] [--command <cmd>]', C.fg.cyan)} ${dim('dev server 联调壳窗口:HMR 热更,改码免重打包')}`);
40
- L.push(` ${paint('freedom keygen', C.fg.cyan)} ${dim('生成应用自更新 ed25519 密钥对(公钥进配置,私钥发布方保管)')}`);
40
+ L.push(` ${paint('freedom keygen [--dir <项目目录>]', C.fg.cyan)} ${dim('生成应用自更新 ed25519 密钥对 + 每产物主密钥(公钥进配置,私钥发布方保管)')}`);
41
41
  L.push(` ${paint('freedom manifest --artifact <产物> --url <下载地址> [--version x]', C.fg.cyan)} ${dim('产出签名的更新清单 latest.json')}`);
42
42
  L.push(section('外观'));
43
43
  L.push(` ${paint('freedom titlebar <native|frameless>', C.fg.cyan)} ${dim('一键切换标题栏策略')}`);
@@ -229,8 +229,12 @@ async function run(argv) {
229
229
 
230
230
  case 'keygen': {
231
231
  const { keygen } = require('./release');
232
+ // --dir 必须生效:忽略它会把密钥 mint 到当前目录,之后在项目里 build 只会在
233
+ // 一个从未存在的文件名下找主密钥(报"缺每产物主密钥",用户无从下手)。
234
+ const di = rest.indexOf('--dir');
235
+ const dir = di >= 0 && rest[di + 1] && !rest[di + 1].startsWith('--') ? rest[di + 1] : process.cwd();
232
236
  try {
233
- console.log(await keygen({ dir: process.cwd(), force: rest.includes('--force') }));
237
+ console.log(await keygen({ dir, force: rest.includes('--force') }));
234
238
  } catch (e) {
235
239
  console.error(`${err(e.message)}`);
236
240
  return 1;
package/lib/release.js CHANGED
@@ -15,29 +15,45 @@ const fsp = fs.promises;
15
15
  const path = require('path');
16
16
 
17
17
  const { loadConfig } = require('./utils');
18
+ const { ensureKeysIgnored, signingKeyPath, productKeyPath, createProductKey } = require('./security');
18
19
 
19
20
  function manifestPayload(version, url, sha256hex) {
20
21
  return Buffer.from(`freedom-update-v1\n${version}\n${url}\n${String(sha256hex).toLowerCase()}`, 'utf8');
21
22
  }
22
23
 
23
- function keysPath(dir) {
24
- return path.join(dir, '.freedom', 'keys', 'update_ed25519');
25
- }
26
-
24
+ // keygen 一次性 mint 发布方的两把资产:
25
+ // 1) ed25519 签名私钥 —— 签自更新清单(latest.json)与产物完整性清单(.integrity v3);
26
+ // 2) 每产物主密钥 .freedom/keys/<app>.key —— high 模式容器 KEK 的输入(FRDM3)。
27
+ // 两把都落在 .freedom/keys/(ensureKeysIgnored 保证不入库),都属于"丢了就没法再为该应用
28
+ // 重签产物"的发布方资产,故 keygen 的输出把两个路径都念给用户。
27
29
  async function keygen(opts = {}) {
28
30
  const dir = path.resolve(opts.dir || '.');
29
- const privPath = opts.out ? path.resolve(opts.out) : keysPath(dir);
31
+ const privPath = opts.out ? path.resolve(opts.out) : signingKeyPath(dir);
30
32
  if (fs.existsSync(privPath) && !opts.force) {
31
33
  throw new Error(`私钥已存在:${privPath}(重新生成请 --force,注意旧公钥签发的客户端将收不到新签名)`);
32
34
  }
33
35
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
34
36
  await fsp.mkdir(path.dirname(privPath), { recursive: true });
37
+ ensureKeysIgnored(dir); // 只写提示语不算保护:一次 git add -A 就能把私钥推上远端
35
38
  await fsp.writeFile(privPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
36
39
  const rawPub = publicKey.export({ type: 'spki', format: 'der' }).subarray(-32); // SPKI 尾部 32 字节即原始公钥
37
40
  const pubB64 = rawPub.toString('base64');
41
+ // 应用标识必须与 build 同源(cfg.name),否则 high 构建按名字找主密钥会找不到。
42
+ let appName = path.basename(dir);
43
+ try {
44
+ appName = (await loadConfig(dir)).name || appName;
45
+ } catch { /* 非项目目录:按目录名 mint;在项目内重跑 keygen 即可 */ }
46
+ const masterPath = productKeyPath(dir, appName);
47
+ // 已有主密钥就沿用:老项目升级 CLI 后重跑 keygen 只为补这把钥匙,不该被迫 --force 轮换签名私钥。
48
+ let minted = '沿用';
49
+ if (!fs.existsSync(masterPath)) {
50
+ createProductKey(dir, appName);
51
+ minted = '新生成';
52
+ }
38
53
  const lines = [
39
54
  `私钥已生成:${privPath}(发布方资产,勿入库/勿分发)`,
40
55
  `公钥(base64):${pubB64}`,
56
+ `每产物主密钥(${minted}):${masterPath}(high 模式产物加密所需,应用名 ${appName})`,
41
57
  '用法:freedom.config.js 中设置',
42
58
  ` updater: { manifestURL: 'https://your.host/latest.json', publicKey: '${pubB64}' }`,
43
59
  '发版时:freedom manifest 生成签名 latest.json。',
@@ -71,7 +87,7 @@ async function manifest(opts = {}) {
71
87
  || (() => { try { return require(path.join(dir, 'package.json')).version; } catch { return null; } })();
72
88
  if (!version) throw new Error('版本未定:--version / freedom.config.js 的 version / 项目 package.json 的 version 均缺失');
73
89
 
74
- const keyPath = opts.key ? path.resolve(opts.key) : keysPath(dir);
90
+ const keyPath = opts.key ? path.resolve(opts.key) : signingKeyPath(dir);
75
91
  if (!fs.existsSync(keyPath)) {
76
92
  throw new Error(`私钥不存在:${keyPath},先运行 freedom keygen`);
77
93
  }