@yufengtadian/freedom-cli 1.13.1 → 1.13.3

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 (76) hide show
  1. package/LICENSE +40 -0
  2. package/README.md +119 -15
  3. package/lib/agents.js +448 -0
  4. package/lib/build.js +189 -45
  5. package/lib/cli.js +258 -9
  6. package/lib/config.js +20 -1
  7. package/lib/desktop.js +133 -0
  8. package/lib/dev.js +149 -0
  9. package/lib/mcp.js +217 -0
  10. package/lib/release.js +90 -0
  11. package/lib/security.js +113 -54
  12. package/lib/shell.js +170 -63
  13. package/lib/toolchain.js +339 -0
  14. package/lib/tui.js +37 -1
  15. package/lib/utils.js +51 -0
  16. package/lib/verify.js +48 -12
  17. package/lib/webkit.js +45 -0
  18. package/package.json +3 -2
  19. package/shell/darwin-arm64/freedom-shell +0 -0
  20. package/shell/linux-x64/freedom-shell +0 -0
  21. package/shell/win-x64/freedom-shell.exe +0 -0
  22. package/skill/freedom/SKILL.md +159 -0
  23. package/templates/desktop/app.html +559 -0
  24. package/templates/desktop/backend/desktop.mjs +327 -0
  25. package/templates/desktop/freedom.config.js +32 -0
  26. package/templates/desktop/icon.ico +0 -0
  27. package/templates/go/go.mod +3 -0
  28. package/templates/go/pkg/freedom/anti_debug_linux.go +69 -0
  29. package/templates/go/pkg/freedom/anti_debug_other.go +5 -3
  30. package/templates/go/pkg/freedom/anti_debug_windows.go +238 -11
  31. package/templates/go/pkg/freedom/assets/freedom.js +3 -0
  32. package/templates/go/pkg/freedom/assets_embed.go +1 -0
  33. package/templates/go/pkg/freedom/backend_proc.go +5 -5
  34. package/templates/go/pkg/freedom/events_windows.go +5 -5
  35. package/templates/go/pkg/freedom/freedom.go +64 -10
  36. package/templates/go/pkg/freedom/msgwindow_windows.go +8 -8
  37. package/templates/go/pkg/freedom/resources.go +119 -14
  38. package/templates/go/pkg/freedom/securetemp.go +61 -0
  39. package/templates/go/pkg/freedom/securetemp_other.go +20 -0
  40. package/templates/go/pkg/freedom/securetemp_windows.go +25 -0
  41. package/templates/go/pkg/freedom/security.go +293 -64
  42. package/templates/go/pkg/freedom/shutdown.go +55 -0
  43. package/templates/go/pkg/freedom/shutdown_windows.go +47 -0
  44. package/templates/go/pkg/freedom/store.go +11 -3
  45. package/templates/go/pkg/freedom/syscap_windows.go +33 -33
  46. package/templates/go/pkg/freedom/sysint_windows.go +2 -2
  47. package/templates/go/pkg/freedom/tray_windows.go +18 -18
  48. package/templates/go/pkg/freedom/updater.go +3 -3
  49. package/templates/go/pkg/freedom/window_other.go +7 -1
  50. package/templates/go/pkg/freedom/window_windows.go +185 -17
  51. package/templates/go/third_party/webview_go/CHANGELOG.md +15 -0
  52. package/templates/go/third_party/webview_go/FREEDOM-PATCH.md +32 -0
  53. package/templates/go/third_party/webview_go/LICENSE +22 -0
  54. package/templates/go/third_party/webview_go/README.md +50 -0
  55. package/templates/go/third_party/webview_go/glue.c +36 -0
  56. package/templates/go/third_party/webview_go/go.mod +3 -0
  57. package/templates/go/third_party/webview_go/go.sum +0 -0
  58. package/templates/go/third_party/webview_go/libs/mswebview2/LICENSE +27 -0
  59. package/templates/go/third_party/webview_go/libs/mswebview2/include/WebView2.h +23568 -0
  60. package/templates/go/third_party/webview_go/libs/mswebview2/include/vendor.go +2 -0
  61. package/templates/go/third_party/webview_go/libs/mswebview2/vendor.go +2 -0
  62. package/templates/go/third_party/webview_go/libs/mswebview2/version.txt +1 -0
  63. package/templates/go/third_party/webview_go/libs/webview/LICENSE +22 -0
  64. package/templates/go/third_party/webview_go/libs/webview/include/vendor.go +2 -0
  65. package/templates/go/third_party/webview_go/libs/webview/include/webview.h +3598 -0
  66. package/templates/go/third_party/webview_go/libs/webview/vendor.go +2 -0
  67. package/templates/go/third_party/webview_go/libs/webview/version.txt +1 -0
  68. package/templates/go/third_party/webview_go/webkit2_40.go +21 -0
  69. package/templates/go/third_party/webview_go/webkit2_41.go +8 -0
  70. package/templates/go/third_party/webview_go/webview.cc +1 -0
  71. package/templates/go/third_party/webview_go/webview.go +332 -0
  72. package/templates/installer/app.nsi +43 -0
  73. package/templates/project/freedom.config.js +14 -0
  74. package/templates/project/freedom.d.ts +77 -0
  75. package/templates/project-minimal/freedom.config.js +14 -0
  76. package/templates/project-minimal/freedom.d.ts +77 -0
@@ -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,78 @@ 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
+ // 生产路径请用 withMasterSecret:明文只存在于回调作用域内,返回前逐字节抹零。
74
+ func masterSecret() []byte {
75
+ out := make([]byte, len(masterKeyCipher))
76
+ for i, b := range masterKeyCipher {
77
+ out[i] = b ^ byte((i*7+0x5A)&0xff)
78
+ }
79
+ return out
80
+ }
81
+
82
+ // withMasterSecret 把还原出的主密钥交给 fn,并在 fn 返回后立刻抹零该缓冲区
83
+ // (Go 的分配器会复用内存,留着明文等于给内存扫描留靶子)。
84
+ func withMasterSecret(fn func(secret []byte)) {
85
+ secret := masterSecret()
86
+ defer clearBytes(secret)
87
+ fn(secret)
88
+ }
89
+
90
+ // clearBytes 抹零一段字节。编译器不会把对切片的显式写零当死代码删掉(逐元素赋值)。
91
+ func clearBytes(b []byte) {
92
+ for i := range b {
93
+ b[i] = 0
94
+ }
95
+ }
96
+
48
97
  // securePayload 是 app.bin 解密后的载荷(与 JS 侧加密载荷结构一致)。
98
+ // Backend 为 high 模式下的后端源码表:键是相对 resources 的路径(斜杠分隔),
99
+ // 值是 base64 内容 d 与 POSIX 权限位 m——后端源码不再明文落盘(FRDM1 的缺口)。
49
100
  type securePayload struct {
50
- HTML string `json:"html"`
51
- Config string `json:"config"`
101
+ HTML string `json:"html"`
102
+ Config string `json:"config"`
103
+ Backend map[string]secureFile `json:"backend,omitempty"`
104
+ }
105
+
106
+ // secureFile 是容器内的单个后端文件。
107
+ type secureFile struct {
108
+ Data []byte `json:"d"`
109
+ Mode uint32 `json:"m"`
52
110
  }
53
111
 
54
112
  // integrityFile 是 resources/.integrity 的磁盘结构(CLI build 生成)。
55
113
  type integrityFile struct {
56
114
  V int `json:"v"`
57
115
  AppBin string `json:"appBin"`
58
- Backend map[string]string `json:"backend"`
116
+ Backend map[string]string `json:"backend,omitempty"`
59
117
  }
60
118
 
61
119
  // pbkdf2HMACSHA256 自实现 PBKDF2-HMAC-SHA256(RFC 2898),
@@ -110,63 +168,151 @@ func appIdentity() (string, error) {
110
168
  return appIdentityName(filepath.Base(exe)), nil
111
169
  }
112
170
 
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)
171
+ // secureKey 是单个容器对应的密钥对:加密与认证分开,避免同一密钥同时
172
+ // 服务 AES-CTR 与 HMAC。
173
+ type secureKey struct {
174
+ enc []byte // AES-256-CTR 密钥(PBKDF2 直接产出)
175
+ mac []byte // HMAC-SHA256 密钥(KEK 经固定标签拉伸)
118
176
  }
119
177
 
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")
178
+ // deriveSecurityKey 派生容器密钥。盐 = 固定前缀 + 应用标识 + 容器内构建期随机盐,
179
+ // 故不同应用密钥不同、同一应用每次 build 密钥也不同,仅拿到主密钥常量不足以复现。
180
+ //
181
+ // PBKDF2 迭代 60 万次(实测约 180ms)是启动路径上唯一的昂贵操作:结果按
182
+ // (应用标识, 容器盐) 记忆化,配置与页面两处加载共用一次派生。
183
+ func deriveSecurityKey(appName string, salt []byte) secureKey {
184
+ id := appIdentityName(appName) + "\x00" + string(salt)
185
+ secureKeyMu.Lock()
186
+ defer secureKeyMu.Unlock()
187
+ if k, ok := secureKeyCache[id]; ok {
188
+ return k
189
+ }
190
+ base := append([]byte(securityDeriveSalt+":"+appIdentityName(appName)), salt...)
191
+ var k secureKey
192
+ withMasterSecret(func(secret []byte) {
193
+ kek := pbkdf2HMACSHA256(secret, base, securityPbkdf2Iter, securityKeyLen)
194
+ h := hmac.New(sha256.New, kek)
195
+ h.Write([]byte(securityMacLabel))
196
+ k = secureKey{enc: kek, mac: h.Sum(nil)}
197
+ })
198
+ if len(secureKeyCache) >= secureKeyCacheMax { // 防御性上限:单进程正常只用一两条
199
+ // 淘汰不是"丢掉引用"就完事:被丢弃的密钥字节仍会被分配器复用,先抹零。
200
+ for _, stale := range secureKeyCache {
201
+ clearBytes(stale.enc)
202
+ clearBytes(stale.mac)
203
+ }
204
+ secureKeyCache = make(map[string]secureKey)
205
+ }
206
+ secureKeyCache[id] = k
207
+ return k
208
+ }
209
+
210
+ const secureKeyCacheMax = 8
211
+
212
+ var (
213
+ secureKeyMu sync.Mutex
214
+ secureKeyCache = make(map[string]secureKey)
215
+ )
216
+
217
+ // splitAppBin 校验并拆分容器头。magic 不符(含旧版 FRDM1)时给出可操作错误,
218
+ // 绝不回退明文路径。
219
+ func splitAppBin(data []byte) (salt, iv, tag, ct []byte, err error) {
220
+ if len(data) < appBinHeaderLen {
221
+ return nil, nil, nil, nil, errors.New("app.bin 长度不足容器头")
126
222
  }
127
223
  if string(data[:len(securityMagic)]) != securityMagic {
128
- return nil, errors.New("app.bin bad magic")
224
+ return nil, nil, nil, nil, fmt.Errorf(
225
+ "app.bin 容器版本不受支持(头为 %q,本壳要求 %q):请用与壳同版的 freedom-cli 重新 build",
226
+ string(data[:len(securityMagic)]), securityMagic)
129
227
  }
130
228
  off := len(securityMagic)
131
- iv := data[off : off+securityIVLen]
229
+ salt = data[off : off+securitySaltLen]
230
+ off += securitySaltLen
231
+ iv = data[off : off+securityIVLen]
132
232
  off += securityIVLen
133
- tag := data[off : off+securityTagLen]
233
+ tag = data[off : off+securityTagLen]
134
234
  off += securityTagLen
135
- ct := data[off:]
235
+ ct = data[off:]
236
+ return salt, iv, tag, ct, nil
237
+ }
136
238
 
137
- key := deriveSecurityKey(appName)
239
+ // appBinAuthLen 是参与认证的头部长度:magic + salt + iv(不含 tag 自身)。
240
+ const appBinAuthLen = len(securityMagic) + securitySaltLen + securityIVLen
138
241
 
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
- }
242
+ // appBinTag 计算认证标签:HMAC-SHA256(macKey, magic+salt+iv+密文) 前 16 字节。
243
+ // 覆盖头部使"换 iv / 换 salt"这类 CTR 明文操纵与降级尝试都过不了认证。
244
+ func appBinTag(k secureKey, data, ct []byte) []byte {
245
+ h := hmac.New(sha256.New, k.mac)
246
+ h.Write(data[:appBinAuthLen])
247
+ h.Write(ct)
248
+ return h.Sum(nil)[:securityTagLen]
249
+ }
145
250
 
146
- // 2) 解密:AES-256-CTR。
147
- block, err := aes.NewCipher(key)
251
+ // decryptAppBin 解密 resources/app.bin,返回载荷。
252
+ // 先恒定时间校验认证标签(Encrypt-then-MAC),再 AES-256-CTR 解密。
253
+ // 失败原因可能是 exe 被重命名 / 容器被篡改 / 壳与 CLI 版本不匹配。
254
+ func decryptAppBin(appName string, data []byte) (*securePayload, error) {
255
+ salt, iv, tag, ct, err := splitAppBin(data)
256
+ if err != nil {
257
+ return nil, err
258
+ }
259
+ k := deriveSecurityKey(appName, salt)
260
+ if !hmac.Equal(tag, appBinTag(k, data, ct)) {
261
+ return nil, errors.New("app.bin 认证失败(exe 被重命名、资源被篡改,或壳与 CLI 版本不匹配)")
262
+ }
263
+ block, err := aes.NewCipher(k.enc)
148
264
  if err != nil {
149
265
  return nil, err
150
266
  }
151
- stream := cipher.NewCTR(block, iv)
152
267
  plain := make([]byte, len(ct))
153
- stream.XORKeyStream(plain, ct)
268
+ cipher.NewCTR(block, iv).XORKeyStream(plain, ct)
154
269
 
155
270
  var p securePayload
156
271
  if err := json.Unmarshal(plain, &p); err != nil {
157
272
  return nil, fmt.Errorf("app.bin 载荷无效:%w", err)
158
273
  }
274
+ // 与打包端 lib/security.js 同一道门:认证通过不等于结构可用。少了 html/config 的容器
275
+ // 会让上层回落到内置占位页——那是「加密产物跑起来了但内容是空的」级别的静默降级。
276
+ if p.HTML == "" || p.Config == "" {
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
+ }
283
+ }
159
284
  return &p, nil
160
285
  }
161
286
 
162
- // verifyIntegrity 校验 resources/.integrity 清单(存在时)。
163
- // 比对 app.bin 与 backend/** 各文件 HMAC-SHA256,防整体替换/篡改。
164
- // 无 .integrity 文件(旧产物)时跳过,保持向后兼容。
165
- func verifyIntegrity(dir, appName string, appBin []byte) error {
287
+ // isSafeRelPath 判定容器内相对路径可安全落盘:非绝对、无空段、无 "." / ".." 段。
288
+ // 解密出的内容来自产物文件,落盘前必须自证不会越出目标目录。
289
+ func isSafeRelPath(rel string) bool {
290
+ if rel == "" || strings.Contains(rel, "\\") || strings.HasPrefix(rel, "/") {
291
+ return false
292
+ }
293
+ if len(rel) >= 2 && rel[1] == ':' { // Windows 盘符绝对路径
294
+ return false
295
+ }
296
+ for _, seg := range strings.Split(rel, "/") {
297
+ if seg == "" || seg == "." || seg == ".." {
298
+ return false
299
+ }
300
+ }
301
+ return true
302
+ }
303
+
304
+ // verifyIntegrity 校验 resources/.integrity 清单。
305
+ // 比对 app.bin 的 HMAC-SHA256,防容器被整体替换;backend 已进容器,其完整性由
306
+ // 容器认证标签一并保证,清单里的 backend 段仅作向后兼容。
307
+ // 清单缺失是致命错误而非"旧产物豁免":app.bin 的认证钥由「exe 名 + 容器自带盐」派生,
308
+ // 两者都在产物里,攻击者能为任意内容自造合法容器;.integrity 正是那道产物外的重放绑定,
309
+ // 而「删掉 .integrity」就是绕过它最省事的手法——跳过校验等于把这道门留给攻击者关。
310
+ // 自 FRDM2 起 CLI 恒写清单(lib/build.js),拒绝无清单容器不会误伤任何正规产物。
311
+ func verifyIntegrity(dir string, k secureKey, appBin []byte) error {
166
312
  raw, err := os.ReadFile(filepath.Join(dir, ".integrity"))
167
313
  if err != nil {
168
314
  if errors.Is(err, os.ErrNotExist) {
169
- return nil
315
+ return errors.New("resources/.integrity 缺失:app.bin 未经完整性绑定,产物可能被整体替换(请重新 freedom build)")
170
316
  }
171
317
  return err
172
318
  }
@@ -174,16 +320,21 @@ func verifyIntegrity(dir, appName string, appBin []byte) error {
174
320
  if err := json.Unmarshal(raw, &f); err != nil {
175
321
  return fmt.Errorf(".integrity 无效:%w", err)
176
322
  }
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)
323
+ check := func(label, wantHex string, buf []byte) error {
324
+ want, err := hex.DecodeString(wantHex)
182
325
  if err != nil {
183
- return fmt.Errorf(".integrity appBin 校验值非法:%w", err)
326
+ return fmt.Errorf(".integrity %s 校验值非法:%w", label, err)
184
327
  }
328
+ mac := hmac.New(sha256.New, k.mac)
329
+ mac.Write(buf)
185
330
  if !hmac.Equal(want, mac.Sum(nil)) {
186
- return errors.New(".integrity 校验失败:resources/app.bin 被篡改")
331
+ return fmt.Errorf(".integrity 校验失败:%s 被篡改", label)
332
+ }
333
+ return nil
334
+ }
335
+ if f.AppBin != "" {
336
+ if err := check("appBin", f.AppBin, appBin); err != nil {
337
+ return err
187
338
  }
188
339
  }
189
340
  for rel, wantHex := range f.Backend {
@@ -191,22 +342,48 @@ func verifyIntegrity(dir, appName string, appBin []byte) error {
191
342
  if err != nil {
192
343
  return fmt.Errorf(".integrity 校验失败:backend 文件缺失 %s:%w", rel, err)
193
344
  }
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)
345
+ if err := check("backend["+rel+"]", wantHex, b); err != nil {
346
+ return err
202
347
  }
203
348
  }
204
349
  return nil
205
350
  }
206
351
 
352
+ // hasSecureResources 只做文件探测:exe 同目录是否存在 high 容器 app.bin。
353
+ // Run 据此决定"解密前"是否先跑一次反调试(不派生密钥,代价为一次 stat)。
354
+ func hasSecureResources() bool {
355
+ dir, err := resourcesDir()
356
+ if err != nil {
357
+ return false
358
+ }
359
+ _, err = os.Stat(filepath.Join(dir, "app.bin"))
360
+ return err == nil
361
+ }
362
+
363
+ // antiDebugEnabled 是 high 模式调试器探测的开关。
364
+ //
365
+ // 探测本身是 fail-safe 的(每个信号取不到数据一律按"未命中"处理),但六路信号里
366
+ // IsDebuggerPresent / NtQueryInformationProcess 在部分合法环境本就会给出真值:
367
+ // 逆向沙箱、EDR/杀软注入、某些虚拟化与远程桌面环境。被误判的用户需要一条**不必重新
368
+ // 打包**就能脱身的路,所以除了发布方编译期的 Config.DisableAntiDebug,这里再认一个
369
+ // 运行期环境变量。
370
+ //
371
+ // 关掉只影响"探测"这一道:容器解密、HMAC 认证与 .integrity 校验照常,
372
+ // 源码保护的本体不在此。config.json 里刻意不给这个开关——非 high 产物的
373
+ // config.json 是明文可改的,那样等于把防御交给攻击者。
374
+ func (a *App) antiDebugEnabled() bool {
375
+ if a != nil && a.cfg.DisableAntiDebug {
376
+ return false
377
+ }
378
+ return os.Getenv("FREEDOM_DISABLE_ANTIDEBUG") != "1"
379
+ }
380
+
207
381
  // loadSecureResources 尝试加载 high 模式加密资源。
208
382
  // 返回 (载荷, 是否命中, 错误):resources/app.bin 不存在时命中=false(非 high 产物),
209
383
  // 命中但解密/校验失败时返回错误(拒绝静默回退明文,避免降级攻击)。
384
+ //
385
+ // 每次调用都重新读盘并复算认证标签(不缓存载荷):容器被替换后立即失效,
386
+ // 启动路径上真正昂贵的 PBKDF2 已由 deriveSecurityKey 记忆化。
210
387
  func loadSecureResources() (*securePayload, bool, error) {
211
388
  dir, err := resourcesDir()
212
389
  if err != nil {
@@ -223,7 +400,11 @@ func loadSecureResources() (*securePayload, bool, error) {
223
400
  if err != nil {
224
401
  return nil, false, err
225
402
  }
226
- if err := verifyIntegrity(dir, name, data); err != nil {
403
+ salt, _, _, _, err := splitAppBin(data)
404
+ if err != nil {
405
+ return nil, false, err
406
+ }
407
+ if err := verifyIntegrity(dir, deriveSecurityKey(name, salt), data); err != nil {
227
408
  return nil, false, err
228
409
  }
229
410
  p, err := decryptAppBin(name, data)
@@ -232,3 +413,51 @@ func loadSecureResources() (*securePayload, bool, error) {
232
413
  }
233
414
  return p, true, nil
234
415
  }
416
+
417
+ // materializeSecureBackend 把容器内的后端源码解密写入进程私有临时目录(0700),
418
+ // 供 ProcBackend 以子进程方式执行——high 模式磁盘上不再有 resources/backend 明文。
419
+ // 返回临时目录路径;调用方负责在退出时删除(见 App.cleanupSecureBackend)。
420
+ // 权限位取自容器内记录并显式 chmod(umask 会削弱 WriteFile 的 perm),
421
+ // 无有效权限位时回退 0600,绝不放宽到其他用户可读写。
422
+ //
423
+ // 目录名内嵌 PID(freedom-<app>-<pid>-<随机>):正常退出走 defer 清理,但崩溃与
424
+ // taskkill /F 不执行 defer,明文源码会永久留在临时目录里。故每次物化前先回收
425
+ // "同应用 + PID 已不在"的历史目录,把强杀留下的残留压到下次启动即清。
426
+ func materializeSecureBackend(files map[string]secureFile) (string, error) {
427
+ name, err := appIdentity()
428
+ if err != nil {
429
+ return "", err
430
+ }
431
+ gcStaleSecureBackendDirs(os.TempDir(), name)
432
+ dir, err := os.MkdirTemp("", secureTempDirName(name))
433
+ if err != nil {
434
+ return "", err
435
+ }
436
+ for rel, f := range files {
437
+ if !isSafeRelPath(rel) {
438
+ os.RemoveAll(dir)
439
+ return "", fmt.Errorf("后端路径非法:%q", rel)
440
+ }
441
+ full := filepath.Join(dir, filepath.FromSlash(rel))
442
+ if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
443
+ os.RemoveAll(dir)
444
+ return "", err
445
+ }
446
+ perm := os.FileMode(f.Mode & 0o777)
447
+ if perm == 0 {
448
+ perm = 0o600
449
+ }
450
+ // 容器里的权限位来自构建机(Windows 上 stat 常给 0666):非属主写位一律抹掉,
451
+ // 保留读/执行位(临时目录本身 0700,此处的收紧是第二道防线)。
452
+ perm &= 0o755
453
+ if err := os.WriteFile(full, f.Data, 0o600); err != nil {
454
+ os.RemoveAll(dir)
455
+ return "", err
456
+ }
457
+ if err := os.Chmod(full, perm); err != nil {
458
+ os.RemoveAll(dir)
459
+ return "", err
460
+ }
461
+ }
462
+ return dir, nil
463
+ }
@@ -0,0 +1,55 @@
1
+ package freedom
2
+
3
+ // 退出前清扫(跨平台):high 模式把后端源码解密到系统临时目录,正常退出由 Run 的
4
+ // defer 删除;但 SIGINT / SIGTERM(Ctrl+C、`kill`、systemd stop)会让 Go 直接终止
5
+ // 进程,defer 与 cleanup 都不执行,明文后端源码就永久躺在 %TEMP% / /tmp 里——
6
+ // 直到用户下次再启动这个应用才会被启动期 GC 回收。对「磁盘不留明文」的承诺来说
7
+ // 这是真空档,故在此显式接管常规信号:命中即先清扫再退。
8
+ //
9
+ // 边界:SIGKILL / taskkill /F / 断电无法拦截(无用户态代码可跑),仍靠下次启动的
10
+ // gcStaleSecureBackendDirs 兜底;Windows 的控制台事件见 shutdown_windows.go。
11
+
12
+ import (
13
+ "os"
14
+ "os/signal"
15
+ "sync"
16
+ "syscall"
17
+ )
18
+
19
+ // secureCleanup 由 Run 注册(Run 未跑过就没有可清的东西)。
20
+ var secureCleanup func()
21
+
22
+ // cleanupOnce 用指针:单测需要一个可复位的实例来断言「只挂一次」。
23
+ var cleanupOnce = new(sync.Once)
24
+
25
+ // platformShutdownHooks 由各平台补挂退出通道(Windows 控制台事件),无额外通道的平台为 nil。
26
+ var platformShutdownHooks func()
27
+
28
+ // installShutdownCleanup 幂等地挂上退出清扫通道,由 Run 在注册 secureCleanup 后调用。
29
+ func installShutdownCleanup() {
30
+ cleanupOnce.Do(func() {
31
+ ch := make(chan os.Signal, 1)
32
+ signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
33
+ go waitForShutdownSignal(ch)
34
+ if platformShutdownHooks != nil {
35
+ platformShutdownHooks()
36
+ }
37
+ })
38
+ }
39
+
40
+ // waitForShutdownSignal 命中信号即清扫退场;拆成变量便于单测注入(真实信号无法跨平台自投)。
41
+ var waitForShutdownSignal = func(ch <-chan os.Signal) {
42
+ <-ch
43
+ exitAfterCleanup()
44
+ }
45
+
46
+ // exitAfterCleanup 清扫后以 130(128+SIGINT 惯例)退出。
47
+ func exitAfterCleanup() {
48
+ if fn := secureCleanup; fn != nil {
49
+ fn()
50
+ }
51
+ shutdownExit(130)
52
+ }
53
+
54
+ // shutdownExit 是 os.Exit 的槽位:单测靠它断言"先清扫再退出",否则会把测试进程一起带走。
55
+ var shutdownExit = os.Exit
@@ -0,0 +1,47 @@
1
+ //go:build windows
2
+
3
+ package freedom
4
+
5
+ // Windows 退出通道补挂:SIGTERM 在本平台不存在,控制台关闭 / 注销 / 关机走
6
+ // SetConsoleCtrlHandler。命中 CTRL_CLOSE_EVENT 时系统只给约 5 秒宽限,
7
+ // 删一个临时目录绑绑有余;GUI 无控制台时注册会失败,静默跳过(不影响信号通道)。
8
+ //
9
+ // 覆盖面要说清:`-H windowsgui` 发布的正式壳根本没有控制台,这条通道对它永不触发,
10
+ // 其正常关窗由 WM_CLOSE → DestroyWindow → Run 的 defer 清扫,强杀/崩溃由下次启动的
11
+ // gcStaleSecureBackendDirs 兜底。本通道实际服务控制台子系统构建(裸 go build、调试期直接跑 exe)。
12
+
13
+ import (
14
+ "syscall"
15
+ )
16
+
17
+ var (
18
+ kernel32Shutdown = syscall.NewLazyDLL("kernel32.dll")
19
+ procSetConsoleCtrlHandler = kernel32Shutdown.NewProc("SetConsoleCtrlHandler")
20
+
21
+ consoleCtrlCloseEvent uint32 = 2
22
+ )
23
+
24
+ // consoleCtrlHandlerProc 的形参与返回必须是 uintptr:syscall.NewCallback 会校验
25
+ // 「一个 uintptr 宽返回值」,写成 BOOL 习惯的 uint32 直接 panic
26
+ // (compileCallback: expected function with one uintptr-sized result)。
27
+ func consoleCtrlHandlerProc(ctrlType uintptr) uintptr {
28
+ if uint32(ctrlType) == consoleCtrlCloseEvent {
29
+ exitAfterCleanup() // 不返回:清扫后立即退场
30
+ }
31
+ return 0 // 继续交给链上其它处理器
32
+ }
33
+
34
+ // consoleCtrlHandler 固定在包级求值一次:syscall.NewCallback 每次调用都占用全局句柄表,
35
+ // 重复申请会泄漏(与 events_windows.go 的同款约束)。
36
+ var consoleCtrlHandler = syscall.NewCallback(consoleCtrlHandlerProc)
37
+
38
+ func init() {
39
+ platformShutdownHooks = installConsoleCtrlCleanup
40
+ }
41
+
42
+ func installConsoleCtrlCleanup() {
43
+ if err := procSetConsoleCtrlHandler.Find(); err != nil {
44
+ return
45
+ }
46
+ procSetConsoleCtrlHandler.Call(consoleCtrlHandler, 1)
47
+ }
@@ -232,7 +232,7 @@ func saveWindowState(dir string, st WindowState) error {
232
232
 
233
233
  // ---- os / process 信息 ----
234
234
 
235
- func osInfo() map[string]interface{} {
235
+ func osInfo(app *App) map[string]interface{} {
236
236
  host, _ := os.Hostname()
237
237
  info := map[string]interface{}{
238
238
  "platform": runtime.GOOS,
@@ -240,7 +240,7 @@ func osInfo() map[string]interface{} {
240
240
  "hostname": host,
241
241
  "osVersion": osVersionString(), // 平台分文件:osver_windows.go / osver_other.go
242
242
  "goVersion": runtime.Version(),
243
- "appVersion": Version, // 构建期 ldflags 注入,见 freedom.go
243
+ "appVersion": app.appVersion(), // 运行时声明优先,回落 ldflags 注入值
244
244
  "numCPU": runtime.NumCPU(),
245
245
  }
246
246
  // M6:WebView2 Runtime 探测回显(仅 Windows 且检出时给键,其他平台省略)
@@ -311,7 +311,7 @@ func (a *App) sysGeneric(method string, args map[string]json.RawMessage) (result
311
311
  }
312
312
  return sf.keys(), true, nil
313
313
  case "os.info":
314
- info := osInfo()
314
+ info := osInfo(a)
315
315
  // M3:回显生效的能力收口,前端可据此隐藏入口(nil=全开时省略该键)。
316
316
  if a.cfg.Capabilities != nil {
317
317
  info["capabilities"] = map[string][]string{
@@ -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 == "" {