@yufengtadian/freedom-cli 1.13.1 → 1.13.2

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 (38) hide show
  1. package/README.md +87 -13
  2. package/lib/agents.js +318 -0
  3. package/lib/build.js +168 -39
  4. package/lib/cli.js +160 -4
  5. package/lib/config.js +20 -1
  6. package/lib/desktop.js +133 -0
  7. package/lib/dev.js +149 -0
  8. package/lib/mcp.js +217 -0
  9. package/lib/release.js +90 -0
  10. package/lib/security.js +113 -54
  11. package/lib/shell.js +5 -2
  12. package/lib/tui.js +37 -1
  13. package/lib/verify.js +43 -8
  14. package/package.json +2 -1
  15. package/shell/darwin-arm64/freedom-shell +0 -0
  16. package/shell/linux-x64/freedom-shell +0 -0
  17. package/shell/win-x64/freedom-shell.exe +0 -0
  18. package/skill/freedom/SKILL.md +159 -0
  19. package/templates/desktop/app.html +559 -0
  20. package/templates/desktop/backend/desktop.mjs +317 -0
  21. package/templates/desktop/freedom.config.js +29 -0
  22. package/templates/desktop/icon.ico +0 -0
  23. package/templates/go/pkg/freedom/anti_debug_windows.go +114 -11
  24. package/templates/go/pkg/freedom/assets/freedom.js +3 -0
  25. package/templates/go/pkg/freedom/freedom.go +52 -9
  26. package/templates/go/pkg/freedom/resources.go +68 -14
  27. package/templates/go/pkg/freedom/securetemp.go +61 -0
  28. package/templates/go/pkg/freedom/securetemp_other.go +20 -0
  29. package/templates/go/pkg/freedom/securetemp_windows.go +25 -0
  30. package/templates/go/pkg/freedom/security.go +240 -61
  31. package/templates/go/pkg/freedom/store.go +8 -0
  32. package/templates/go/pkg/freedom/window_other.go +7 -1
  33. package/templates/go/pkg/freedom/window_windows.go +185 -17
  34. package/templates/installer/app.nsi +43 -0
  35. package/templates/project/freedom.config.js +14 -0
  36. package/templates/project/freedom.d.ts +77 -0
  37. package/templates/project-minimal/freedom.config.js +14 -0
  38. package/templates/project-minimal/freedom.d.ts +77 -0
@@ -40,19 +40,30 @@ type runtimeBackend struct {
40
40
  Args []string `json:"args"`
41
41
  }
42
42
 
43
+ // runtimeUpdater 描述 config.json 中的自动更新配置(对应 updater.go 的 UpdateConfig;
44
+ // 公钥为 base64 std 的 ed25519 32 字节公钥,私钥属发布方资产——签名由 freedom CLI 生成)。
45
+ type runtimeUpdater struct {
46
+ ManifestURL string `json:"manifestURL"`
47
+ PublicKey string `json:"publicKey"`
48
+ RequireSignature *bool `json:"requireSignature"`
49
+ }
50
+
43
51
  // runtimeConfigFile 描述 resources/config.json 的磁盘结构。
44
52
  // 由 freedom CLI 在 build 阶段根据 freedom.config.js 生成;Name 仅作来源标记不应用。
45
53
  type runtimeConfigFile struct {
46
- Name string `json:"name"`
47
- Title string `json:"title"`
48
- TitleBar string `json:"titlebar"`
49
- Width int `json:"width"`
50
- Height int `json:"height"`
51
- MinWidth int `json:"minWidth"`
52
- MinHeight int `json:"minHeight"`
53
- Center *bool `json:"center"`
54
- Debug *bool `json:"debug"`
55
- Backend *runtimeBackend `json:"backend"`
54
+ Name string `json:"name"`
55
+ Title string `json:"title"`
56
+ TitleBar string `json:"titlebar"`
57
+ Width int `json:"width"`
58
+ Height int `json:"height"`
59
+ MinWidth int `json:"minWidth"`
60
+ MinHeight int `json:"minHeight"`
61
+ Center *bool `json:"center"`
62
+ Debug *bool `json:"debug"`
63
+ Backend *runtimeBackend `json:"backend"`
64
+ URL string `json:"url"`
65
+ SingleInstance *bool `json:"singleInstance"`
66
+ Updater *runtimeUpdater `json:"updater"`
56
67
  }
57
68
 
58
69
  // resourcesDirOverride 是单测缝:非空时替代 exe 同目录的 resources 定位。
@@ -82,10 +93,19 @@ func (a *App) loadRuntimeConfig() error {
82
93
  if err := json.Unmarshal([]byte(p.Config), &rc); err != nil {
83
94
  return fmt.Errorf("parse encrypted config: %w", err)
84
95
  }
96
+ a.secure = true
97
+ // high 模式的后端源码在容器内(磁盘无 resources/backend 明文):解密到进程私有
98
+ // 临时目录供子进程执行,Run 退出时删除。
99
+ if len(p.Backend) > 0 {
100
+ bdir, err := materializeSecureBackend(p.Backend)
101
+ if err != nil {
102
+ return secureFatal(fmt.Errorf("后端源码解密落地失败:%w", err))
103
+ }
104
+ a.secureBackendDir = bdir
105
+ }
85
106
  a.applyRuntimeConfig(&rc)
86
107
  // high 模式强制关闭 WebView 开发者工具,防止前端源码经 devtools 直接查看。
87
108
  a.cfg.Debug = false
88
- a.secure = true
89
109
  return nil
90
110
  }
91
111
  return a.loadRuntimeConfigPlain()
@@ -143,14 +163,29 @@ func (a *App) applyRuntimeConfig(rc *runtimeConfigFile) {
143
163
  if rc.Debug != nil {
144
164
  a.cfg.Debug = *rc.Debug
145
165
  }
166
+ if rc.URL != "" {
167
+ a.cfg.URL = rc.URL
168
+ }
169
+ if rc.SingleInstance != nil {
170
+ a.cfg.SingleInstance = *rc.SingleInstance
171
+ }
172
+ // 自动更新:manifestURL 与 publicKey 缺一即视为未配置(半截配置宁可不启用,
173
+ // 也不让壳带着空公钥去更新)。
174
+ if rc.Updater != nil && rc.Updater.ManifestURL != "" && rc.Updater.PublicKey != "" {
175
+ uc := &UpdateConfig{ManifestURL: rc.Updater.ManifestURL, PublicKey: rc.Updater.PublicKey}
176
+ if rc.Updater.RequireSignature != nil {
177
+ uc.RequireSignature = *rc.Updater.RequireSignature
178
+ }
179
+ a.cfg.Update = uc
180
+ }
146
181
  // 外部后端进程配置:仅在壳未显式绑定后端时生效(用户 Bind 过内嵌方法 =
147
182
  // 显式内嵌后端,config.json 不得静默替换,否则其方法全部失效)。
148
- // 工作目录设为 resources 目录,使相对路径参数(backend/main.mjs)按
149
- // resources/ 解析(CLI 会把项目 backend/ 目录分发到 resources/backend/)。
183
+ // 工作目录取 backendWorkDir():明文模式 = resources 目录(相对路径 backend/main.mjs
184
+ // 由其解析);high 模式 = 容器解出的私有临时目录(resources 下没有明文后端)。
150
185
  if rc.Backend != nil && rc.Backend.Command != "" && !a.backendExplicit {
151
186
  args := append([]string{rc.Backend.Command}, rc.Backend.Args...)
152
187
  pb := NewProcBackend(args...)
153
- if dir, err := resourcesDir(); err == nil && dir != "" {
188
+ if dir, err := a.backendWorkDir(); err == nil && dir != "" {
154
189
  pb.SetDir(dir)
155
190
  }
156
191
  a.cfg.Backend = pb
@@ -158,6 +193,25 @@ func (a *App) applyRuntimeConfig(rc *runtimeConfigFile) {
158
193
  }
159
194
  }
160
195
 
196
+ // backendWorkDir 返回后端进程的工作目录。
197
+ func (a *App) backendWorkDir() (string, error) {
198
+ if a.secureBackendDir != "" {
199
+ return a.secureBackendDir, nil
200
+ }
201
+ return resourcesDir()
202
+ }
203
+
204
+ // cleanupSecureBackend 删除 high 模式解出的临时后端目录(Run 退出时调用)。
205
+ // 进程被强杀时目录会残留在系统临时区——内容仍可被下一次启动的壳重新解出,
206
+ // 且明文源码本就来自内存,故不引入跨进程清扫。
207
+ func (a *App) cleanupSecureBackend() {
208
+ if a.secureBackendDir == "" {
209
+ return
210
+ }
211
+ _ = os.RemoveAll(a.secureBackendDir)
212
+ a.secureBackendDir = ""
213
+ }
214
+
161
215
  // loadRuntimeHTML 返回前端页面内容(high 模式:解密 app.bin 内的 html;
162
216
  // 否则读取 exe 同目录 resources/index.html)。文件不存在时返回空串。
163
217
  func loadRuntimeHTML() (string, error) {
@@ -0,0 +1,61 @@
1
+ package freedom
2
+
3
+ // high 模式后端源码临时目录的启动期回收。
4
+ //
5
+ // 正常退出由 App.cleanupSecureBackend 删除;进程崩溃或被 taskkill /F 强杀时
6
+ // defer 不会执行,解密的明文源码就留在临时目录里(同用户的逆向者可直接读走)。
7
+ // 这里在每次物化前扫一遍临时目录,回收"同应用且属主进程已不在"的历史目录。
8
+ //
9
+ // 目录名格式:freedom-<app>-<pid>-<MkdirTemp 随机后缀>。按应用名精确匹配,
10
+ // 因此只可能删到同一应用自己的残留目录;判据失败(PID 解析不出、进程仍活着)
11
+ // 一律保留,宁留残留不误删。
12
+
13
+ import (
14
+ "os"
15
+ "path/filepath"
16
+ "strconv"
17
+ "strings"
18
+ )
19
+
20
+ // secureTempDirName 生成物化目录名前缀(含应用标识与当前 PID)。
21
+ func secureTempDirName(app string) string {
22
+ return "freedom-" + app + "-" + strconv.Itoa(os.Getpid()) + "-"
23
+ }
24
+
25
+ // gcStaleSecureBackendDirs 删除 dir 下属于 appName、且属主进程已退出的物化目录。
26
+ func gcStaleSecureBackendDirs(dir, appName string) {
27
+ entries, err := os.ReadDir(dir)
28
+ if err != nil {
29
+ return // 临时目录不可读:跳过回收,不影响本次启动
30
+ }
31
+ prefix := "freedom-" + appName + "-"
32
+ for _, e := range entries {
33
+ if !e.IsDir() {
34
+ continue
35
+ }
36
+ pid, ok := parseSecureTempName(e.Name(), prefix)
37
+ if !ok || backendProcessAlive(pid) {
38
+ continue
39
+ }
40
+ _ = os.RemoveAll(filepath.Join(dir, e.Name()))
41
+ }
42
+ }
43
+
44
+ // parseSecureTempName 从 "freedom-<app>-<pid>-<rand>" 取出 pid。
45
+ // 要求 rand 段存在(我们自建的目录必然带 MkdirTemp 后缀),据此排除
46
+ // 手工命名的同前缀目录。
47
+ func parseSecureTempName(name, prefix string) (int, bool) {
48
+ rest, ok := strings.CutPrefix(name, prefix)
49
+ if !ok {
50
+ return 0, false
51
+ }
52
+ pidStr, randStr, ok := strings.Cut(rest, "-")
53
+ if !ok || randStr == "" {
54
+ return 0, false
55
+ }
56
+ pid, err := strconv.Atoi(pidStr)
57
+ if err != nil || pid <= 0 {
58
+ return 0, false
59
+ }
60
+ return pid, true
61
+ }
@@ -0,0 +1,20 @@
1
+ //go:build !windows
2
+
3
+ package freedom
4
+
5
+ import (
6
+ "errors"
7
+ "os"
8
+ "syscall"
9
+ )
10
+
11
+ // backendProcessAlive 用 signal 0 探测进程存在性:
12
+ // 成功=存活;EPERM=存活但非本人所有(保守判存活);ESRCH 等=已退出。
13
+ func backendProcessAlive(pid int) bool {
14
+ p, err := os.FindProcess(pid)
15
+ if err != nil {
16
+ return false
17
+ }
18
+ err = p.Signal(syscall.Signal(0))
19
+ return err == nil || errors.Is(err, syscall.EPERM)
20
+ }
@@ -0,0 +1,25 @@
1
+ //go:build windows
2
+
3
+ package freedom
4
+
5
+ import "syscall"
6
+
7
+ var (
8
+ kernel32SecureTemp = syscall.NewLazyDLL("kernel32.dll")
9
+ procOpenProcess = kernel32SecureTemp.NewProc("OpenProcess")
10
+ procCloseHandle = kernel32SecureTemp.NewProc("CloseHandle")
11
+ )
12
+
13
+ // processQueryLimitedInformation 是最低权限的进程存在性探测(不需要任何特权,
14
+ // 对已退出但句柄未回收的进程仍返回真,属安全方向的保守判定)。
15
+ const processQueryLimitedInformation = 0x1000
16
+
17
+ // backendProcessAlive 判断 PID 是否仍存活;OpenProcess 失败(含权限不足)即判为不存活。
18
+ func backendProcessAlive(pid int) bool {
19
+ h, _, _ := procOpenProcess.Call(processQueryLimitedInformation, 0, uintptr(pid))
20
+ if h == 0 {
21
+ return false
22
+ }
23
+ procCloseHandle.Call(h)
24
+ return true
25
+ }
@@ -1,23 +1,33 @@
1
1
  package freedom
2
2
 
3
- // security.go — high 安全模式:资源加密容器(app.bin)与完整性校验。
3
+ // security.go — high 安全模式:资源加密容器 app.bin(FRDM2)与完整性校验。
4
4
  //
5
5
  // 与 freedom-cli lib/security.js 跨语言同步(改任一侧必须同步另一侧,否则 CLI
6
6
  // 加密的产物壳无法解密):
7
- // - securityMasterKey / securityDeriveSalt / securityPbkdf2Iter / securityKeyLen
8
- // - securityMagic / securityIVLen / securityTagLen(app.bin 容器头格式)
9
- // - 派生算法:PBKDF2-HMAC-SHA256(RFC 2898),salt = "freedom:derive:v1:" + 应用标识
7
+ // - masterKeyCipher(异或掩码后的主密钥字节表)与掩码算法 out[i] ^= (i*7+0x5A)
8
+ // - securityDeriveSalt / securityPbkdf2Iter / securityKeyLen / securityMacLabel
9
+ // - securityMagic / securitySaltLen / securityIVLen / securityTagLen(容器头格式)
10
+ // - 载荷 JSON 结构 securePayload(html / config / backend)
11
+ // - 算法:PBKDF2-HMAC-SHA256 → AES-256-CTR + Encrypt-then-MAC(HMAC 截 16B)
10
12
  // - 应用标识 = exe 文件名去扩展名(CLI build 时的 name 与运行时 os.Executable() 一致)
11
13
  //
12
- // 加密算法:AES-256-CTR + HMAC-SHA256(Encrypt-then-MAC),与 Node crypto 的
13
- // aes-256-ctr + createHmac('sha256') 跨语言一致。(不用 GCM:曾在 Windows 环境
14
- // 出现标准库 GHASH 确定性认证失败,CTR+HMAC 语义等价且规避该问题。)
14
+ // FRDM2 相对 FRDM1 的四点加强(目标是把"打开产物就能读源码"抬到"必须逆向壳 +
15
+ // 运行时取密钥";客户端加密做不到不可逆,故也不这样宣称):
16
+ // 1. 主密钥不以明文常量存在(掩码字节表 + 运行时还原),strings 扫不到完整密钥;
17
+ // 2. 派生盐加入构建期随机 16B(每个产物不同),PBKDF2 迭代 60000 → 600000;
18
+ // 3. 加密密钥与认证密钥域分离;认证标签覆盖 magic+salt+iv+密文
19
+ // (CTR 下改 iv 即明文可预测翻转,头部不认证等于留了解密 oracle);
20
+ // 4. 后端源码并入容器(backend 字段),磁盘上不再有 resources/backend 明文目录。
21
+ //
22
+ // 旧版 FRDM1 容器一律拒绝并提示重新 build,不做静默降级(静默回退即降级攻击面)。
15
23
  //
16
24
  // high 模式产物布局(CLI build 写入,与明文模式互斥):
17
- // resources/app.bin 加密容器:magic(5B FRDM1)+iv(16B)+tag(16B)+ciphertext
18
- // 解密载荷 JSON:{"html": <string>, "config": <string>}
19
- // resources/.integrity HMAC-SHA256 完整性清单(app.bin + backend/**)
25
+ // resources/app.bin FRDM2 容器:magic(5)+salt(16)+iv(16)+tag(16)+ciphertext
26
+ // 解密载荷 JSON:{"html","config","backend":{"<rel>":{"d","m"}}}
27
+ // resources/.integrity HMAC-SHA256(app.bin),壳启动时校验,防容器被整体替换
20
28
  // 壳启动时在内存解密,磁盘无明文;exe 被重命名/资源被篡改 → 解密失败即拒绝运行。
29
+ // (不用 GCM:曾在 Windows 环境出现标准库 GHASH 确定性认证失败,CTR+HMAC 语义等价
30
+ // 且规避该问题。)
21
31
 
22
32
  import (
23
33
  "crypto/aes"
@@ -32,30 +42,62 @@ import (
32
42
  "os"
33
43
  "path/filepath"
34
44
  "strings"
45
+ "sync"
35
46
  )
36
47
 
37
48
  // ---- 与 lib/security.js 同步的派生与容器参数 ----
38
49
  const (
39
- securityMasterKey = "freedom-shell::kdf-master::v1::7f4e8c2a9b1d6e3f"
40
- securityDeriveSalt = "freedom:derive:v1"
41
- securityPbkdf2Iter = 60000
50
+ securityDeriveSalt = "freedom:derive:v2"
51
+ securityMacLabel = "freedom:mac:v2"
52
+ securityPbkdf2Iter = 600000
42
53
  securityKeyLen = 32
43
- securityMagic = "FRDM1"
54
+ securityMagic = "FRDM2"
55
+ securitySaltLen = 16 // 构建期随机派生盐
44
56
  securityIVLen = 16 // AES-CTR 计数器长度 = AES 块大小
45
57
  securityTagLen = 16 // HMAC-SHA256 截断为 16 字节
46
58
  )
47
59
 
60
+ // appBinHeaderLen 容器头总长:magic + salt + iv + tag。
61
+ const appBinHeaderLen = len(securityMagic) + securitySaltLen + securityIVLen + securityTagLen
62
+
63
+ // masterKeyCipher:主密钥明文逐字节与位置相关掩码异或后的字节表。
64
+ // 目的仅是让静态 strings / 十六进制搜索拿不到主密钥;运行时内存中仍有明文密钥,
65
+ // 真正的对抗边界是"必须动态调试壳才能取到"(见 anti_debug_windows.go)。
66
+ var masterKeyCipher = []byte{
67
+ 60, 19, 13, 10, 18, 18, 233, 166, 225, 241, 197, 203, 194, 143, 134, 168,
68
+ 174, 183, 245, 178, 135, 158, 128, 158, 112, 51, 42, 97, 44, 31, 22, 10,
69
+ 89, 117, 46, 125, 97, 60, 1, 90, 22, 65, 226, 179, 189, 240, 172,
70
+ }
71
+
72
+ // masterSecret 还原主密钥(PBKDF2 的 password)。
73
+ func masterSecret() []byte {
74
+ out := make([]byte, len(masterKeyCipher))
75
+ for i, b := range masterKeyCipher {
76
+ out[i] = b ^ byte((i*7+0x5A)&0xff)
77
+ }
78
+ return out
79
+ }
80
+
48
81
  // securePayload 是 app.bin 解密后的载荷(与 JS 侧加密载荷结构一致)。
82
+ // Backend 为 high 模式下的后端源码表:键是相对 resources 的路径(斜杠分隔),
83
+ // 值是 base64 内容 d 与 POSIX 权限位 m——后端源码不再明文落盘(FRDM1 的缺口)。
49
84
  type securePayload struct {
50
- HTML string `json:"html"`
51
- Config string `json:"config"`
85
+ HTML string `json:"html"`
86
+ Config string `json:"config"`
87
+ Backend map[string]secureFile `json:"backend,omitempty"`
88
+ }
89
+
90
+ // secureFile 是容器内的单个后端文件。
91
+ type secureFile struct {
92
+ Data []byte `json:"d"`
93
+ Mode uint32 `json:"m"`
52
94
  }
53
95
 
54
96
  // integrityFile 是 resources/.integrity 的磁盘结构(CLI build 生成)。
55
97
  type integrityFile struct {
56
98
  V int `json:"v"`
57
99
  AppBin string `json:"appBin"`
58
- Backend map[string]string `json:"backend"`
100
+ Backend map[string]string `json:"backend,omitempty"`
59
101
  }
60
102
 
61
103
  // pbkdf2HMACSHA256 自实现 PBKDF2-HMAC-SHA256(RFC 2898),
@@ -110,59 +152,131 @@ func appIdentity() (string, error) {
110
152
  return appIdentityName(filepath.Base(exe)), nil
111
153
  }
112
154
 
113
- // deriveSecurityKey 派生 high 模式加密密钥(AES-256-CTR 与 HMAC-SHA256 共用同一 32B 密钥)。
114
- // salt 引入应用标识使不同应用密钥不同;PBKDF2 迭代增加暴力破解成本。
115
- func deriveSecurityKey(appName string) []byte {
116
- salt := []byte(securityDeriveSalt + ":" + appIdentityName(appName))
117
- return pbkdf2HMACSHA256([]byte(securityMasterKey), salt, securityPbkdf2Iter, securityKeyLen)
155
+ // secureKey 是单个容器对应的密钥对:加密与认证分开,避免同一密钥同时
156
+ // 服务 AES-CTR 与 HMAC。
157
+ type secureKey struct {
158
+ enc []byte // AES-256-CTR 密钥(PBKDF2 直接产出)
159
+ mac []byte // HMAC-SHA256 密钥(KEK 经固定标签拉伸)
118
160
  }
119
161
 
120
- // decryptAppBin 解密 resources/app.bin,返回载荷。
121
- // 先恒定时间校验 HMAC 认证标签(Encrypt-then-MAC),再 AES-256-CTR 解密。
122
- // 解密失败(exe 被重命名 / 资源被篡改 / 密钥不匹配)时返回错误。
123
- func decryptAppBin(appName string, data []byte) (*securePayload, error) {
124
- if len(data) < len(securityMagic)+securityIVLen+securityTagLen {
125
- return nil, errors.New("app.bin too short")
162
+ // deriveSecurityKey 派生容器密钥。盐 = 固定前缀 + 应用标识 + 容器内构建期随机盐,
163
+ // 故不同应用密钥不同、同一应用每次 build 密钥也不同,仅拿到主密钥常量不足以复现。
164
+ //
165
+ // PBKDF2 迭代 60 万次(实测约 180ms)是启动路径上唯一的昂贵操作:结果按
166
+ // (应用标识, 容器盐) 记忆化,配置与页面两处加载共用一次派生。
167
+ func deriveSecurityKey(appName string, salt []byte) secureKey {
168
+ id := appIdentityName(appName) + "\x00" + string(salt)
169
+ secureKeyMu.Lock()
170
+ defer secureKeyMu.Unlock()
171
+ if k, ok := secureKeyCache[id]; ok {
172
+ return k
173
+ }
174
+ base := append([]byte(securityDeriveSalt+":"+appIdentityName(appName)), salt...)
175
+ kek := pbkdf2HMACSHA256(masterSecret(), base, securityPbkdf2Iter, securityKeyLen)
176
+ h := hmac.New(sha256.New, kek)
177
+ h.Write([]byte(securityMacLabel))
178
+ k := secureKey{enc: kek, mac: h.Sum(nil)}
179
+ if len(secureKeyCache) >= secureKeyCacheMax { // 防御性上限:单进程正常只用一两条
180
+ secureKeyCache = make(map[string]secureKey)
181
+ }
182
+ secureKeyCache[id] = k
183
+ return k
184
+ }
185
+
186
+ const secureKeyCacheMax = 8
187
+
188
+ var (
189
+ secureKeyMu sync.Mutex
190
+ secureKeyCache = make(map[string]secureKey)
191
+ )
192
+
193
+ // splitAppBin 校验并拆分容器头。magic 不符(含旧版 FRDM1)时给出可操作错误,
194
+ // 绝不回退明文路径。
195
+ func splitAppBin(data []byte) (salt, iv, tag, ct []byte, err error) {
196
+ if len(data) < appBinHeaderLen {
197
+ return nil, nil, nil, nil, errors.New("app.bin 长度不足容器头")
126
198
  }
127
199
  if string(data[:len(securityMagic)]) != securityMagic {
128
- return nil, errors.New("app.bin bad magic")
200
+ return nil, nil, nil, nil, fmt.Errorf(
201
+ "app.bin 容器版本不受支持(头为 %q,本壳要求 %q):请用与壳同版的 freedom-cli 重新 build",
202
+ string(data[:len(securityMagic)]), securityMagic)
129
203
  }
130
204
  off := len(securityMagic)
131
- iv := data[off : off+securityIVLen]
205
+ salt = data[off : off+securitySaltLen]
206
+ off += securitySaltLen
207
+ iv = data[off : off+securityIVLen]
132
208
  off += securityIVLen
133
- tag := data[off : off+securityTagLen]
209
+ tag = data[off : off+securityTagLen]
134
210
  off += securityTagLen
135
- ct := data[off:]
211
+ ct = data[off:]
212
+ return salt, iv, tag, ct, nil
213
+ }
136
214
 
137
- key := deriveSecurityKey(appName)
215
+ // appBinAuthLen 是参与认证的头部长度:magic + salt + iv(不含 tag 自身)。
216
+ const appBinAuthLen = len(securityMagic) + securitySaltLen + securityIVLen
138
217
 
139
- // 1) 认证:HMAC-SHA256(ct) 前 16 字节与容器头 tag 恒定时间比对。
140
- mac := hmac.New(sha256.New, key)
141
- mac.Write(ct)
142
- if !hmac.Equal(tag, mac.Sum(nil)[:securityTagLen]) {
143
- return nil, errors.New("app.bin 解密失败(exe 被重命名或资源被篡改?):认证失败")
144
- }
218
+ // appBinTag 计算认证标签:HMAC-SHA256(macKey, magic+salt+iv+密文) 前 16 字节。
219
+ // 覆盖头部使"换 iv / 换 salt"这类 CTR 明文操纵与降级尝试都过不了认证。
220
+ func appBinTag(k secureKey, data, ct []byte) []byte {
221
+ h := hmac.New(sha256.New, k.mac)
222
+ h.Write(data[:appBinAuthLen])
223
+ h.Write(ct)
224
+ return h.Sum(nil)[:securityTagLen]
225
+ }
145
226
 
146
- // 2) 解密:AES-256-CTR。
147
- block, err := aes.NewCipher(key)
227
+ // decryptAppBin 解密 resources/app.bin,返回载荷。
228
+ // 先恒定时间校验认证标签(Encrypt-then-MAC),再 AES-256-CTR 解密。
229
+ // 失败原因可能是 exe 被重命名 / 容器被篡改 / 壳与 CLI 版本不匹配。
230
+ func decryptAppBin(appName string, data []byte) (*securePayload, error) {
231
+ salt, iv, tag, ct, err := splitAppBin(data)
232
+ if err != nil {
233
+ return nil, err
234
+ }
235
+ k := deriveSecurityKey(appName, salt)
236
+ if !hmac.Equal(tag, appBinTag(k, data, ct)) {
237
+ return nil, errors.New("app.bin 认证失败(exe 被重命名、资源被篡改,或壳与 CLI 版本不匹配)")
238
+ }
239
+ block, err := aes.NewCipher(k.enc)
148
240
  if err != nil {
149
241
  return nil, err
150
242
  }
151
- stream := cipher.NewCTR(block, iv)
152
243
  plain := make([]byte, len(ct))
153
- stream.XORKeyStream(plain, ct)
244
+ cipher.NewCTR(block, iv).XORKeyStream(plain, ct)
154
245
 
155
246
  var p securePayload
156
247
  if err := json.Unmarshal(plain, &p); err != nil {
157
248
  return nil, fmt.Errorf("app.bin 载荷无效:%w", err)
158
249
  }
250
+ for rel := range p.Backend {
251
+ if !isSafeRelPath(rel) {
252
+ return nil, fmt.Errorf("app.bin 后端路径非法:%q", rel)
253
+ }
254
+ }
159
255
  return &p, nil
160
256
  }
161
257
 
258
+ // isSafeRelPath 判定容器内相对路径可安全落盘:非绝对、无空段、无 "." / ".." 段。
259
+ // 解密出的内容来自产物文件,落盘前必须自证不会越出目标目录。
260
+ func isSafeRelPath(rel string) bool {
261
+ if rel == "" || strings.Contains(rel, "\\") || strings.HasPrefix(rel, "/") {
262
+ return false
263
+ }
264
+ if len(rel) >= 2 && rel[1] == ':' { // Windows 盘符绝对路径
265
+ return false
266
+ }
267
+ for _, seg := range strings.Split(rel, "/") {
268
+ if seg == "" || seg == "." || seg == ".." {
269
+ return false
270
+ }
271
+ }
272
+ return true
273
+ }
274
+
162
275
  // verifyIntegrity 校验 resources/.integrity 清单(存在时)。
163
- // 比对 app.bin 与 backend/** 各文件 HMAC-SHA256,防整体替换/篡改。
276
+ // 比对 app.bin 的 HMAC-SHA256,防容器被整体替换;backend 已进容器,其完整性由
277
+ // 容器认证标签一并保证,清单里的 backend 段仅作向后兼容。
164
278
  // 无 .integrity 文件(旧产物)时跳过,保持向后兼容。
165
- func verifyIntegrity(dir, appName string, appBin []byte) error {
279
+ func verifyIntegrity(dir string, k secureKey, appBin []byte) error {
166
280
  raw, err := os.ReadFile(filepath.Join(dir, ".integrity"))
167
281
  if err != nil {
168
282
  if errors.Is(err, os.ErrNotExist) {
@@ -174,16 +288,21 @@ func verifyIntegrity(dir, appName string, appBin []byte) error {
174
288
  if err := json.Unmarshal(raw, &f); err != nil {
175
289
  return fmt.Errorf(".integrity 无效:%w", err)
176
290
  }
177
- key := deriveSecurityKey(appName)
178
- if f.AppBin != "" {
179
- mac := hmac.New(sha256.New, key)
180
- mac.Write(appBin)
181
- want, err := hex.DecodeString(f.AppBin)
291
+ check := func(label, wantHex string, buf []byte) error {
292
+ want, err := hex.DecodeString(wantHex)
182
293
  if err != nil {
183
- return fmt.Errorf(".integrity appBin 校验值非法:%w", err)
294
+ return fmt.Errorf(".integrity %s 校验值非法:%w", label, err)
184
295
  }
296
+ mac := hmac.New(sha256.New, k.mac)
297
+ mac.Write(buf)
185
298
  if !hmac.Equal(want, mac.Sum(nil)) {
186
- return errors.New(".integrity 校验失败:resources/app.bin 被篡改")
299
+ return fmt.Errorf(".integrity 校验失败:%s 被篡改", label)
300
+ }
301
+ return nil
302
+ }
303
+ if f.AppBin != "" {
304
+ if err := check("appBin", f.AppBin, appBin); err != nil {
305
+ return err
187
306
  }
188
307
  }
189
308
  for rel, wantHex := range f.Backend {
@@ -191,22 +310,30 @@ func verifyIntegrity(dir, appName string, appBin []byte) error {
191
310
  if err != nil {
192
311
  return fmt.Errorf(".integrity 校验失败:backend 文件缺失 %s:%w", rel, err)
193
312
  }
194
- mac := hmac.New(sha256.New, key)
195
- mac.Write(b)
196
- want, err := hex.DecodeString(wantHex)
197
- if err != nil {
198
- return fmt.Errorf(".integrity backend[%s] 校验值非法:%w", rel, err)
199
- }
200
- if !hmac.Equal(want, mac.Sum(nil)) {
201
- return fmt.Errorf(".integrity 校验失败:backend 文件 %s 被篡改", rel)
313
+ if err := check("backend["+rel+"]", wantHex, b); err != nil {
314
+ return err
202
315
  }
203
316
  }
204
317
  return nil
205
318
  }
206
319
 
320
+ // hasSecureResources 只做文件探测:exe 同目录是否存在 high 容器 app.bin。
321
+ // Run 据此决定"解密前"是否先跑一次反调试(不派生密钥,代价为一次 stat)。
322
+ func hasSecureResources() bool {
323
+ dir, err := resourcesDir()
324
+ if err != nil {
325
+ return false
326
+ }
327
+ _, err = os.Stat(filepath.Join(dir, "app.bin"))
328
+ return err == nil
329
+ }
330
+
207
331
  // loadSecureResources 尝试加载 high 模式加密资源。
208
332
  // 返回 (载荷, 是否命中, 错误):resources/app.bin 不存在时命中=false(非 high 产物),
209
333
  // 命中但解密/校验失败时返回错误(拒绝静默回退明文,避免降级攻击)。
334
+ //
335
+ // 每次调用都重新读盘并复算认证标签(不缓存载荷):容器被替换后立即失效,
336
+ // 启动路径上真正昂贵的 PBKDF2 已由 deriveSecurityKey 记忆化。
210
337
  func loadSecureResources() (*securePayload, bool, error) {
211
338
  dir, err := resourcesDir()
212
339
  if err != nil {
@@ -223,7 +350,11 @@ func loadSecureResources() (*securePayload, bool, error) {
223
350
  if err != nil {
224
351
  return nil, false, err
225
352
  }
226
- if err := verifyIntegrity(dir, name, data); err != nil {
353
+ salt, _, _, _, err := splitAppBin(data)
354
+ if err != nil {
355
+ return nil, false, err
356
+ }
357
+ if err := verifyIntegrity(dir, deriveSecurityKey(name, salt), data); err != nil {
227
358
  return nil, false, err
228
359
  }
229
360
  p, err := decryptAppBin(name, data)
@@ -232,3 +363,51 @@ func loadSecureResources() (*securePayload, bool, error) {
232
363
  }
233
364
  return p, true, nil
234
365
  }
366
+
367
+ // materializeSecureBackend 把容器内的后端源码解密写入进程私有临时目录(0700),
368
+ // 供 ProcBackend 以子进程方式执行——high 模式磁盘上不再有 resources/backend 明文。
369
+ // 返回临时目录路径;调用方负责在退出时删除(见 App.cleanupSecureBackend)。
370
+ // 权限位取自容器内记录并显式 chmod(umask 会削弱 WriteFile 的 perm),
371
+ // 无有效权限位时回退 0600,绝不放宽到其他用户可读写。
372
+ //
373
+ // 目录名内嵌 PID(freedom-<app>-<pid>-<随机>):正常退出走 defer 清理,但崩溃与
374
+ // taskkill /F 不执行 defer,明文源码会永久留在临时目录里。故每次物化前先回收
375
+ // "同应用 + PID 已不在"的历史目录,把强杀留下的残留压到下次启动即清。
376
+ func materializeSecureBackend(files map[string]secureFile) (string, error) {
377
+ name, err := appIdentity()
378
+ if err != nil {
379
+ return "", err
380
+ }
381
+ gcStaleSecureBackendDirs(os.TempDir(), name)
382
+ dir, err := os.MkdirTemp("", secureTempDirName(name))
383
+ if err != nil {
384
+ return "", err
385
+ }
386
+ for rel, f := range files {
387
+ if !isSafeRelPath(rel) {
388
+ os.RemoveAll(dir)
389
+ return "", fmt.Errorf("后端路径非法:%q", rel)
390
+ }
391
+ full := filepath.Join(dir, filepath.FromSlash(rel))
392
+ if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
393
+ os.RemoveAll(dir)
394
+ return "", err
395
+ }
396
+ perm := os.FileMode(f.Mode & 0o777)
397
+ if perm == 0 {
398
+ perm = 0o600
399
+ }
400
+ // 容器里的权限位来自构建机(Windows 上 stat 常给 0666):非属主写位一律抹掉,
401
+ // 保留读/执行位(临时目录本身 0700,此处的收紧是第二道防线)。
402
+ perm &= 0o755
403
+ if err := os.WriteFile(full, f.Data, 0o600); err != nil {
404
+ os.RemoveAll(dir)
405
+ return "", err
406
+ }
407
+ if err := os.Chmod(full, perm); err != nil {
408
+ os.RemoveAll(dir)
409
+ return "", err
410
+ }
411
+ }
412
+ return dir, nil
413
+ }
@@ -349,6 +349,14 @@ func (a *App) storeFor(name string) (*storeFile, error) {
349
349
  return storeFor(dir, name)
350
350
  }
351
351
 
352
+ // effectiveAppID 是数据目录与单实例锁共用的应用标识:Config.AppID 优先,回退 exe 名。
353
+ func (a *App) effectiveAppID() string {
354
+ if a.cfg.AppID != "" {
355
+ return a.cfg.AppID
356
+ }
357
+ return defaultAppID()
358
+ }
359
+
352
360
  // appPath 解析 path.get:kind 目录 + 可选子目录名(默认 AppID,回退 exe 名)。
353
361
  func (a *App) appPath(kind, name string) (string, error) {
354
362
  if name == "" {