@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.
- package/LICENSE +40 -0
- package/README.md +119 -15
- package/lib/agents.js +448 -0
- package/lib/build.js +189 -45
- package/lib/cli.js +258 -9
- package/lib/config.js +20 -1
- package/lib/desktop.js +133 -0
- package/lib/dev.js +149 -0
- package/lib/mcp.js +217 -0
- package/lib/release.js +90 -0
- package/lib/security.js +113 -54
- package/lib/shell.js +170 -63
- package/lib/toolchain.js +339 -0
- package/lib/tui.js +37 -1
- package/lib/utils.js +51 -0
- package/lib/verify.js +48 -12
- package/lib/webkit.js +45 -0
- 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/skill/freedom/SKILL.md +159 -0
- package/templates/desktop/app.html +559 -0
- package/templates/desktop/backend/desktop.mjs +327 -0
- package/templates/desktop/freedom.config.js +32 -0
- package/templates/desktop/icon.ico +0 -0
- package/templates/go/go.mod +3 -0
- package/templates/go/pkg/freedom/anti_debug_linux.go +69 -0
- package/templates/go/pkg/freedom/anti_debug_other.go +5 -3
- package/templates/go/pkg/freedom/anti_debug_windows.go +238 -11
- package/templates/go/pkg/freedom/assets/freedom.js +3 -0
- package/templates/go/pkg/freedom/assets_embed.go +1 -0
- package/templates/go/pkg/freedom/backend_proc.go +5 -5
- package/templates/go/pkg/freedom/events_windows.go +5 -5
- package/templates/go/pkg/freedom/freedom.go +64 -10
- package/templates/go/pkg/freedom/msgwindow_windows.go +8 -8
- package/templates/go/pkg/freedom/resources.go +119 -14
- package/templates/go/pkg/freedom/securetemp.go +61 -0
- package/templates/go/pkg/freedom/securetemp_other.go +20 -0
- package/templates/go/pkg/freedom/securetemp_windows.go +25 -0
- package/templates/go/pkg/freedom/security.go +293 -64
- package/templates/go/pkg/freedom/shutdown.go +55 -0
- package/templates/go/pkg/freedom/shutdown_windows.go +47 -0
- package/templates/go/pkg/freedom/store.go +11 -3
- package/templates/go/pkg/freedom/syscap_windows.go +33 -33
- package/templates/go/pkg/freedom/sysint_windows.go +2 -2
- package/templates/go/pkg/freedom/tray_windows.go +18 -18
- package/templates/go/pkg/freedom/updater.go +3 -3
- package/templates/go/pkg/freedom/window_other.go +7 -1
- package/templates/go/pkg/freedom/window_windows.go +185 -17
- package/templates/go/third_party/webview_go/CHANGELOG.md +15 -0
- package/templates/go/third_party/webview_go/FREEDOM-PATCH.md +32 -0
- package/templates/go/third_party/webview_go/LICENSE +22 -0
- package/templates/go/third_party/webview_go/README.md +50 -0
- package/templates/go/third_party/webview_go/glue.c +36 -0
- package/templates/go/third_party/webview_go/go.mod +3 -0
- package/templates/go/third_party/webview_go/go.sum +0 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/LICENSE +27 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/include/WebView2.h +23568 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/include/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/mswebview2/version.txt +1 -0
- package/templates/go/third_party/webview_go/libs/webview/LICENSE +22 -0
- package/templates/go/third_party/webview_go/libs/webview/include/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/webview/include/webview.h +3598 -0
- package/templates/go/third_party/webview_go/libs/webview/vendor.go +2 -0
- package/templates/go/third_party/webview_go/libs/webview/version.txt +1 -0
- package/templates/go/third_party/webview_go/webkit2_40.go +21 -0
- package/templates/go/third_party/webview_go/webkit2_41.go +8 -0
- package/templates/go/third_party/webview_go/webview.cc +1 -0
- package/templates/go/third_party/webview_go/webview.go +332 -0
- package/templates/installer/app.nsi +43 -0
- package/templates/project/freedom.config.js +14 -0
- package/templates/project/freedom.d.ts +77 -0
- package/templates/project-minimal/freedom.config.js +14 -0
- package/templates/project-minimal/freedom.d.ts +77 -0
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
package freedom
|
|
2
2
|
|
|
3
|
-
// security.go — high
|
|
3
|
+
// security.go — high 安全模式:资源加密容器 app.bin(FRDM2)与完整性校验。
|
|
4
4
|
//
|
|
5
5
|
// 与 freedom-cli lib/security.js 跨语言同步(改任一侧必须同步另一侧,否则 CLI
|
|
6
6
|
// 加密的产物壳无法解密):
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
18
|
-
//
|
|
19
|
-
// resources/.integrity
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
securityPbkdf2Iter =
|
|
50
|
+
securityDeriveSalt = "freedom:derive:v2"
|
|
51
|
+
securityMacLabel = "freedom:mac:v2"
|
|
52
|
+
securityPbkdf2Iter = 600000
|
|
42
53
|
securityKeyLen = 32
|
|
43
|
-
securityMagic = "
|
|
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
|
|
51
|
-
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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,
|
|
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
|
-
|
|
229
|
+
salt = data[off : off+securitySaltLen]
|
|
230
|
+
off += securitySaltLen
|
|
231
|
+
iv = data[off : off+securityIVLen]
|
|
132
232
|
off += securityIVLen
|
|
133
|
-
tag
|
|
233
|
+
tag = data[off : off+securityTagLen]
|
|
134
234
|
off += securityTagLen
|
|
135
|
-
ct
|
|
235
|
+
ct = data[off:]
|
|
236
|
+
return salt, iv, tag, ct, nil
|
|
237
|
+
}
|
|
136
238
|
|
|
137
|
-
|
|
239
|
+
// appBinAuthLen 是参与认证的头部长度:magic + salt + iv(不含 tag 自身)。
|
|
240
|
+
const appBinAuthLen = len(securityMagic) + securitySaltLen + securityIVLen
|
|
138
241
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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":
|
|
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 == "" {
|