@yufengtadian/freedom-cli 1.13.2 → 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.
Files changed (58) hide show
  1. package/LICENSE +40 -0
  2. package/README.md +74 -21
  3. package/lib/agents.js +182 -52
  4. package/lib/build.js +119 -42
  5. package/lib/cli.js +106 -9
  6. package/lib/release.js +22 -6
  7. package/lib/security.js +525 -45
  8. package/lib/shell.js +288 -64
  9. package/lib/toolchain.js +339 -0
  10. package/lib/utils.js +51 -0
  11. package/lib/verify.js +85 -30
  12. package/lib/webkit.js +45 -0
  13. package/package.json +4 -3
  14. package/shell/darwin-arm64/freedom-shell +0 -0
  15. package/shell/linux-x64/freedom-shell +0 -0
  16. package/shell/win-x64/freedom-shell.exe +0 -0
  17. package/skill/freedom/SKILL.md +3 -3
  18. package/templates/desktop/backend/desktop.mjs +16 -6
  19. package/templates/desktop/freedom.config.js +4 -1
  20. package/templates/go/go.mod +3 -0
  21. package/templates/go/pkg/freedom/anti_debug_linux.go +69 -0
  22. package/templates/go/pkg/freedom/anti_debug_other.go +5 -3
  23. package/templates/go/pkg/freedom/anti_debug_windows.go +136 -12
  24. package/templates/go/pkg/freedom/assets_embed.go +1 -0
  25. package/templates/go/pkg/freedom/backend_proc.go +5 -5
  26. package/templates/go/pkg/freedom/events_windows.go +5 -5
  27. package/templates/go/pkg/freedom/freedom.go +17 -3
  28. package/templates/go/pkg/freedom/msgwindow_windows.go +8 -8
  29. package/templates/go/pkg/freedom/resources.go +59 -1
  30. package/templates/go/pkg/freedom/security.go +475 -41
  31. package/templates/go/pkg/freedom/shutdown.go +55 -0
  32. package/templates/go/pkg/freedom/shutdown_windows.go +47 -0
  33. package/templates/go/pkg/freedom/store.go +3 -3
  34. package/templates/go/pkg/freedom/syscap_windows.go +33 -33
  35. package/templates/go/pkg/freedom/sysint_windows.go +2 -2
  36. package/templates/go/pkg/freedom/tray_windows.go +18 -18
  37. package/templates/go/pkg/freedom/updater.go +3 -3
  38. package/templates/go/third_party/webview_go/CHANGELOG.md +15 -0
  39. package/templates/go/third_party/webview_go/FREEDOM-PATCH.md +32 -0
  40. package/templates/go/third_party/webview_go/LICENSE +22 -0
  41. package/templates/go/third_party/webview_go/README.md +50 -0
  42. package/templates/go/third_party/webview_go/glue.c +36 -0
  43. package/templates/go/third_party/webview_go/go.mod +3 -0
  44. package/templates/go/third_party/webview_go/go.sum +0 -0
  45. package/templates/go/third_party/webview_go/libs/mswebview2/LICENSE +27 -0
  46. package/templates/go/third_party/webview_go/libs/mswebview2/include/WebView2.h +23568 -0
  47. package/templates/go/third_party/webview_go/libs/mswebview2/include/vendor.go +2 -0
  48. package/templates/go/third_party/webview_go/libs/mswebview2/vendor.go +2 -0
  49. package/templates/go/third_party/webview_go/libs/mswebview2/version.txt +1 -0
  50. package/templates/go/third_party/webview_go/libs/webview/LICENSE +22 -0
  51. package/templates/go/third_party/webview_go/libs/webview/include/vendor.go +2 -0
  52. package/templates/go/third_party/webview_go/libs/webview/include/webview.h +3598 -0
  53. package/templates/go/third_party/webview_go/libs/webview/vendor.go +2 -0
  54. package/templates/go/third_party/webview_go/libs/webview/version.txt +1 -0
  55. package/templates/go/third_party/webview_go/webkit2_40.go +21 -0
  56. package/templates/go/third_party/webview_go/webkit2_41.go +8 -0
  57. package/templates/go/third_party/webview_go/webview.cc +1 -0
  58. package/templates/go/third_party/webview_go/webview.go +332 -0
package/lib/security.js CHANGED
@@ -11,8 +11,14 @@
11
11
  //
12
12
  // 架构说明(重要):通用壳 = 所有应用共用一份预编译 freedom-shell 二进制,
13
13
  // 因此密钥派生所需的"主密钥常量"编译在通用壳内,天然被所有应用共享;
14
- // high 模式的对抗目标是把攻击者从"直接读明文"抬升到"必须逆向壳二进制、
15
- // 提取主密钥并复现派生算法"。客户端加密做不到不可逆,本模式不这样宣称。
14
+ // high 模式**对外曾表述为**"必须逆向壳二进制才能提取主密钥"——该表述已被 2026-09-25
15
+ // 取证证伪(台账 B-20260925-054):本文件连同 MASTER_KEY_CIPHER、decryptApp、buildIntegrity
16
+ // 一起随 npm 包分发,而派生钥的另外两个输入(应用标识 = exe 文件名、派生盐 = app.bin 头 16B)
17
+ // 都在产物自己身上 ⇒ 持有本 CLI 者无需逆向即可解密任意产物并自造合法 .integrity。
18
+ // 根因是对称密钥模型:预编译通用壳没有构建期注入点,per-product 秘密无处安放,
19
+ // 这条路径上对称加密只能给到混淆级强度。结构性修复(FRDM3):.integrity 改由发布方
20
+ // ed25519 私钥签名、公钥进壳,并把主密钥移出公开源——见 .liangzu/plans/2026-09-25-r6-defense-max.md。
21
+ // 客户端加密做不到不可逆,本模式不这样宣称。
16
22
  //
17
23
  // ⚠️ 跨语言同步点:本文件与 templates/go/pkg/freedom/security.go(= 仓库根 security.go)必须保持
18
24
  // MASTER_KEY_CIPHER 字节表与掩码算法 out[i] ^= (i*7+0x5A)
@@ -23,6 +29,8 @@
23
29
  // 回归锁:tests/security-frdm2.test.mjs 与 Go security_test.go 互为对方产物的夹具。
24
30
 
25
31
  const crypto = require('crypto');
32
+ const fs = require('fs');
33
+ const path = require('path');
26
34
 
27
35
  const SECURITY_MODES = ['none', 'basic', 'high'];
28
36
 
@@ -80,13 +88,18 @@ function appIdentityFor(name) {
80
88
  return s;
81
89
  }
82
90
 
91
+ // maskPositional 按位置异或掩码(与 Go 侧 out[i] ^= byte((i*7+0x5A)&0xff) 严格同式)。
92
+ // 目的单一:让静态 strings / 十六进制扫描在壳与 exe 里找不到完整的秘密字节串。
93
+ // 这不是加密——密钥运行时仍在内存里,真正的对抗边界是反调试那一层。
94
+ function maskPositional(cipher) {
95
+ const out = Buffer.alloc(cipher.length);
96
+ for (let i = 0; i < cipher.length; i++) out[i] = cipher[i] ^ ((i * 7 + 0x5a) & 0xff);
97
+ return out;
98
+ }
99
+
83
100
  // masterSecret 还原主密钥(PBKDF2 的 password),与 Go masterSecret() 同式。
84
101
  function masterSecret() {
85
- const out = Buffer.alloc(MASTER_KEY_CIPHER.length);
86
- for (let i = 0; i < MASTER_KEY_CIPHER.length; i++) {
87
- out[i] = MASTER_KEY_CIPHER[i] ^ ((i * 7 + 0x5a) & 0xff);
88
- }
89
- return out;
102
+ return maskPositional(MASTER_KEY_CIPHER);
90
103
  }
91
104
 
92
105
  // 容器密钥对:加密与认证分开,避免同一密钥同时服务 AES-CTR 与 HMAC。
@@ -102,64 +115,46 @@ function deriveKeys(appName, salt) {
102
115
  return { enc, mac };
103
116
  }
104
117
 
105
- // ---- 加密 / 解密(high 模式)----
118
+ // ---- 容器密封/开启(两代际共用,差别只在 magic 与派生函数)----
106
119
 
107
- // 载荷结构:{ html, config, backend: { <rel>: { d: base64, m: mode } } }
108
- // backend 为可选的后端源码表(键 = 相对 resources 的斜杠路径),high 模式下
109
- // 后端源码进容器,磁盘不再留 resources/backend 明文目录。
110
- // 返回 app.bin 完整字节。
111
- function encryptApp(name, html, configJSON, backend) {
120
+ // sealAppBin 组装 magic + salt(16) + iv(16) + tag(16) + 密文。
121
+ // tag = HMAC-SHA256(macKey, magic+salt+iv+密文) 截 16B:头部一并认证,
122
+ // 否则换 salt/iv 就是可控的解密 oracle(Encrypt-then-MAC 纪律)。
123
+ // derive(salt) -> { enc, mac }:代际差异全部收在这个回调里。
124
+ function sealAppBin(magic, derive, payloadObj) {
112
125
  const salt = crypto.randomBytes(CTR_SALT_LEN);
113
126
  const iv = crypto.randomBytes(CTR_IV_LEN);
114
- const k = deriveKeys(name, salt);
115
- const payloadObj = { html, config: configJSON };
116
- if (backend && Object.keys(backend).length > 0) {
117
- payloadObj.backend = {};
118
- for (const [rel, entry] of Object.entries(backend)) {
119
- if (!isSafeRelPath(rel)) throw new Error(`后端路径非法,拒绝入容器:${rel}`);
120
- const buf = Buffer.isBuffer(entry) ? entry : entry.data;
121
- const mode = Buffer.isBuffer(entry) ? 0o644 : (((entry.mode ?? 0o644)) & 0o7777);
122
- payloadObj.backend[rel] = { d: buf.toString('base64'), m: mode };
123
- }
124
- }
127
+ const k = derive(salt);
125
128
  const payload = Buffer.from(JSON.stringify(payloadObj), 'utf8');
126
129
  const cipher = crypto.createCipheriv('aes-256-ctr', k.enc, iv);
127
130
  const ct = Buffer.concat([cipher.update(payload), cipher.final()]);
128
- const header = Buffer.concat([Buffer.from(APP_BIN_MAGIC, 'ascii'), salt, iv]);
131
+ const header = Buffer.concat([Buffer.from(magic, 'ascii'), salt, iv]);
129
132
  const tag = crypto.createHmac('sha256', k.mac)
130
133
  .update(header).update(ct).digest().subarray(0, CTR_TAG_LEN);
131
134
  return Buffer.concat([header, tag, ct]);
132
135
  }
133
136
 
134
- // 拆分并校验容器头;旧版 FRDM1 等不兼容版本明确拒绝(不静默降级)。
135
- function splitAppBin(buf) {
136
- const magic = Buffer.from(APP_BIN_MAGIC, 'ascii');
137
- if (buf.length < APP_BIN_HEADER_LEN) {
137
+ // unpackAppBin 校验 magic 并拆出头/密文;返回 { salt, iv, tag, ct, headerLen }。
138
+ function unpackAppBin(magic, buf) {
139
+ const m = Buffer.from(magic, 'ascii');
140
+ if (buf.length < m.length + CTR_SALT_LEN + CTR_IV_LEN + CTR_TAG_LEN) {
138
141
  throw new Error('app.bin 长度不足容器头');
139
142
  }
140
- if (!buf.subarray(0, magic.length).equals(magic)) {
143
+ if (!buf.subarray(0, m.length).equals(m)) {
141
144
  throw new Error(
142
- `app.bin 容器版本不受支持(头为 "${buf.subarray(0, magic.length).toString('ascii')}",要求 ${APP_BIN_MAGIC})`);
145
+ `app.bin 容器版本不受支持(头为 "${buf.subarray(0, m.length).toString('ascii')}",要求 ${magic})`);
143
146
  }
144
- let off = magic.length;
147
+ let off = m.length;
145
148
  const salt = buf.subarray(off, off + CTR_SALT_LEN); off += CTR_SALT_LEN;
146
149
  const iv = buf.subarray(off, off + CTR_IV_LEN); off += CTR_IV_LEN;
147
150
  const tag = buf.subarray(off, off + CTR_TAG_LEN); off += CTR_TAG_LEN;
148
- const ct = buf.subarray(off);
149
- return { salt, iv, tag, ct, headerLen: APP_BIN_AUTH_LEN };
151
+ return { salt, iv, tag, ct: buf.subarray(off), headerLen: m.length + CTR_SALT_LEN + CTR_IV_LEN };
150
152
  }
151
153
 
152
- // 判定容器内相对路径可安全落盘(与 Go isSafeRelPath 同式)。
153
- function isSafeRelPath(rel) {
154
- if (rel === '' || rel.includes('\\') || rel.startsWith('/')) return false;
155
- if (rel.length >= 2 && rel[1] === ':') return false;
156
- return rel.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
157
- }
158
-
159
- // 解密 app.bin(供跨语言回归测试与 CLI 自检复用)。返回 { html, config, backend? }。
160
- function decryptApp(name, buf) {
161
- const { salt, iv, tag, ct, headerLen } = splitAppBin(buf);
162
- const k = deriveKeys(name, salt);
154
+ // openAppBin 先恒定时间认证、再解密,并校验载荷结构;任何一步失败都抛(不降级)。
155
+ function openAppBin(magic, derive, buf) {
156
+ const { salt, iv, tag, ct, headerLen } = unpackAppBin(magic, buf);
157
+ const k = derive(salt);
163
158
  const expect = crypto.createHmac('sha256', k.mac)
164
159
  .update(buf.subarray(0, headerLen)).update(ct).digest().subarray(0, CTR_TAG_LEN);
165
160
  if (!crypto.timingSafeEqual(tag, expect)) {
@@ -174,6 +169,52 @@ function decryptApp(name, buf) {
174
169
  return obj;
175
170
  }
176
171
 
172
+ // backendEntries 把 { rel: Buffer | {data,mode} } 归一成容器内的 { rel: {d,m} }。
173
+ function backendEntries(backend) {
174
+ const out = {};
175
+ for (const [rel, entry] of Object.entries(backend)) {
176
+ if (!isSafeRelPath(rel)) throw new Error(`后端路径非法,拒绝入容器:${rel}`);
177
+ const buf = Buffer.isBuffer(entry) ? entry : entry.data;
178
+ const mode = Buffer.isBuffer(entry) ? 0o644 : (((entry.mode ?? 0o644)) & 0o7777);
179
+ out[rel] = { d: buf.toString('base64'), m: mode };
180
+ }
181
+ return out;
182
+ }
183
+
184
+ // appPayload 组装容器载荷对象(html/config + 可选 backend)。
185
+ function appPayload(html, configJSON, backend) {
186
+ const obj = { html, config: configJSON };
187
+ if (backend && Object.keys(backend).length > 0) obj.backend = backendEntries(backend);
188
+ return obj;
189
+ }
190
+
191
+ // ---- 加密 / 解密(high 模式,FRDM2 现行写侧)----
192
+
193
+ // 载荷结构:{ html, config, backend: { <rel>: { d: base64, m: mode } } }
194
+ // backend 为可选的后端源码表(键 = 相对 resources 的斜杠路径),high 模式下
195
+ // 后端源码进容器,磁盘不再留 resources/backend 明文目录。
196
+ // 返回 app.bin 完整字节。
197
+ function encryptApp(name, html, configJSON, backend) {
198
+ return sealAppBin(APP_BIN_MAGIC, (salt) => deriveKeys(name, salt), appPayload(html, configJSON, backend));
199
+ }
200
+
201
+ // 拆分并校验容器头;旧版 FRDM1 等不兼容版本明确拒绝(不静默降级)。
202
+ function splitAppBin(buf) {
203
+ return unpackAppBin(APP_BIN_MAGIC, buf);
204
+ }
205
+
206
+ // 判定容器内相对路径可安全落盘(与 Go isSafeRelPath 同式)。
207
+ function isSafeRelPath(rel) {
208
+ if (rel === '' || rel.includes('\\') || rel.startsWith('/')) return false;
209
+ if (rel.length >= 2 && rel[1] === ':') return false;
210
+ return rel.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
211
+ }
212
+
213
+ // 解密 app.bin(供跨语言回归测试与 CLI 自检复用)。返回 { html, config, backend? }。
214
+ function decryptApp(name, buf) {
215
+ return openAppBin(APP_BIN_MAGIC, (salt) => deriveKeys(name, salt), buf);
216
+ }
217
+
177
218
  // ---- 完整性清单(high 模式)----
178
219
 
179
220
  // 生成 resources/.integrity:记录 app.bin 的 HMAC-SHA256,壳启动时校验,
@@ -190,6 +231,423 @@ function renderIntegrity(integrity) {
190
231
  return JSON.stringify(integrity, null, 2);
191
232
  }
192
233
 
234
+ // ==================== FRDM3(下一代际:每产物主密钥 + 签名清单)====================
235
+ //
236
+ // 与 FRDM2 的两点结构性差别(对应台账 B-20260925-054 的根因):
237
+ // D2 主密钥不再编译进公开源:每产物一把 `.freedom/keys/<app>.key`(32B 随机,hex 落盘),
238
+ // 不在 npm 包、不在产物里 ⇒ 持有 CLI 不再等于能解任意产物。
239
+ // D1 完整性清单不再是对称 HMAC:改 ed25519 签名(私钥只在发布方),公钥作信任锚。
240
+ // 锚必须在被校验对象之外——Tier B(自编译壳)编译期内嵌;Tier A(通用预编译壳)只能读
241
+ // 产物内的 resources/.trust,属循环信任,强度按 SECURITY.md 的口径如实降级。
242
+ // 契约权威:.liangzu/plans/r6-defense-max/frdm3-contract.md
243
+
244
+ const DERIVE_SALT3 = 'freedom:derive:v3';
245
+ const MAC_LABEL3 = 'freedom:mac:v3';
246
+ const APP_BIN_MAGIC3 = 'FRDM3';
247
+ const PRODUCT_KEY_LEN = 32;
248
+
249
+ // ed25519 SPKI 固定前缀(302a300506032b6570032100)+ 32B 原始公钥 = 完整 DER。
250
+ // 清单与信任锚都只携带 32B 原始公钥(hex),验签时拼回 SPKI。
251
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
252
+
253
+ function sha256hex(buf) {
254
+ return crypto.createHash('sha256').update(buf).digest('hex');
255
+ }
256
+
257
+ function normalizePubHex(hex) {
258
+ const s = String(hex).trim().toLowerCase();
259
+ if (!/^[0-9a-f]{64}$/.test(s)) {
260
+ throw new Error(`ed25519 公钥需为 64 位十六进制(32 字节),实际:${String(hex).slice(0, 24)}…`);
261
+ }
262
+ return s;
263
+ }
264
+
265
+ function rawPubHexFromKey(key) {
266
+ // 传私钥(KeyObject 或 PEM/DER)也取到它配对的原始公钥:信任锚只能由私钥推得,
267
+ // 而 createPublicKey 只吃 key data,直接对私钥 export 会报 options.type invalid。
268
+ const k = key instanceof Uint8Array || typeof key === 'string' || key.type === 'private'
269
+ ? crypto.createPublicKey(key) : key;
270
+ return k.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex');
271
+ }
272
+
273
+ function publicKeyFromRawHex(hex) {
274
+ const raw = Buffer.from(normalizePubHex(hex), 'hex');
275
+ return crypto.createPublicKey({
276
+ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki',
277
+ });
278
+ }
279
+
280
+ function asPrivateKey(key) {
281
+ return typeof key === 'string' || key instanceof Uint8Array ? crypto.createPrivateKey(key) : key;
282
+ }
283
+
284
+ // ---- 每产物主密钥(D2)----
285
+
286
+ const KEYS_IGNORE_LINE = '.freedom/keys/';
287
+
288
+ // ensureKeysIgnored 把密钥目录写进项目 .gitignore。
289
+ // 「私钥勿入库」此前只是提示语——发布方一次 `git add -A` 就会把主密钥推上远端,
290
+ // 那等于把 D2 的全部收益清零,所以生成密钥时顺手补这一行(幂等)。
291
+ function ensureKeysIgnored(dir) {
292
+ const p = path.join(dir, '.gitignore');
293
+ let cur = '';
294
+ try {
295
+ cur = fs.readFileSync(p, 'utf8');
296
+ } catch (e) {
297
+ if (e.code !== 'ENOENT') throw e;
298
+ }
299
+ if (cur.split(/\r?\n/).some((l) => l.trim() === KEYS_IGNORE_LINE)) return false;
300
+ const head = cur === '' || cur.endsWith('\n') ? cur : cur + '\n';
301
+ fs.writeFileSync(p, head + '# Freedom 发布密钥(每产物主密钥 / ed25519 私钥),绝不入库\n' + KEYS_IGNORE_LINE + '\n', 'utf8');
302
+ return true;
303
+ }
304
+
305
+ // keysDir 是发布方秘密资产的唯一目录约定(与 KEYS_IGNORE_LINE 配对:整目录不入库)。
306
+ function keysDir(dir) {
307
+ return path.join(path.resolve(dir), '.freedom', 'keys');
308
+ }
309
+
310
+ // appNameFor 应用名的文件名清洗(与 build.js / verify.js 的 `name` 同一规则)。
311
+ // 密钥文件名必须按同一规则推导:keygen 拿到的是 freedom.config.js 里的原始 cfg.name,
312
+ // build 拿到的是清洗后的 name,两者若各自清洗不一致,high 构建就会"密钥明明在却找不到"。
313
+ function appNameFor(raw) {
314
+ return String(raw || 'freedom-app').replace(/[^a-zA-Z0-9_.-]/g, '-');
315
+ }
316
+
317
+ function productKeyPath(dir, appName) {
318
+ return path.join(keysDir(dir), appIdentityFor(appNameFor(appName)) + '.key');
319
+ }
320
+
321
+ // 发布方 ed25519 私钥(自更新清单与产物完整性清单共用一把)。
322
+ const UPDATE_KEY_BASENAME = 'update_ed25519';
323
+
324
+ function signingKeyPath(dir) {
325
+ return path.join(keysDir(dir), UPDATE_KEY_BASENAME);
326
+ }
327
+
328
+ // createProductKey 生成 32B 随机主密钥(hex 落盘)。已存在时除非 force 一律拒绝——
329
+ // 覆盖即等于让旧产物永久无法解密(发布方资产,丢了要能自己看出来)。
330
+ function createProductKey(dir, appName, opts = {}) {
331
+ const p = productKeyPath(dir, appName);
332
+ if (fs.existsSync(p) && !opts.force) {
333
+ throw new Error(`每产物主密钥已存在:${p}(覆盖会使既有产物全部失效,确需重生成请 --force)`);
334
+ }
335
+ const hex = crypto.randomBytes(PRODUCT_KEY_LEN).toString('hex');
336
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
337
+ ensureKeysIgnored(path.resolve(dir));
338
+ fs.writeFileSync(p, hex + '\n', { encoding: 'utf8', mode: 0o600 });
339
+ return hex;
340
+ }
341
+
342
+ // loadProductKey 读取并校验主密钥;缺失时给出可操作的错误(不静默退回全域常量主密钥——
343
+ // 那正是 FRDM2 的根因,退回即降级攻击面)。
344
+ function loadProductKey(dir, appName) {
345
+ const p = productKeyPath(dir, appName);
346
+ let raw;
347
+ try {
348
+ raw = fs.readFileSync(p, 'utf8');
349
+ } catch (e) {
350
+ if (e.code === 'ENOENT') {
351
+ throw new Error(`缺每产物主密钥:${p}(先运行 freedom keygen 生成,切勿丢失或入库)`);
352
+ }
353
+ throw e;
354
+ }
355
+ const hex = raw.trim().toLowerCase();
356
+ if (!/^[0-9a-f]{64}$/.test(hex)) {
357
+ throw new Error(`每产物主密钥格式非法:${p} 应为 64 位十六进制(32 字节)`);
358
+ }
359
+ return hex;
360
+ }
361
+
362
+ function productMasterBytes(master) {
363
+ if (Buffer.isBuffer(master)) return master;
364
+ return Buffer.from(String(master).trim(), 'hex'); // 长度由 deriveKeysV3 显式校验
365
+ }
366
+
367
+ // ---- Tier B 编译期注入(每产物壳)----
368
+ //
369
+ // high 模式在 Tier B 下不再复制预编译通用壳,而是为本应用现编一个专属壳。两样秘密输入
370
+ // 各有通道,通道不同是因为它们要防的对手不同:
371
+ //
372
+ // 信任锚(发布方 ed25519 公钥)—— 不需要保密,走 -ldflags -X 字符串变量即可。
373
+ // 每产物主密钥 —— 不能走 -X:单个字符串常量(哪怕掩码过)在 exe 里是一个可定位的整体,
374
+ // 而掩码算法在公开源里 ⇒ 一份脱壳器通吃所有产物(台账 B-20260925-060)。改走 keySlot
375
+ // 代码生成(见下),每次 high 构建现场产出一份只属于本产物的装配码。
376
+ //
377
+ // 符号路径与 Go 侧包级变量一一对应,改任一侧必须同步——这是 build 产物能否启动的硬绑定。
378
+ const SHELL_PKG = 'freedom-cli-shell/pkg/freedom';
379
+ const SHELL_VAR_ANCHOR = 'securityAnchorPubHex';
380
+
381
+ // shellInject 返回 buildShell 可直接消费的 -X 映射(完整符号路径 → 值)。
382
+ function shellInject(anchorPubHex) {
383
+ return { [`${SHELL_PKG}.${SHELL_VAR_ANCHOR}`]: normalizePubHex(anchorPubHex) };
384
+ }
385
+
386
+ // ---- 每产物多态密钥装配(keySlot,R7-乙)----
387
+ //
388
+ // 主密钥拆成 3~7 片,每片自选一种可逆变换与参数,装配循环的次序与声明次序都随机。
389
+ // 产出的 Go 源落进**一次性构建目录**(不入库、不发布),编进该产物专属壳后:
390
+ // · 静态扫描找不到连续的 32 字节密钥形态;
391
+ // · 更关键的是"如何拼回主密钥"这件事本身变成每产物一份代码——从 A 产物逆出的流程
392
+ // 对 B 产物无效,脱壳器无法一份通吃。
393
+ // 边界要说清楚:这抬的是**自动化与跨产物复用**的成本,挡不住肯为单个产物人工逆向的人
394
+ // (装配码本身在壳里、明文密钥运行期仍在内存)。口径见 SECURITY.md 与 README 加固上限。
395
+
396
+ const KEYSLOT_OPS = ['xor', 'add', 'sub', 'rol', 'posxor'];
397
+ const KEYSLOT_SHARD_MIN = 3;
398
+ const KEYSLOT_SHARD_MAX = 7;
399
+
400
+ // mulberry32:可播种的小 PRNG。播种的意义是"同一 seed 必得同一份装配码",
401
+ // 让测试能锁死生成器的输出形状;真实构建不传 seed,每次随机。
402
+ function mulberry32(seed) {
403
+ let a = seed >>> 0;
404
+ return function next() {
405
+ a = (a + 0x6d2b79f5) >>> 0;
406
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
407
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
408
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
409
+ };
410
+ }
411
+
412
+ function rotl8(b, k) {
413
+ const s = k & 7;
414
+ return ((b << s) | (b >>> (8 - s))) & 0xff;
415
+ }
416
+
417
+ // keySlotPlan 把主密钥切成 plan:{ shards: [{ op, p, positions, bytes(hex) }] }。
418
+ // positions[i] 与 bytes[i] 成对——存的就是"这一片落在主密钥的哪个位置、该位置存什么字节",
419
+ // 顺序本身随机,所以不需要额外的逆序标志。
420
+ function keySlotPlan(master, opts = {}) {
421
+ const raw = productMasterBytes(master);
422
+ if (raw.length !== PRODUCT_KEY_LEN) {
423
+ throw new Error(`每产物主密钥需 ${PRODUCT_KEY_LEN} 字节(64 位十六进制),实际 ${raw.length} 字节`);
424
+ }
425
+ const rng = typeof opts.seed === 'number' ? mulberry32(opts.seed) : null;
426
+ const rnd = rng || (() => crypto.randomBytes(4).readUInt32BE(0) / 0x100000000);
427
+ const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
428
+
429
+ const idx = Array.from({ length: raw.length }, (_, i) => i);
430
+ for (let i = idx.length - 1; i > 0; i--) {
431
+ const j = Math.floor(rnd() * (i + 1));
432
+ [idx[i], idx[j]] = [idx[j], idx[i]];
433
+ }
434
+ const shardCount = KEYSLOT_SHARD_MIN + Math.floor(rnd() * (KEYSLOT_SHARD_MAX - KEYSLOT_SHARD_MIN + 1));
435
+ // 切点不得重复:重复切点会产出空片,实际片数就可能掉到下限以下。
436
+ const cutSet = new Set();
437
+ while (cutSet.size < shardCount - 1) cutSet.add(1 + Math.floor(rnd() * (raw.length - 2)));
438
+ const cuts = [...cutSet].sort((a, b) => a - b);
439
+ const groups = [];
440
+ let prev = 0;
441
+ for (const c of cuts.concat(raw.length)) {
442
+ groups.push(idx.slice(prev, c));
443
+ prev = c;
444
+ }
445
+ const tag = Math.floor(rnd() * 0xffffff).toString(16).padStart(6, '0');
446
+ const shards = groups.map((positions, n) => {
447
+ const op = pick(KEYSLOT_OPS);
448
+ // posxor 的 a 取奇数,位置掩码才能在 0..255 上真正铺开;两侧都用非负字节,
449
+ // 渲染成 Go 字面量才是合法的 0xNN。
450
+ const param = op === 'posxor'
451
+ ? [1 + 2 * Math.floor(rnd() * 128), Math.floor(rnd() * 256)]
452
+ : [op === 'rol' ? 1 + Math.floor(rnd() * 7) : Math.floor(rnd() * 256), 0];
453
+ const bytes = Buffer.from(positions.map((p) => keySlotForward(op, raw[p], p, param)));
454
+ return { name: `k${tag}s${n}`, positions, op, param, bytes: bytes.toString('hex') };
455
+ });
456
+ const plan = { v: 1, tag, func: `ks${tag}`, len: raw.length, shards };
457
+ const back = assembleKeySlot(plan);
458
+ if (back.equals(raw) === false) {
459
+ // 装配自检不过就等于"编出来的壳永远跑不起来",必须在生成期当场炸,而不是让用户拿到坏产物。
460
+ throw new Error('internal: keySlot 装配自检失败(生成器与参考装配实现不一致)');
461
+ }
462
+ return plan;
463
+ }
464
+
465
+ function keySlotForward(op, b, pos, p) {
466
+ switch (op) {
467
+ case 'xor': return b ^ p[0];
468
+ case 'add': return (b + p[0]) & 0xff;
469
+ case 'sub': return (b - p[0]) & 0xff;
470
+ case 'rol': return rotl8(b, p[0]);
471
+ case 'posxor': return b ^ ((pos * p[0] + p[1]) & 0xff);
472
+ default: throw new Error(`internal: 未知 keySlot 变换 ${op}`);
473
+ }
474
+ }
475
+
476
+ // assembleKeySlot 是 plan 的参考装配实现(JS 侧,用于生成期自检与测试独立复算)。
477
+ // 运行期真正跑的是渲染出来的 Go 代码——两者由 tests/security-frdm3.test.mjs 的
478
+ // 「生成码能编译并跑出同一主密钥」一条跨语言锁死。
479
+ function assembleKeySlot(plan) {
480
+ const out = Buffer.alloc(plan.len);
481
+ for (const sh of plan.shards) {
482
+ const bytes = Buffer.from(sh.bytes, 'hex');
483
+ sh.positions.forEach((pos, i) => {
484
+ const b = bytes[i];
485
+ switch (sh.op) {
486
+ case 'xor': out[pos] = b ^ sh.param[0]; break;
487
+ case 'add': out[pos] = (b - sh.param[0]) & 0xff; break;
488
+ case 'sub': out[pos] = (b + sh.param[0]) & 0xff; break;
489
+ case 'rol': out[pos] = rotl8(b, 8 - sh.param[0]); break;
490
+ case 'posxor': out[pos] = b ^ ((pos * sh.param[0] + sh.param[1]) & 0xff); break;
491
+ default: throw new Error(`internal: 未知 keySlot 变换 ${sh.op}`);
492
+ }
493
+ });
494
+ }
495
+ return out;
496
+ }
497
+
498
+ // renderKeySlotGo 把 plan 渲染成 Go 源(package freedom,在 init() 里挂上壳侧钩子)。
499
+ function renderKeySlotGo(plan) {
500
+ const hex = (n) => `0x${n.toString(16).padStart(2, '0')}`;
501
+ const lit = (arr) => arr.map((x) => hex(x)).join(', ');
502
+ const vars = plan.shards.map((sh) =>
503
+ `\t${sh.name} = []byte{${lit(Buffer.from(sh.bytes, 'hex'))}}\n` +
504
+ `\t${sh.name}At = []byte{${lit(sh.positions)}}`
505
+ ).join('\n');
506
+ const loops = plan.shards.map((sh) => {
507
+ const [a, c] = sh.param;
508
+ let expr;
509
+ switch (sh.op) {
510
+ case 'xor': expr = `b ^ ${hex(a)}`; break;
511
+ case 'add': expr = `byte((int(b) - ${hex(a)}) & 0xff)`; break;
512
+ case 'sub': expr = `byte((int(b) + ${hex(a)}) & 0xff)`; break;
513
+ case 'rol': expr = `b>>${a} | b<<${8 - a}`; break;
514
+ case 'posxor': expr = `b ^ byte((int(p)*${hex(a)} + ${hex(c)}) & 0xff)`; break;
515
+ default: throw new Error(`internal: 未知 keySlot 变换 ${sh.op}`);
516
+ }
517
+ return (
518
+ `\tfor i, p := range ${sh.name}At {\n` +
519
+ `\t\tb := ${sh.name}[i]\n` +
520
+ `\t\tout[p] = ${expr}\n` +
521
+ `\t}`
522
+ );
523
+ }).join('\n');
524
+ return (
525
+ '// Code generated by freedom-cli (Tier B keySlot). DO NOT EDIT.\n' +
526
+ `// 本文件只属于这一次构建:分片数、分片内容、变换与参数均随机(tag ${plan.tag})。\n` +
527
+ '// 目的不是保密算法(算法在公开源里),而是让"从产物里拼回主密钥"这件事每产物一套代码,\n' +
528
+ '// 使针对某个产物得到的脱壳流程无法直接复用到下一个产物。边界见 SECURITY.md。\n' +
529
+ 'package freedom\n\n' +
530
+ `func init() { keySlotAssemble = ${plan.func} }\n\n` +
531
+ 'var (\n' + vars + '\n)\n\n' +
532
+ `// ${plan.func} 拼出 ${plan.len} 字节主密钥;长度由调用方 productMaster() 复核。\n` +
533
+ `func ${plan.func}() []byte {\n` +
534
+ `\tout := make([]byte, ${plan.len})\n` + loops + '\n' +
535
+ '\treturn out\n}\n'
536
+ );
537
+ }
538
+
539
+ // keySlotForBuild 产出本次构建的装配码:{ fileName, source, plan }。
540
+ // opts.seed 仅供测试复现;构建期不传,每次全新随机。
541
+ function keySlotForBuild(master, opts = {}) {
542
+ const plan = keySlotPlan(master, opts);
543
+ return { fileName: `keyslot_${plan.tag}.go`, source: renderKeySlotGo(plan), plan };
544
+ }
545
+
546
+
547
+ // ---- 容器密钥派生(v3)----
548
+
549
+ // deriveKeysV3 与 Go deriveSecurityKeyV3 同式:
550
+ // KEK = PBKDF2(每产物主密钥, DERIVE_SALT3:app + 容器随机盐, 600000, 32),macKey = HMAC(KEK, MAC_LABEL3)。
551
+ function deriveKeysV3(appName, salt, master) {
552
+ const secret = productMasterBytes(master);
553
+ if (secret.length !== PRODUCT_KEY_LEN) throw new Error('每产物主密钥需 32 字节(64 位十六进制)');
554
+ const base = Buffer.concat([
555
+ Buffer.from(DERIVE_SALT3 + ':' + appIdentityFor(appName), 'utf8'),
556
+ salt,
557
+ ]);
558
+ const enc = crypto.pbkdf2Sync(secret, base, PBKDF2_ITER, KEY_LEN, 'sha256');
559
+ const mac = crypto.createHmac('sha256', enc).update(MAC_LABEL3).digest();
560
+ return { enc, mac };
561
+ }
562
+
563
+ function encryptAppV3(master, name, html, configJSON, backend) {
564
+ return sealAppBin(APP_BIN_MAGIC3,
565
+ (salt) => deriveKeysV3(name, salt, master), appPayload(html, configJSON, backend));
566
+ }
567
+
568
+ function decryptAppV3(master, name, buf) {
569
+ return openAppBin(APP_BIN_MAGIC3, (salt) => deriveKeysV3(name, salt, master), buf);
570
+ }
571
+
572
+ // ---- 签名完整性清单(D1)----
573
+
574
+ // integrityClaims 是被签名的载荷字段;签名对象是这段字节的**确切序列化结果**
575
+ // (base64 原样存进清单,验签后按字段回比对真实值),跨语言不做 JSON 规范化。
576
+ function integrityV3Bytes(claims) {
577
+ return Buffer.from(JSON.stringify({
578
+ appBin: claims.appBin, identity: claims.identity,
579
+ salt: claims.salt, self: claims.self, built: claims.built,
580
+ }), 'utf8');
581
+ }
582
+
583
+ // signIntegrityV3 产出 .integrity v3:{ v, alg, pub, payload(base64), sig(hex) }。
584
+ // signingKey = 发布方 ed25519 私钥(PEM 字符串或 KeyObject),公钥由其导出并写入清单。
585
+ // selfHash 非空时壳启动即自校验 exe 摘要(Tier B);Tier A 通用壳传空串跳过。
586
+ function signIntegrityV3(opts) {
587
+ const { name, appBin, signingKey } = opts;
588
+ const { salt } = unpackAppBin(APP_BIN_MAGIC3, appBin);
589
+ const claims = {
590
+ appBin: sha256hex(appBin),
591
+ identity: appIdentityFor(name),
592
+ salt: salt.toString('hex'),
593
+ self: opts.selfHash || '',
594
+ built: opts.built || new Date().toISOString(),
595
+ };
596
+ const bytes = integrityV3Bytes(claims);
597
+ const priv = asPrivateKey(signingKey);
598
+ const sig = crypto.sign(null, bytes, priv).toString('hex');
599
+ return {
600
+ v: 3,
601
+ alg: 'ed25519',
602
+ pub: rawPubHexFromKey(crypto.createPublicKey(priv)),
603
+ payload: bytes.toString('base64'),
604
+ sig,
605
+ };
606
+ }
607
+
608
+ // renderManifestV3 是清单的磁盘序列化(与 Go encoding/json 输出逐字节一致,
609
+ // 两侧产物可互换校验;键序即 signIntegrityV3 的返回对象键序)。
610
+ function renderManifestV3(manifest) {
611
+ return JSON.stringify(manifest, null, 2) + '\n';
612
+ }
613
+
614
+ // verifyIntegrityV3 用**信任锚公钥**校验清单,并把声明值与真实输入逐项比对。
615
+ // 先比锚再验签:否则攻击者换一对钥匙即可自证合法(清单里的 pub 不可自证)。
616
+ // 通过返回声明对象,失败抛错(调用方据此拒绝运行)。
617
+ function verifyIntegrityV3(opts) {
618
+ const { manifest, anchorPubHex, name, appBin } = opts;
619
+ if (!manifest || manifest.v !== 3 || manifest.alg !== 'ed25519') {
620
+ throw new Error('完整性清单版本不受支持(要求 v3 / ed25519):请用与壳同版的 freedom-cli 重新 build');
621
+ }
622
+ const anchor = normalizePubHex(anchorPubHex);
623
+ if (normalizePubHex(manifest.pub) !== anchor) {
624
+ throw new Error('.integrity 公钥与信任锚不一致:产物可能被换钥匙重签');
625
+ }
626
+ const bytes = Buffer.from(String(manifest.payload), 'base64');
627
+ const sig = Buffer.from(String(manifest.sig), 'hex');
628
+ if (sig.length !== 64) throw new Error('.integrity 签名长度非法(ed25519 需 64 字节)');
629
+ if (!crypto.verify(null, bytes, publicKeyFromRawHex(anchor), sig)) {
630
+ throw new Error('.integrity 签名校验失败:清单或 app.bin 被篡改');
631
+ }
632
+ let claims;
633
+ try {
634
+ claims = JSON.parse(bytes.toString('utf8'));
635
+ } catch {
636
+ throw new Error('.integrity 签名载荷不是合法 JSON');
637
+ }
638
+ const { salt } = unpackAppBin(APP_BIN_MAGIC3, appBin);
639
+ if (claims.appBin !== sha256hex(appBin)) {
640
+ throw new Error('.integrity 校验失败:app.bin 与签名清单不符');
641
+ }
642
+ if (claims.identity !== appIdentityFor(name)) {
643
+ throw new Error(`.integrity 校验失败:清单绑定身份 ${claims.identity},当前 exe 是 ${appIdentityFor(name)}(被重命名或跨应用复用)`);
644
+ }
645
+ if (claims.salt !== salt.toString('hex')) {
646
+ throw new Error('.integrity 校验失败:清单容器盐与 app.bin 头部不符(清单与容器非同一产物)');
647
+ }
648
+ return claims;
649
+ }
650
+
193
651
  module.exports = {
194
652
  SECURITY_MODES,
195
653
  APP_BIN_MAGIC,
@@ -198,6 +656,7 @@ module.exports = {
198
656
  parseSecurity,
199
657
  resolveSecurity,
200
658
  appIdentityFor,
659
+ appNameFor,
201
660
  masterSecret,
202
661
  deriveKeys,
203
662
  encryptApp,
@@ -206,4 +665,25 @@ module.exports = {
206
665
  isSafeRelPath,
207
666
  buildIntegrity,
208
667
  renderIntegrity,
668
+ // FRDM3
669
+ APP_BIN_MAGIC3,
670
+ ensureKeysIgnored,
671
+ keysDir,
672
+ signingKeyPath,
673
+ productKeyPath,
674
+ createProductKey,
675
+ loadProductKey,
676
+ deriveKeysV3,
677
+ encryptAppV3,
678
+ decryptAppV3,
679
+ signIntegrityV3,
680
+ renderManifestV3,
681
+ verifyIntegrityV3,
682
+ rawPubHexFromKey,
683
+ sha256hex,
684
+ keySlotForBuild,
685
+ assembleKeySlot,
686
+ shellInject,
687
+ SHELL_PKG,
688
+ SHELL_VAR_ANCHOR,
209
689
  };