@yufengtadian/freedom-cli 1.13.0 → 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.
- package/README.md +96 -14
- package/lib/agents.js +318 -0
- package/lib/build.js +168 -39
- package/lib/cli.js +160 -4
- 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 +5 -2
- package/lib/tui.js +37 -1
- package/lib/verify.js +43 -8
- package/package.json +2 -1
- 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 +317 -0
- package/templates/desktop/freedom.config.js +29 -0
- package/templates/desktop/icon.ico +0 -0
- package/templates/go/pkg/freedom/anti_debug_windows.go +114 -11
- package/templates/go/pkg/freedom/assets/freedom.js +3 -0
- package/templates/go/pkg/freedom/freedom.go +52 -9
- package/templates/go/pkg/freedom/resources.go +68 -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 +240 -61
- package/templates/go/pkg/freedom/store.go +8 -0
- package/templates/go/pkg/freedom/window_other.go +7 -1
- package/templates/go/pkg/freedom/window_windows.go +185 -17
- 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
|
@@ -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
|
|
47
|
-
Title
|
|
48
|
-
TitleBar
|
|
49
|
-
Width
|
|
50
|
-
Height
|
|
51
|
-
MinWidth
|
|
52
|
-
MinHeight
|
|
53
|
-
Center
|
|
54
|
-
Debug
|
|
55
|
-
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
|
-
//
|
|
149
|
-
//
|
|
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 :=
|
|
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
|
|
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,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
|
-
|
|
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
|
+
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
|
|
51
|
-
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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,
|
|
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
|
-
|
|
205
|
+
salt = data[off : off+securitySaltLen]
|
|
206
|
+
off += securitySaltLen
|
|
207
|
+
iv = data[off : off+securityIVLen]
|
|
132
208
|
off += securityIVLen
|
|
133
|
-
tag
|
|
209
|
+
tag = data[off : off+securityTagLen]
|
|
134
210
|
off += securityTagLen
|
|
135
|
-
ct
|
|
211
|
+
ct = data[off:]
|
|
212
|
+
return salt, iv, tag, ct, nil
|
|
213
|
+
}
|
|
136
214
|
|
|
137
|
-
|
|
215
|
+
// appBinAuthLen 是参与认证的头部长度:magic + salt + iv(不含 tag 自身)。
|
|
216
|
+
const appBinAuthLen = len(securityMagic) + securitySaltLen + securityIVLen
|
|
138
217
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
276
|
+
// 比对 app.bin 的 HMAC-SHA256,防容器被整体替换;backend 已进容器,其完整性由
|
|
277
|
+
// 容器认证标签一并保证,清单里的 backend 段仅作向后兼容。
|
|
164
278
|
// 无 .integrity 文件(旧产物)时跳过,保持向后兼容。
|
|
165
|
-
func verifyIntegrity(dir,
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 == "" {
|