@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 +32 -9
- package/lib/build.js +98 -36
- package/lib/cli.js +6 -2
- package/lib/release.js +22 -6
- package/lib/security.js +525 -45
- package/lib/shell.js +129 -9
- package/lib/verify.js +80 -26
- package/package.json +3 -2
- package/shell/darwin-arm64/freedom-shell +0 -0
- package/shell/linux-x64/freedom-shell +0 -0
- package/shell/win-x64/freedom-shell.exe +0 -0
- package/templates/go/pkg/freedom/freedom.go +4 -1
- package/templates/go/pkg/freedom/resources.go +7 -0
- package/templates/go/pkg/freedom/security.go +423 -39
|
@@ -12,19 +12,27 @@ package freedom
|
|
|
12
12
|
// - 应用标识 = exe 文件名去扩展名(CLI build 时的 name 与运行时 os.Executable() 一致)
|
|
13
13
|
//
|
|
14
14
|
// FRDM2 相对 FRDM1 的四点加强(目标是把"打开产物就能读源码"抬到"必须逆向壳 +
|
|
15
|
-
// 运行时取密钥"
|
|
15
|
+
// 运行时取密钥")——**该目标未达成,实测边界比这低得多,见下"当前真实边界"**:
|
|
16
16
|
// 1. 主密钥不以明文常量存在(掩码字节表 + 运行时还原),strings 扫不到完整密钥;
|
|
17
17
|
// 2. 派生盐加入构建期随机 16B(每个产物不同),PBKDF2 迭代 60000 → 600000;
|
|
18
18
|
// 3. 加密密钥与认证密钥域分离;认证标签覆盖 magic+salt+iv+密文
|
|
19
19
|
// (CTR 下改 iv 即明文可预测翻转,头部不认证等于留了解密 oracle);
|
|
20
20
|
// 4. 后端源码并入容器(backend 字段),磁盘上不再有 resources/backend 明文目录。
|
|
21
21
|
//
|
|
22
|
+
// 当前真实边界(2026-09-25 取证,台账 B-20260925-054):主密钥常量连同解密器
|
|
23
|
+
// (lib/security.js 的 MASTER_KEY_CIPHER / decryptApp / buildIntegrity)一起随 npm 包分发,
|
|
24
|
+
// 而派生钥的另外两个输入(应用标识 = exe 文件名、派生盐 = 容器头 16B)都在产物自己身上,
|
|
25
|
+
// 所以持有 CLI 者无需逆向即可解密任意产物、并为改造后的内容签出合法 .integrity。
|
|
26
|
+
// 上述 1–4 仍然有意义(挡住静态扫描与明文直读、挡住无 CLI 的随手篡改),
|
|
27
|
+
// 但不得再对外表述为"必须逆向壳才拿得到"。结构性修复方向见
|
|
28
|
+
// .liangzu/plans/2026-09-25-r6-defense-max.md:清单改发布方 ed25519 签名 + 主密钥移出公开源。
|
|
29
|
+
//
|
|
22
30
|
// 旧版 FRDM1 容器一律拒绝并提示重新 build,不做静默降级(静默回退即降级攻击面)。
|
|
23
31
|
//
|
|
24
32
|
// high 模式产物布局(CLI build 写入,与明文模式互斥):
|
|
25
|
-
// resources/app.bin
|
|
33
|
+
// resources/app.bin FRDM3 容器:magic(5)+salt(16)+iv(16)+tag(16)+ciphertext
|
|
26
34
|
// 解密载荷 JSON:{"html","config","backend":{"<rel>":{"d","m"}}}
|
|
27
|
-
// resources/.integrity
|
|
35
|
+
// resources/.integrity 发布方 ed25519 私钥签名的清单(v3),壳只认编译期内嵌的信任锚
|
|
28
36
|
// 壳启动时在内存解密,磁盘无明文;exe 被重命名/资源被篡改 → 解密失败即拒绝运行。
|
|
29
37
|
// (不用 GCM:曾在 Windows 环境出现标准库 GHASH 确定性认证失败,CTR+HMAC 语义等价
|
|
30
38
|
// 且规避该问题。)
|
|
@@ -32,13 +40,16 @@ package freedom
|
|
|
32
40
|
import (
|
|
33
41
|
"crypto/aes"
|
|
34
42
|
"crypto/cipher"
|
|
43
|
+
"crypto/ed25519"
|
|
35
44
|
"crypto/hmac"
|
|
36
45
|
"crypto/sha256"
|
|
46
|
+
"encoding/base64"
|
|
37
47
|
"encoding/binary"
|
|
38
48
|
"encoding/hex"
|
|
39
49
|
"encoding/json"
|
|
40
50
|
"errors"
|
|
41
51
|
"fmt"
|
|
52
|
+
"io"
|
|
42
53
|
"os"
|
|
43
54
|
"path/filepath"
|
|
44
55
|
"strings"
|
|
@@ -175,6 +186,13 @@ type secureKey struct {
|
|
|
175
186
|
mac []byte // HMAC-SHA256 密钥(KEK 经固定标签拉伸)
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
// clear 抹零派生钥。用完即擦是 FRDM3 运行期的硬要求:丢弃引用不等于销毁,
|
|
190
|
+
// Go 的分配器会把那片内存直接给下一个对象用(见 clearBytes)。
|
|
191
|
+
func (k secureKey) clear() {
|
|
192
|
+
clearBytes(k.enc)
|
|
193
|
+
clearBytes(k.mac)
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
// deriveSecurityKey 派生容器密钥。盐 = 固定前缀 + 应用标识 + 容器内构建期随机盐,
|
|
179
197
|
// 故不同应用密钥不同、同一应用每次 build 密钥也不同,仅拿到主密钥常量不足以复现。
|
|
180
198
|
//
|
|
@@ -214,26 +232,13 @@ var (
|
|
|
214
232
|
secureKeyCache = make(map[string]secureKey)
|
|
215
233
|
)
|
|
216
234
|
|
|
217
|
-
//
|
|
235
|
+
// secureDecryptCount 统计容器实际解密次数(单测缝:断言"同一容器一份产物只解一次")。
|
|
236
|
+
var secureDecryptCount int
|
|
237
|
+
|
|
238
|
+
// splitAppBin 校验并拆分容器头(FRDM2 代际)。magic 不符(含旧版 FRDM1)时给出可操作错误,
|
|
218
239
|
// 绝不回退明文路径。
|
|
219
240
|
func splitAppBin(data []byte) (salt, iv, tag, ct []byte, err error) {
|
|
220
|
-
|
|
221
|
-
return nil, nil, nil, nil, errors.New("app.bin 长度不足容器头")
|
|
222
|
-
}
|
|
223
|
-
if string(data[:len(securityMagic)]) != securityMagic {
|
|
224
|
-
return nil, nil, nil, nil, fmt.Errorf(
|
|
225
|
-
"app.bin 容器版本不受支持(头为 %q,本壳要求 %q):请用与壳同版的 freedom-cli 重新 build",
|
|
226
|
-
string(data[:len(securityMagic)]), securityMagic)
|
|
227
|
-
}
|
|
228
|
-
off := len(securityMagic)
|
|
229
|
-
salt = data[off : off+securitySaltLen]
|
|
230
|
-
off += securitySaltLen
|
|
231
|
-
iv = data[off : off+securityIVLen]
|
|
232
|
-
off += securityIVLen
|
|
233
|
-
tag = data[off : off+securityTagLen]
|
|
234
|
-
off += securityTagLen
|
|
235
|
-
ct = data[off:]
|
|
236
|
-
return salt, iv, tag, ct, nil
|
|
241
|
+
return splitAppBinAt(securityMagic, data)
|
|
237
242
|
}
|
|
238
243
|
|
|
239
244
|
// appBinAuthLen 是参与认证的头部长度:magic + salt + iv(不含 tag 自身)。
|
|
@@ -271,15 +276,9 @@ func decryptAppBin(appName string, data []byte) (*securePayload, error) {
|
|
|
271
276
|
if err := json.Unmarshal(plain, &p); err != nil {
|
|
272
277
|
return nil, fmt.Errorf("app.bin 载荷无效:%w", err)
|
|
273
278
|
}
|
|
274
|
-
// 与打包端 lib/security.js
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return nil, errors.New("app.bin 载荷结构无效:缺少 html/config 字段")
|
|
278
|
-
}
|
|
279
|
-
for rel := range p.Backend {
|
|
280
|
-
if !isSafeRelPath(rel) {
|
|
281
|
-
return nil, fmt.Errorf("app.bin 后端路径非法:%q", rel)
|
|
282
|
-
}
|
|
279
|
+
// 与打包端 lib/security.js 同一道门:认证通过不等于结构可用(见 validateSecurePayload)。
|
|
280
|
+
if err := validateSecurePayload(&p); err != nil {
|
|
281
|
+
return nil, err
|
|
283
282
|
}
|
|
284
283
|
return &p, nil
|
|
285
284
|
}
|
|
@@ -382,8 +381,13 @@ func (a *App) antiDebugEnabled() bool {
|
|
|
382
381
|
// 返回 (载荷, 是否命中, 错误):resources/app.bin 不存在时命中=false(非 high 产物),
|
|
383
382
|
// 命中但解密/校验失败时返回错误(拒绝静默回退明文,避免降级攻击)。
|
|
384
383
|
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
384
|
+
// 自 FRDM3 起本函数只接受 FRDM3 容器,旧代际(FRDM1/FRDM2)一律拒绝。这不是兼容性取舍
|
|
385
|
+
// 而是 v3 强度的前提:v2 的主密钥与 HMAC 清单钥连同解密器一起随 npm 包公开
|
|
386
|
+
// (台账 B-20260925-054),任何持 CLI 者都能造出「合法」的 v2 容器与清单。若本壳仍认 v2,
|
|
387
|
+
// 攻击者把 app.bin 换成 v2 即完成降级,签名清单这道门等于白建。
|
|
388
|
+
//
|
|
389
|
+
// 每次调用都重读清单并跑完整验签链(容器被替换、锚被换掉、exe 被补缀都当场失效),
|
|
390
|
+
// 只有"派生 + 解密"这一步的结果按容器哈希缓存——一份产物在进程内只解一次。
|
|
387
391
|
func loadSecureResources() (*securePayload, bool, error) {
|
|
388
392
|
dir, err := resourcesDir()
|
|
389
393
|
if err != nil {
|
|
@@ -400,18 +404,135 @@ func loadSecureResources() (*securePayload, bool, error) {
|
|
|
400
404
|
if err != nil {
|
|
401
405
|
return nil, false, err
|
|
402
406
|
}
|
|
403
|
-
|
|
407
|
+
if len(data) >= len(securityMagic) && string(data[:len(securityMagic)]) == securityMagic {
|
|
408
|
+
return nil, true, errors.New("app.bin 是 FRDM2 旧代际容器,本壳不再接受(其主密钥与清单密钥都在公开源里,可被任意伪造):" +
|
|
409
|
+
"请用 freedom-cli 1.14.0-preview 及以上重新 freedom build" +
|
|
410
|
+
"(预览代走 preview 通道:npm i -D @yufengtadian/freedom-cli@preview;" +
|
|
411
|
+
"high 模式需本机 Go 工具链编译本应用专属壳)")
|
|
412
|
+
}
|
|
413
|
+
p, err := loadSecureResourcesV3(dir, name, data)
|
|
404
414
|
if err != nil {
|
|
405
|
-
return nil,
|
|
415
|
+
return nil, true, err
|
|
406
416
|
}
|
|
407
|
-
|
|
408
|
-
|
|
417
|
+
return p, true, nil
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// loadSecureResourcesV3 是 FRDM3 的启动校验链:注入的每产物主密钥 → 信任锚验签清单 →
|
|
421
|
+
// exe 自身哈希 → 容器认证解密。
|
|
422
|
+
//
|
|
423
|
+
// 顺序刻意是「先验签、后解密」:清单是这份产物经发布方私钥背书的外部绑定,
|
|
424
|
+
// 先解密等于把 PBKDF2 与 AES 花在未经背书的内容上(且验签失败时解出的明文已进过内存)。
|
|
425
|
+
//
|
|
426
|
+
// 配置与页面两处加载共用一份载荷(securePayloadCache),于是堆内只有一份明文、
|
|
427
|
+
// 启动路径上只跑一次 PBKDF2(实测 ~180ms)。缓存只跳过"派生 + 解密"这一段:
|
|
428
|
+
// 验签链每次照跑,因为它的结论还依赖清单与 exe 字节,不是容器哈希能代表的。
|
|
429
|
+
var (
|
|
430
|
+
securePayloadMu sync.Mutex
|
|
431
|
+
securePayloadCache = map[string]*securePayload{}
|
|
432
|
+
// securePayloadCacheMax 是防御性上限:一个进程正常只服务一份产物。
|
|
433
|
+
securePayloadCacheMax = 4
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
// securePayloadKey 绑的是"这次解密用掉的秘密输入":应用标识(进派生)+ 容器字节哈希
|
|
437
|
+
// (盐与密文都在里面)。两份都通过校验、内容却不同的容器绝不能共用一份载荷。
|
|
438
|
+
// 信任锚不进键是有意为之:锚变了由前面那道验签拒绝,轮不到缓存说话——验签链一次都不省。
|
|
439
|
+
func securePayloadKey(identity string, container []byte) string {
|
|
440
|
+
sum := sha256.Sum256(container)
|
|
441
|
+
return appIdentityName(identity) + "\x00" + hex.EncodeToString(sum[:])
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
func lookupSecurePayload(key string) (*securePayload, bool) {
|
|
445
|
+
securePayloadMu.Lock()
|
|
446
|
+
defer securePayloadMu.Unlock()
|
|
447
|
+
p, ok := securePayloadCache[key]
|
|
448
|
+
return p, ok
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
func storeSecurePayload(key string, p *securePayload) {
|
|
452
|
+
securePayloadMu.Lock()
|
|
453
|
+
defer securePayloadMu.Unlock()
|
|
454
|
+
if len(securePayloadCache) >= securePayloadCacheMax {
|
|
455
|
+
securePayloadCache = map[string]*securePayload{}
|
|
409
456
|
}
|
|
410
|
-
|
|
457
|
+
securePayloadCache[key] = p
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
func loadSecureResourcesV3(dir, name string, data []byte) (*securePayload, error) {
|
|
461
|
+
master, ok := productMaster()
|
|
462
|
+
if !ok {
|
|
463
|
+
return nil, errors.New("FRDM3 产物需本应用专属壳(每产物主密钥未注入本 exe):high 模式要求在本机用 Go 工具链" +
|
|
464
|
+
"编译壳(freedom build / freedom shell build),预编译通用壳不支持;请重新 freedom build,或改用 --security basic")
|
|
465
|
+
}
|
|
466
|
+
defer clearBytes(master) // 派生完成后这份明文就不再需要了,留着等于给内存扫描留靶子
|
|
467
|
+
if securityAnchorPubHex == "" {
|
|
468
|
+
return nil, errors.New("FRDM3 产物需本应用专属壳(信任锚公钥未注入本 exe):请重新 freedom build")
|
|
469
|
+
}
|
|
470
|
+
raw, err := os.ReadFile(filepath.Join(dir, ".integrity"))
|
|
411
471
|
if err != nil {
|
|
412
|
-
|
|
472
|
+
if errors.Is(err, os.ErrNotExist) {
|
|
473
|
+
return nil, errors.New("resources/.integrity 缺失:app.bin 未经签名清单绑定,产物可能被整体替换(请重新 freedom build)")
|
|
474
|
+
}
|
|
475
|
+
return nil, err
|
|
413
476
|
}
|
|
414
|
-
|
|
477
|
+
claims, err := verifyIntegrityManifest(securityAnchorPubHex, raw, name, data)
|
|
478
|
+
if err != nil {
|
|
479
|
+
return nil, err
|
|
480
|
+
}
|
|
481
|
+
if err := verifySelfHash(claims.Self); err != nil {
|
|
482
|
+
return nil, err
|
|
483
|
+
}
|
|
484
|
+
// 到这里本次请求的验签结论已经成立,缓存才有资格接手(跳过派生+解密)。
|
|
485
|
+
key := securePayloadKey(name, data)
|
|
486
|
+
if p, ok := lookupSecurePayload(key); ok {
|
|
487
|
+
return p, nil
|
|
488
|
+
}
|
|
489
|
+
p, err := decryptAppBinV3(master, name, data)
|
|
490
|
+
if err != nil {
|
|
491
|
+
return nil, err
|
|
492
|
+
}
|
|
493
|
+
storeSecurePayload(key, p)
|
|
494
|
+
return p, nil
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// verifySelfHash 用清单里发布方签下的 exe 哈希自校验。
|
|
498
|
+
//
|
|
499
|
+
// 挡住的是「换个壳来加载这套 resources」:应用标识(exe 文件名)与容器盐都在产物里,
|
|
500
|
+
// 只绑它们的清单仍可被挪到另一个同名 exe 上(含把校验分支补掉的补丁壳)。exe 字节哈希
|
|
501
|
+
// 一变即拒。空串表示构建期未绑定(Tier A 通用壳没有可声明的自身摘要),跳过。
|
|
502
|
+
//
|
|
503
|
+
// 诚实边界:exe 若在产品化之后被改写(典型是补做 Authenticode 签名,它会改 PE 证书表),
|
|
504
|
+
// 本检查会拒启动——必须在签名后重新 freedom build,或由 CLI 支持签名后再签清单(尚未实现)。
|
|
505
|
+
func verifySelfHash(want string) error {
|
|
506
|
+
if want == "" {
|
|
507
|
+
return nil
|
|
508
|
+
}
|
|
509
|
+
exe, err := os.Executable()
|
|
510
|
+
if err != nil {
|
|
511
|
+
return fmt.Errorf("无法定位 exe,self 自校验无法完成:%w", err)
|
|
512
|
+
}
|
|
513
|
+
got, err := hashFileHex(exe)
|
|
514
|
+
if err != nil {
|
|
515
|
+
return fmt.Errorf("无法读取 exe 以校验 self:%w", err)
|
|
516
|
+
}
|
|
517
|
+
if !hmac.Equal([]byte(strings.ToLower(want)), []byte(got)) {
|
|
518
|
+
return fmt.Errorf("exe 本体与签名清单不符(清单 %s…,实际 %s…):壳被替换或被补缀,或产物在 build 之后被改写过",
|
|
519
|
+
want[:sha256HexShort], got[:sha256HexShort])
|
|
520
|
+
}
|
|
521
|
+
return nil
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// hashFileHex 流式 sha256 → hex。exe 可达数十 MB,整读进内存会白白抬高启动峰值。
|
|
525
|
+
func hashFileHex(p string) (string, error) {
|
|
526
|
+
f, err := os.Open(p)
|
|
527
|
+
if err != nil {
|
|
528
|
+
return "", err
|
|
529
|
+
}
|
|
530
|
+
defer f.Close()
|
|
531
|
+
h := sha256.New()
|
|
532
|
+
if _, err := io.Copy(h, f); err != nil {
|
|
533
|
+
return "", err
|
|
534
|
+
}
|
|
535
|
+
return hex.EncodeToString(h.Sum(nil)), nil
|
|
415
536
|
}
|
|
416
537
|
|
|
417
538
|
// materializeSecureBackend 把容器内的后端源码解密写入进程私有临时目录(0700),
|
|
@@ -461,3 +582,266 @@ func materializeSecureBackend(files map[string]secureFile) (string, error) {
|
|
|
461
582
|
}
|
|
462
583
|
return dir, nil
|
|
463
584
|
}
|
|
585
|
+
|
|
586
|
+
// scrubSecureBackendPayload 在源文已写入私有临时目录后,抹掉缓存载荷里的后端源码明文字节。
|
|
587
|
+
// 后端源码是产物里价值最高的那份,也是这里唯一"擦得掉"的那份——HTML/Config 是 Go string
|
|
588
|
+
// 且必须交给 webview,明文常驻是逻辑必然(强度分层见 freedom-cli/README「加固上限说明」)。
|
|
589
|
+
// 置 nil 之后没有别的读者(唯一的消费者就是上面的物化),运行期真正执行的是临时目录里那份。
|
|
590
|
+
func scrubSecureBackendPayload(p *securePayload) {
|
|
591
|
+
for rel, f := range p.Backend {
|
|
592
|
+
clearBytes(f.Data)
|
|
593
|
+
p.Backend[rel] = secureFile{Mode: f.Mode}
|
|
594
|
+
}
|
|
595
|
+
p.Backend = nil
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ==================== FRDM3:每产物主密钥 + ed25519 签名清单 ====================
|
|
599
|
+
//
|
|
600
|
+
// 契约权威:.liangzu/plans/r6-defense-max/frdm3-contract.md;与 freedom-cli lib/security.js
|
|
601
|
+
// 的 v3 段跨语言同步(改任一侧必须同步另一侧并重生成 tests/fixtures/frdm3-golden.json):
|
|
602
|
+
// securityDeriveSalt3 / securityMacLabel3 / securityMagic3
|
|
603
|
+
// KEK = PBKDF2(password=每产物 32B 主密钥, salt=v3 前缀+":"+应用标识+容器盐, 600000, 32)
|
|
604
|
+
// macKey = HMAC-SHA256(KEK, securityMacLabel3);容器布局与 FRDM2 相同(仅 magic 换代)
|
|
605
|
+
// 清单签名对象是 payload 字段 base64 解码后的**那串字节**(不做 JSON 重规范化,
|
|
606
|
+
// 两侧序列化差异是这一类同步里最难查的红→绿假失败源)
|
|
607
|
+
//
|
|
608
|
+
// 两代际的密钥来源不同,强度就不同:v2 的主密钥编译在通用壳与 npm 包里(人人可得),
|
|
609
|
+
// v3 的每产物主密钥只在发布方仓库(.freedom/keys/<app>.key),完整性由发布方 ed25519
|
|
610
|
+
// 私钥签名、公钥作信任锚。故本代际的验签必须先比锚、再验签——清单自带的 pub 不可自证。
|
|
611
|
+
|
|
612
|
+
const (
|
|
613
|
+
securityDeriveSalt3 = "freedom:derive:v3"
|
|
614
|
+
securityMacLabel3 = "freedom:mac:v3"
|
|
615
|
+
securityMagic3 = "FRDM3"
|
|
616
|
+
// 每产物主密钥长度(发布方仓库里以 64 位十六进制文本存放)。
|
|
617
|
+
securityProductKeyLen = 32
|
|
618
|
+
// sha256HexShort 是错误消息里展示哈希前缀的长度(完整哈希无隐私价值,前缀足够定位)。
|
|
619
|
+
sha256HexShort = 8
|
|
620
|
+
// sha256HexLen 是清单里 self / appBin 哈希的合法文本长度。
|
|
621
|
+
sha256HexLen = 64
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
// ---- Tier B 编译期注入(CLI 用 -ldflags "-X freedom-cli-shell/pkg/freedom.<var>=<值>" 写入)----
|
|
625
|
+
//
|
|
626
|
+
// FRDM3 的两样秘密输入各有各的通道,通道不同是要防的对手不同:
|
|
627
|
+
//
|
|
628
|
+
// securityAnchorPubHex —— 发布方 ed25519 公钥(信任锚,hex32),验 .integrity 只认它。
|
|
629
|
+
// 它本就不需要保密,故留在 -X。
|
|
630
|
+
// keySlotAssemble —— 每产物主密钥的**装配函数**。R7-乙(台账 B-20260925-060)之前它是
|
|
631
|
+
// 一个 -X 注入的 hex 串加一条公开固定掩码(out[i] ^= (i*7+0x5A))——那份掩码在 npm 包
|
|
632
|
+
// 与仓库里都读得到,等于"写一次脱壳器、所有 Freedom 产物通吃"。现在改由 CLI 每次 high
|
|
633
|
+
// 构建在一次性目录里代码生成 keyslot_<tag>.go(分片数/内容/变换/参数全随机)并在 init()
|
|
634
|
+
// 里赋值给本变量:静态提取的成果只对提取自的那个产物有效。
|
|
635
|
+
// 注意抬的是**自动化与复用**成本,不是绝对强度:装配码仍在同一个壳里,肯为单个产物人工
|
|
636
|
+
// 逆向的人照样拼得出来(边界见 SECURITY.md 与 README 的加固上限说明)。
|
|
637
|
+
//
|
|
638
|
+
// 通用预编译壳(Tier A)既没有 -X 注入的锚,也没有那份生成码(keySlotAssemble 恒为 nil):
|
|
639
|
+
// 它既解不开任何 FRDM3 产物,也没有产物外的锚可验签,故 high 模式在 CLI 侧就要求自编译壳
|
|
640
|
+
// (见 freedom-cli lib/build.js)。
|
|
641
|
+
|
|
642
|
+
var (
|
|
643
|
+
securityAnchorPubHex = ""
|
|
644
|
+
// keySlotAssemble 由本应用的生成码在 init() 里赋值;nil 即"这只壳没有主密钥来源"。
|
|
645
|
+
keySlotAssemble func() []byte
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
// productMaster 取出本产物主密钥。装配函数缺失(Tier A)或产出长度不对(生成码被改坏、
|
|
649
|
+
// 生成器与壳版本不匹配)一律 ok=false——非法值绝不退化成"拿空字节当密钥继续解密",
|
|
650
|
+
// 那会派生出一个人人可复现的密钥,比拒跑糟得多。
|
|
651
|
+
func productMaster() ([]byte, bool) {
|
|
652
|
+
if keySlotAssemble == nil {
|
|
653
|
+
return nil, false
|
|
654
|
+
}
|
|
655
|
+
master := keySlotAssemble()
|
|
656
|
+
// 全零主密钥是"生成码被改坏/装配链断掉"的典型形态:长度合规却等于没有密钥,
|
|
657
|
+
// 拿它派生会得出一个人人可复现的 KEK。宁可拒跑,不能静默用一把公开密钥加密出厂。
|
|
658
|
+
if len(master) != securityProductKeyLen || isZeroBytes(master) {
|
|
659
|
+
clearBytes(master)
|
|
660
|
+
return nil, false
|
|
661
|
+
}
|
|
662
|
+
return master, true
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
func isZeroBytes(b []byte) bool {
|
|
666
|
+
for _, x := range b {
|
|
667
|
+
if x != 0 {
|
|
668
|
+
return false
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return true
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// deriveSecurityKeyV3 用每产物主密钥派生容器密钥(PBKDF2 60 万次,实测约 180ms)。
|
|
675
|
+
//
|
|
676
|
+
// 刻意**不写入 secureKeyCache**(那张表只服务 FRDM2 黄金向量测试):派生钥一旦跨调用驻留,
|
|
677
|
+
// 攻击者只要有一次内存取样拿到 KEK,就能对磁盘上的 app.bin 离线全量解密——salt/iv 都在容器头,
|
|
678
|
+
// 除 KEK 外没有第二个秘密输入。驻留把"内存 dump 只能看当下明文"升级成"永久解密能力",
|
|
679
|
+
// 这不是省一次 PBKDF2 的价钱。一份产物只解密一次(见 securePayloadCache),不缓存也不重复付费。
|
|
680
|
+
func deriveSecurityKeyV3(appName string, salt, master []byte) (secureKey, error) {
|
|
681
|
+
if len(master) != securityProductKeyLen {
|
|
682
|
+
return secureKey{}, fmt.Errorf("每产物主密钥需 %d 字节,实际 %d", securityProductKeyLen, len(master))
|
|
683
|
+
}
|
|
684
|
+
base := append([]byte(securityDeriveSalt3+":"+appIdentityName(appName)), salt...)
|
|
685
|
+
kek := pbkdf2HMACSHA256(master, base, securityPbkdf2Iter, securityKeyLen)
|
|
686
|
+
m := hmac.New(sha256.New, kek)
|
|
687
|
+
m.Write([]byte(securityMacLabel3))
|
|
688
|
+
return secureKey{enc: kek, mac: m.Sum(nil)}, nil
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// splitAppBinAt 校验指定代际的 magic 并拆分容器头。magic 不符(含旧代)时给出
|
|
692
|
+
// 可操作错误,绝不回退明文路径。两代 magic 等长(5B),故 appBinTag 无需参数化。
|
|
693
|
+
func splitAppBinAt(magic string, data []byte) (salt, iv, tag, ct []byte, err error) {
|
|
694
|
+
if len(data) < appBinHeaderLen {
|
|
695
|
+
return nil, nil, nil, nil, errors.New("app.bin 长度不足容器头")
|
|
696
|
+
}
|
|
697
|
+
if string(data[:len(magic)]) != magic {
|
|
698
|
+
return nil, nil, nil, nil, fmt.Errorf(
|
|
699
|
+
"app.bin 容器版本不受支持(头为 %q,本壳要求 %q):请用与壳同版的 freedom-cli 重新 build",
|
|
700
|
+
string(data[:len(magic)]), magic)
|
|
701
|
+
}
|
|
702
|
+
off := len(magic)
|
|
703
|
+
salt = data[off : off+securitySaltLen]
|
|
704
|
+
off += securitySaltLen
|
|
705
|
+
iv = data[off : off+securityIVLen]
|
|
706
|
+
off += securityIVLen
|
|
707
|
+
tag = data[off : off+securityTagLen]
|
|
708
|
+
off += securityTagLen
|
|
709
|
+
ct = data[off:]
|
|
710
|
+
return salt, iv, tag, ct, nil
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// validateSecurePayload 是解密后的结构门:认证通过不等于内容可用。
|
|
714
|
+
// 少了 html/config 会让上层回落到内置占位页(静默降级),后端路径越界则危及落盘。
|
|
715
|
+
func validateSecurePayload(p *securePayload) error {
|
|
716
|
+
if p.HTML == "" || p.Config == "" {
|
|
717
|
+
return errors.New("app.bin 载荷结构无效:缺少 html/config 字段")
|
|
718
|
+
}
|
|
719
|
+
for rel := range p.Backend {
|
|
720
|
+
if !isSafeRelPath(rel) {
|
|
721
|
+
return fmt.Errorf("app.bin 后端路径非法:%q", rel)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return nil
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// decryptAppBinV3 解密 FRDM3 容器(每产物主密钥)。先恒定时间认证、再解密、再查结构。
|
|
728
|
+
func decryptAppBinV3(master []byte, appName string, data []byte) (*securePayload, error) {
|
|
729
|
+
salt, iv, tag, ct, err := splitAppBinAt(securityMagic3, data)
|
|
730
|
+
if err != nil {
|
|
731
|
+
return nil, err
|
|
732
|
+
}
|
|
733
|
+
k, err := deriveSecurityKeyV3(appName, salt, master)
|
|
734
|
+
if err != nil {
|
|
735
|
+
return nil, err
|
|
736
|
+
}
|
|
737
|
+
defer k.clear() // 派生钥只活在这一次解密的作用域里:见 deriveSecurityKeyV3 的驻留论证
|
|
738
|
+
secureDecryptCount++
|
|
739
|
+
if !hmac.Equal(tag, appBinTag(k, data, ct)) {
|
|
740
|
+
return nil, errors.New("app.bin 认证失败(exe 被重命名、资源被篡改,或缺每产物主密钥)")
|
|
741
|
+
}
|
|
742
|
+
block, err := aes.NewCipher(k.enc)
|
|
743
|
+
if err != nil {
|
|
744
|
+
return nil, err
|
|
745
|
+
}
|
|
746
|
+
plain := make([]byte, len(ct))
|
|
747
|
+
cipher.NewCTR(block, iv).XORKeyStream(plain, ct)
|
|
748
|
+
defer clearBytes(plain) // 明文缓冲(base64 形态的后端源码也在里面)不留给下一次堆扫描
|
|
749
|
+
var p securePayload
|
|
750
|
+
if err := json.Unmarshal(plain, &p); err != nil {
|
|
751
|
+
return nil, fmt.Errorf("app.bin 载荷无效:%w", err)
|
|
752
|
+
}
|
|
753
|
+
if err := validateSecurePayload(&p); err != nil {
|
|
754
|
+
return nil, err
|
|
755
|
+
}
|
|
756
|
+
return &p, nil
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// integrityManifestV3 是 resources/.integrity 的 v3 磁盘结构(CLI build 签名产出)。
|
|
760
|
+
type integrityManifestV3 struct {
|
|
761
|
+
V int `json:"v"`
|
|
762
|
+
Alg string `json:"alg"`
|
|
763
|
+
Pub string `json:"pub"` // hex(32B 原始 ed25519 公钥)
|
|
764
|
+
Payload string `json:"payload"` // base64(被签名的确切字节)
|
|
765
|
+
Sig string `json:"sig"` // hex(64B)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// integrityClaimsV3 是 payload 解码后的声明;字段顺序即签名字节顺序(与 JS 侧一致)。
|
|
769
|
+
type integrityClaimsV3 struct {
|
|
770
|
+
AppBin string `json:"appBin"`
|
|
771
|
+
Identity string `json:"identity"`
|
|
772
|
+
Salt string `json:"salt"`
|
|
773
|
+
Self string `json:"self"`
|
|
774
|
+
Built string `json:"built"`
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// verifyIntegrityManifest 校验签名清单,返回声明;任何一步失败都抛(调用方据此拒绝运行)。
|
|
778
|
+
//
|
|
779
|
+
// anchorPubHex 是**产物之外**的信任锚:Tier B(自编译壳)来自编译期内嵌的
|
|
780
|
+
// Config.Security.PublicKey,Tier A(通用预编译壳)只能来自产物内的 resources/.trust,
|
|
781
|
+
// 后者是循环信任、强度按 SECURITY.md 的口径如实降级。
|
|
782
|
+
// 顺序刻意是「先比锚、再验签」:清单自带的 pub 不可自证,先信它就等于允许攻击者
|
|
783
|
+
// 换一对钥匙自造合法产物。
|
|
784
|
+
func verifyIntegrityManifest(anchorPubHex string, raw []byte, appName string, appBin []byte) (*integrityClaimsV3, error) {
|
|
785
|
+
var m integrityManifestV3
|
|
786
|
+
if err := json.Unmarshal(raw, &m); err != nil {
|
|
787
|
+
return nil, fmt.Errorf(".integrity 无效:%w", err)
|
|
788
|
+
}
|
|
789
|
+
if m.V != 3 || m.Alg != "ed25519" {
|
|
790
|
+
return nil, fmt.Errorf(".integrity 版本不受支持(v=%d alg=%q,本壳要求 v=3 / ed25519):请用同版 freedom-cli 重新 build", m.V, m.Alg)
|
|
791
|
+
}
|
|
792
|
+
anchor, err := parsePubKeyHex(anchorPubHex)
|
|
793
|
+
if err != nil {
|
|
794
|
+
return nil, err
|
|
795
|
+
}
|
|
796
|
+
declared, err := parsePubKeyHex(m.Pub)
|
|
797
|
+
if err != nil {
|
|
798
|
+
return nil, fmt.Errorf(".integrity pub 非法:%w", err)
|
|
799
|
+
}
|
|
800
|
+
if !hmac.Equal(anchor, declared) {
|
|
801
|
+
return nil, errors.New(".integrity 公钥与信任锚不一致:产物可能被换钥匙重签")
|
|
802
|
+
}
|
|
803
|
+
bytes, err := base64.StdEncoding.DecodeString(m.Payload)
|
|
804
|
+
if err != nil {
|
|
805
|
+
return nil, fmt.Errorf(".integrity payload 非法 base64:%w", err)
|
|
806
|
+
}
|
|
807
|
+
sig, err := hex.DecodeString(m.Sig)
|
|
808
|
+
if err != nil || len(sig) != ed25519.SignatureSize {
|
|
809
|
+
return nil, fmt.Errorf(".integrity 签名长度非法(ed25519 需 %d 字节十六进制)", ed25519.SignatureSize)
|
|
810
|
+
}
|
|
811
|
+
if !ed25519.Verify(anchor, bytes, sig) {
|
|
812
|
+
return nil, errors.New(".integrity 签名校验失败:清单或 app.bin 被篡改")
|
|
813
|
+
}
|
|
814
|
+
var c integrityClaimsV3
|
|
815
|
+
if err := json.Unmarshal(bytes, &c); err != nil {
|
|
816
|
+
return nil, fmt.Errorf(".integrity 签名载荷不是合法 JSON:%w", err)
|
|
817
|
+
}
|
|
818
|
+
// self 由发布方签名,正常恒为 hex64 或空串;长度不合法说明清单与本壳不同代(或
|
|
819
|
+
// 构建端写错字段)。先卡格式再进 verifySelfHash,免得那里对短串切片越界。
|
|
820
|
+
if c.Self != "" && len(c.Self) != sha256HexLen {
|
|
821
|
+
return nil, fmt.Errorf(".integrity self 非法(需 %d 位十六进制或空串,实际 %d 字符)", sha256HexLen, len(c.Self))
|
|
822
|
+
}
|
|
823
|
+
sum := sha256.Sum256(appBin)
|
|
824
|
+
if !hmac.Equal([]byte(c.AppBin), []byte(hex.EncodeToString(sum[:]))) {
|
|
825
|
+
return nil, errors.New(".integrity 校验失败:app.bin 与签名清单不符")
|
|
826
|
+
}
|
|
827
|
+
if c.Identity != appIdentityName(appName) {
|
|
828
|
+
return nil, fmt.Errorf(".integrity 校验失败:清单绑定身份 %q,当前 exe 是 %q(被重命名或跨应用复用)", c.Identity, appIdentityName(appName))
|
|
829
|
+
}
|
|
830
|
+
salt, _, _, _, err := splitAppBinAt(securityMagic3, appBin)
|
|
831
|
+
if err != nil {
|
|
832
|
+
return nil, err
|
|
833
|
+
}
|
|
834
|
+
if !hmac.Equal([]byte(c.Salt), []byte(hex.EncodeToString(salt))) {
|
|
835
|
+
return nil, errors.New(".integrity 校验失败:清单容器盐与 app.bin 头部不符(清单与容器非同一产物)")
|
|
836
|
+
}
|
|
837
|
+
return &c, nil
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// parsePubKeyHex 解析 hex(32B) 原始 ed25519 公钥为 ed25519.PublicKey。
|
|
841
|
+
func parsePubKeyHex(s string) (ed25519.PublicKey, error) {
|
|
842
|
+
b, err := hex.DecodeString(strings.ToLower(strings.TrimSpace(s)))
|
|
843
|
+
if err != nil || len(b) != ed25519.PublicKeySize {
|
|
844
|
+
return nil, fmt.Errorf("ed25519 公钥需为 64 位十六进制(32 字节),实际 %d 字节", len(b))
|
|
845
|
+
}
|
|
846
|
+
return ed25519.PublicKey(b), nil
|
|
847
|
+
}
|