mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序
把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。
## 为什么
原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
· nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
只靠 ticker 10min 才没炸(隐式依赖,无声明)
· 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
· 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)
## 怎么改
- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)
## 判据
kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
· 删掉 AddCloser 的锁 → -race 报数据竞争
· Scheduler 同批跑去重 → 203 vs 200
· 图 hash 不匹配 → 拒绝启动
## 顺带修的缺陷
热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。
实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。
* feat(security): 密钥防火墙 + 配置加密 + close 纪律
## 密钥防火墙
模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
· 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
· 入向:模型传别名,入口处填回真值(插件完全无感知)
· 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件
模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。
## 配置加密
- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计
## close 纪律(用户明确要求:只要调 close 就要有前置检查)
internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
· `select { case <-ch: default: close(ch) }` **不是**并发安全的
(两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
· `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
· map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
改成 *pendingRPC 包装,每通道一个 once
## 判据
security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。
* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复
## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)
- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
"未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
(不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
那是进程退出用的,误用于"暂停"就是关了就死
## 数据目录迁移(伪关机 → 迁移 → 唤醒)
- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
→ 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本
★ 迁移脚本的三个实测教训(都写成了判据):
1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)
## 发行包修复(全部经实测确认)
1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
("生产构建(内嵌 hash 非空)但读不到依赖图")
2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
⇒ --version 显示 unknown
4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH
## ★ build.sh 的版本号必须校验(真实事故)
原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。
修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。
## deb 的 systemd unit 改为 guard 模式
ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。
## 其他
- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)
* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)
- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
"出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
mgrIsRuntimeStopped)+ settings 对齐
判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。
* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)
GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:
internal/security/prefilter_test.go:39
internal/security/store_test.go:177
internal/security/survey_test.go:20
它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。
★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。
改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
glpat-abcdefghijklmnopqrst → glpat-FAKE-TEST-TOKEN-NOT-REAL
sk-abcdefghij...012345 → sk-FAKE-TEST-KEY-NOT-REAL-0000000
AKIAIOSFODNN7EXAMPLE → AKIA0000FAKE0000TEST (保持 20 字符)
ASIAIOSFODNN7EXAMPLE → ASIA0000FAKE0000TEST
xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL
★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。
顺带核实:全仓 grep 无任何真实密钥泄漏。
* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一
上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:
VERSION="${VERSION:-$(git describe --tags --dirty)}"
无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。
危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。
修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。
判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。
实测:不传 VERSION 跑 package-linux.sh,输出
[PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
Version: 1.4.0
* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)
CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:
--- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
bridge_sync_test.go:170: 读模板: open
../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
no such file or directory
## 根因:判据依赖了一个**被 gitignore 的路径**
`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
· 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
· CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**
我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。
## 修法
文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。
★ 这不是"放弃检查":
· 跨仓一致性由 **SDK 仓自己的判据**守着
(tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
· 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)
## 验证
· 本机(有文件) → PASS(判据真跑)
· 模拟 CI(移走 tools/)→ SKIP(不再 fail)
· 全量 53 包 ok / 0 fail
## 顺带
同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
24
internal/security/alias_shape_test.go
Normal file
24
internal/security/alias_shape_test.go
Normal file
@ -0,0 +1,24 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
|
||||
// 判据:内核别名是 HMAC 派生 ⇒ **用户不能自选名字**。
|
||||
//
|
||||
// 这决定了去重的形态:插件若要保留可读名(@{gitea-local}),
|
||||
// 它必须另外存一个 name→alias 映射(**只存映射,不存值**)。
|
||||
func TestKernelAliasIsDerivedNotUserChosen(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
a1, _ := s.Alias("some-plain-value")
|
||||
a2, _ := s.Alias("some-plain-value")
|
||||
if a1 != a2 {
|
||||
t.Fatal("别名必须确定性派生(否则失去自动合并)")
|
||||
}
|
||||
// 没有任何 API 允许指定别名内容
|
||||
if a1 == "gitea-local" {
|
||||
t.Fatal("别名不该等于用户想要的名字")
|
||||
}
|
||||
if len(a1) != 20 || a1[:4] != "sec_" {
|
||||
t.Fatalf("别名形态固定: %s", a1)
|
||||
}
|
||||
t.Logf("内核别名是派生值: %s(用户无法自选 ⇒ 插件要保留可读名必须自己存映射)", a1)
|
||||
}
|
||||
143
internal/security/bench_test.go
Normal file
143
internal/security/bench_test.go
Normal file
@ -0,0 +1,143 @@
|
||||
package security
|
||||
|
||||
// 热路径基准 —— 安全模块挂在每次 LLM 调用与每次工具调用上,
|
||||
// 它的开销会被**乘上**这两个频次。所以必须知道数量级。
|
||||
//
|
||||
// go test -bench . -benchmem ./internal/security/
|
||||
//
|
||||
// 关注点:
|
||||
// · Replace(出向):每条消息扫一遍模式库 —— 未命中时应当很便宜
|
||||
// · Fill(入向):每个工具参数调用一次
|
||||
// · FillMap(入向):工具参数通常是嵌套 map
|
||||
// · Alias:HMAC 派生(每次拦截都要算)
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func benchStore(b *testing.B) *Store {
|
||||
b.Helper()
|
||||
dir := b.TempDir()
|
||||
s := New(Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err := s.Load(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ★ 最重要的一个:**未命中**时的代价(绝大多数消息不含凭据)。
|
||||
//
|
||||
// 若这条与"命中时"一个量级,说明模式库每次都在做昂贵的全量扫描 ——
|
||||
// 那是随消息数线性放大的开销。
|
||||
func BenchmarkReplaceNoMatch(b *testing.B) {
|
||||
s := benchStore(b)
|
||||
text := strings.Repeat("这是一段普通的对话内容,讨论架构与实现细节。", 20)
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if out, evts := s.Replace(text, "bench", Patterns); len(evts) != 0 {
|
||||
b.Fatal("不该命中")
|
||||
} else {
|
||||
_ = out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 命中时的代价(含 HMAC 派生 + 落盘)。
|
||||
func BenchmarkReplaceWithMatch(b *testing.B) {
|
||||
s := benchStore(b)
|
||||
text := "token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 请使用"
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, evts := s.Replace(text, "bench", Patterns)
|
||||
if len(evts) == 0 {
|
||||
b.Fatal("应命中")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill:工具参数回填。
|
||||
func BenchmarkFill(b *testing.B) {
|
||||
s := benchStore(b)
|
||||
alias, _ := s.Submit("bench-secret-value-123456", "")
|
||||
text := "Authorization: token @{" + alias + "}"
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = s.Fill(text)
|
||||
}
|
||||
}
|
||||
|
||||
// FillMap:真实工具参数(嵌套)。
|
||||
func BenchmarkFillMap(b *testing.B) {
|
||||
s := benchStore(b)
|
||||
alias, _ := s.Submit("bench-secret-value-123456", "")
|
||||
args := map[string]interface{}{
|
||||
"url": "https://x:@" + alias + "@host/r",
|
||||
"nested": map[string]interface{}{
|
||||
"token": "@{" + alias + "}",
|
||||
"list": []interface{}{"a", "@{" + alias + "}"},
|
||||
},
|
||||
"n": 42,
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.FillMap(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Alias:HMAC 派生(拦截路径上的固定成本)。
|
||||
func BenchmarkAlias(b *testing.B) {
|
||||
s := benchStore(b)
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := s.Alias("some-plain-secret"); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 并发下的吞吐:模拟多 agent 同时出向。
|
||||
func BenchmarkReplaceParallel(b *testing.B) {
|
||||
s := benchStore(b)
|
||||
text := strings.Repeat("普通对话内容,没有凭据。", 10)
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, _ = s.Replace(text, "bench", Patterns)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── provider 装饰器的端到端开销(经真实请求结构) ──
|
||||
|
||||
// BenchmarkProviderScrub 走真实的 api 装饰器路径。
|
||||
//
|
||||
// 放在 security 包里会形成 import 环,所以这里只模拟它的形状:
|
||||
// 逐条消息调 Replace。真实开销 = 消息条数 × Replace。
|
||||
func BenchmarkProviderScrubShape(b *testing.B) {
|
||||
s := benchStore(b)
|
||||
msgs := make([]string, 20)
|
||||
for i := range msgs {
|
||||
msgs[i] = fmt.Sprintf("第 %d 条消息:讨论实现细节,无凭据。%s", i,
|
||||
strings.Repeat("内容。", 30))
|
||||
}
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for _, m := range msgs {
|
||||
_, _ = s.Replace(m, "llm_out", Patterns)
|
||||
}
|
||||
}
|
||||
_ = context.Background
|
||||
}
|
||||
285
internal/security/codec.go
Normal file
285
internal/security/codec.go
Normal file
@ -0,0 +1,285 @@
|
||||
package security
|
||||
|
||||
// 域隔离的加密编解码器(供内核其它模块复用主密钥)。
|
||||
//
|
||||
// ## 为什么要域隔离
|
||||
//
|
||||
// 同一把主密钥会服务多类用途:变量别名的 HMAC、配置落盘的 AES、
|
||||
// (未来可能的)审计日志签名……若它们**共用同一把密钥**,就会产生
|
||||
// 跨域攻击面:一处的输出可能在另一处被当作合法输入。
|
||||
//
|
||||
// 所以每类用途用 **HKDF 风格的派生子密钥**:
|
||||
//
|
||||
// subKey = HMAC-SHA256(masterKey, "homeagent/<domain>/v1")
|
||||
//
|
||||
// 这样一把主密钥可以安全地派生出无限多个互不相关的子密钥,
|
||||
// 而部署方只需要管一把密钥。
|
||||
//
|
||||
// ## 与 Alias() 的关系
|
||||
//
|
||||
// `Alias()` 是**单向**的(明文 → 别名,不可逆),用 HMAC 做哈希。
|
||||
// `Codec` 是**双向**的(明文 ⇄ 密文),用 AES-GCM 做加密。
|
||||
// 二者用途不同、密钥不同 —— 这就是域隔离的意义。
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// EncPrefixV1 是密文的自描述前缀。
|
||||
//
|
||||
// 为何用前缀而不是加列/加表:**无需 schema 迁移**,且明文旧数据
|
||||
// 天然被识别(没有前缀就是明文),逐条惰性加密即可。
|
||||
// 版本号 `v1` 让将来换算法时可以两代并存(读到 v1 用旧算法解)。
|
||||
const EncPrefixV1 = "enc:v1:"
|
||||
|
||||
// ErrBadCiphertext 表示密文格式非法或密钥不对。
|
||||
var ErrBadCiphertext = errors.New("security: 密文无法解密(密钥不对或数据损坏)")
|
||||
|
||||
// Codec 是用派生密钥做对称加解密的编解码器。
|
||||
//
|
||||
// key 为 nil 时 **Enabled()==false**,所有 Seal 退化为「原样返回」。
|
||||
// 这是刻意设计:编解码器未装配时不能崩,但调用方可以据此判断
|
||||
// 「密钥不可用」并选择响亮失败。
|
||||
type Codec struct {
|
||||
key []byte
|
||||
domain string
|
||||
// oldKeys 是轮换前的派生子密钥,仅用于 Open。
|
||||
oldKeys [][]byte
|
||||
}
|
||||
|
||||
// NewCodec 由主密钥派生出某个用途的子密钥。
|
||||
func NewCodec(masterKey []byte, domain string) (*Codec, error) {
|
||||
if len(masterKey) != 32 {
|
||||
return nil, fmt.Errorf("security: 主密钥必须是 32 字节,实际 %d", len(masterKey))
|
||||
}
|
||||
if domain == "" {
|
||||
return nil, errors.New("security: codec 域不能为空(域隔离是安全要求)")
|
||||
}
|
||||
m := hmac.New(sha256.New, masterKey)
|
||||
m.Write([]byte("homeagent/" + domain + "/v1"))
|
||||
return &Codec{key: m.Sum(nil), domain: domain}, nil
|
||||
}
|
||||
|
||||
// Enabled 报告编解码器是否可用。
|
||||
func (c *Codec) Enabled() bool { return c != nil && len(c.key) == 32 }
|
||||
|
||||
// NewCodecWithOldKeys 构造支持平滑轮换的 codec。
|
||||
//
|
||||
// current 是新主密钥;oldMasters 是旧主密钥,**只用于解开旧密文**。
|
||||
// 轮换时若不带旧钥,已落盘的密文会全部解不开(响亮失败但服务不可用)
|
||||
// —— 那就是 E-1 要解决的问题。
|
||||
func NewCodecWithOldKeys(current []byte, domain string, oldMasters [][]byte) (*Codec, error) {
|
||||
if len(current) != 32 {
|
||||
return nil, fmt.Errorf("security: 主密钥必须是 32 字节,实际 %d", len(current))
|
||||
}
|
||||
if domain == "" {
|
||||
return nil, errors.New("security: codec 域不能为空(域隔离是安全要求)")
|
||||
}
|
||||
derive := func(master []byte) []byte {
|
||||
m := hmac.New(sha256.New, master)
|
||||
m.Write([]byte("homeagent/" + domain + "/v1"))
|
||||
return m.Sum(nil)
|
||||
}
|
||||
c := &Codec{key: derive(current), domain: domain}
|
||||
for _, old := range oldMasters {
|
||||
if len(old) == 32 {
|
||||
c.oldKeys = append(c.oldKeys, derive(old))
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Domain 返回用途域(便于诊断,比如日志里说清楚是哪一域的密文解不开)。
|
||||
func (c *Codec) Domain() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.domain
|
||||
}
|
||||
|
||||
// Seal 加密并带上版本前缀。
|
||||
//
|
||||
// 未启用时**原样返回**(明文),这样调用方可以在未装配密钥的
|
||||
// 环境(测试、本地开发)继续工作 —— 但生产必须装配(见 IsEncrypted)。
|
||||
func (c *Codec) Seal(plain string) (string, error) {
|
||||
if !c.Enabled() {
|
||||
return plain, nil
|
||||
}
|
||||
// 已加密的不要再加一层(幂等)
|
||||
if IsEncrypted(plain) {
|
||||
return plain, nil
|
||||
}
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return EncPrefixV1 + encodeB64(sealed), nil
|
||||
}
|
||||
|
||||
// Open 解密。非密文(无前缀)原样返回 —— 这是**存量明文兼容**的关键:
|
||||
// 旧数据不需要一次性迁移就能继续可读。
|
||||
func (c *Codec) Open(stored string) (string, error) {
|
||||
if !IsEncrypted(stored) {
|
||||
return stored, nil // 明文(旧数据或未启用)
|
||||
}
|
||||
if !c.Enabled() {
|
||||
return "", fmt.Errorf("%w: 有密文但编解码器未启用(域 %s)",
|
||||
ErrNoMasterKey, c.Domain())
|
||||
}
|
||||
raw, err := decodeB64(strings.TrimPrefix(stored, EncPrefixV1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: base64 解码失败", ErrBadCiphertext)
|
||||
}
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("%w: 密文太短", ErrBadCiphertext)
|
||||
}
|
||||
// ★ 先试当前钥,再依次试历史钥(E-1 轮换过渡期)。
|
||||
// 不能只试当前钥:轮换后旧条目仍是旧钥密文,只试新钥会让它们
|
||||
// 全部变成“解不开”,而那正是要避免的服务不可用。
|
||||
keys := append([][]byte{c.key}, c.oldKeys...)
|
||||
for _, k := range keys {
|
||||
block, err := aes.NewCipher(k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err == nil {
|
||||
return string(plain), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w (域 %s)", ErrBadCiphertext, c.Domain())
|
||||
}
|
||||
|
||||
// IsEncrypted 报告一个已存储的值是否是本模块的密文。
|
||||
//
|
||||
// ★ 单调为真:密文解密后可能恰好以 `enc:v1:` 开头(极小概率),
|
||||
// 但那会导致重复解密失败而非静默错误,可接受。
|
||||
func IsEncrypted(stored string) bool {
|
||||
return strings.HasPrefix(stored, EncPrefixV1)
|
||||
}
|
||||
|
||||
// ---------------- 全局配置编解码器 ----------------
|
||||
|
||||
var (
|
||||
globalCodecMu sync.RWMutex
|
||||
globalCodec *Codec
|
||||
)
|
||||
|
||||
// InitConfigCodec 装配配置落盘用的编解码器(内核启动时调用一次)。
|
||||
func InitConfigCodec(masterKey []byte) (*Codec, error) {
|
||||
return InitConfigCodecWithOldKeys(masterKey, nil)
|
||||
}
|
||||
|
||||
// InitConfigCodecWithOldKeys 装配支持 E-1 平滑轮换的配置 codec。
|
||||
// 当前钥负责 Seal;旧钥仅参与 Open。
|
||||
|
||||
func InitConfigCodecWithOldKeys(masterKey []byte, oldKeys [][]byte) (*Codec, error) {
|
||||
c, err := NewCodecWithOldKeys(masterKey, "config-at-rest", oldKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
globalCodecMu.Lock()
|
||||
globalCodec = c
|
||||
globalCodecMu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ConfigCodec 取配置编解码器;未装配时返回 nil(调用方原样读写)。
|
||||
func ConfigCodec() *Codec {
|
||||
globalCodecMu.RLock()
|
||||
defer globalCodecMu.RUnlock()
|
||||
return globalCodec
|
||||
}
|
||||
|
||||
// NeedsRekey 报告该密文是否**不是**当前主密钥加的(即轮换后尚未重加密)。
|
||||
//
|
||||
// 用途:轮换后需要知道哪些 config 项要重写;只需先试当前钥能不能开。
|
||||
// 非密文(明文/旧数据)返回 false(那是 EncryptExistingSecrets 的事)。
|
||||
func (c *Codec) NeedsRekey(stored string) bool {
|
||||
if !c.Enabled() || !IsEncrypted(stored) {
|
||||
return false
|
||||
}
|
||||
_, err := c.openWithSingleKey(c.key, stored)
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// Rekey 把一条密文(可能是旧钥加的)用当前钥重新加密。
|
||||
// 非密文原样返回;旧钥也解不开则**响亮报错**(不静默丢数据)。
|
||||
func (c *Codec) Rekey(stored string) (string, error) {
|
||||
if !c.Enabled() || !IsEncrypted(stored) {
|
||||
return stored, nil
|
||||
}
|
||||
if _, err := c.openWithSingleKey(c.key, stored); err == nil {
|
||||
return stored, nil // 已是当前钥,幂等
|
||||
}
|
||||
plain, err := c.Open(stored)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.Seal(plain)
|
||||
}
|
||||
|
||||
// openWithSingleKey 只用指定的一把钥试解(供 NeedsRekey 判定)。
|
||||
func (c *Codec) openWithSingleKey(key []byte, stored string) (string, error) {
|
||||
raw, err := decodeB64(strings.TrimPrefix(stored, EncPrefixV1))
|
||||
if err != nil {
|
||||
return "", ErrBadCiphertext
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", ErrBadCiphertext
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", ErrBadCiphertext
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// ClearConfigCodec 卸下配置编解码器(测试与"显式关闭加密"用)。
|
||||
//
|
||||
// 单独一个函数而不是让 InitConfigCodec(nil) 承担:
|
||||
// 后者语义模糊(nil 是"没密钥"还是"要清空"?),且会掩盖调用方的错误
|
||||
// —— 曾因此让一个判据假绿(以为清空了,其实上一把密钥还在)。
|
||||
func ClearConfigCodec() {
|
||||
globalCodecMu.Lock()
|
||||
globalCodec = nil
|
||||
globalCodecMu.Unlock()
|
||||
}
|
||||
51
internal/security/codec_rotation_test.go
Normal file
51
internal/security/codec_rotation_test.go
Normal file
@ -0,0 +1,51 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCodecRotationReadsOldAndSealsNew(t *testing.T) {
|
||||
oldKey := bytes.Repeat([]byte{0x71}, 32)
|
||||
newKey := bytes.Repeat([]byte{0x72}, 32)
|
||||
old, err := NewCodec(oldKey, "rotation-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealedOld, err := old.Seal("CONFIG_SECRET")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := NewCodecWithOldKeys(newKey, "rotation-test", [][]byte{oldKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := rotated.Open(sealedOld)
|
||||
if err != nil || plain != "CONFIG_SECRET" {
|
||||
t.Fatalf("old ciphertext unreadable: %q %v", plain, err)
|
||||
}
|
||||
if !rotated.NeedsRekey(sealedOld) {
|
||||
t.Fatal("old ciphertext should need rekey")
|
||||
}
|
||||
sealedNew, err := rotated.Rekey(sealedOld)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rotated.NeedsRekey(sealedNew) {
|
||||
t.Fatal("rekeyed ciphertext still needs rekey")
|
||||
}
|
||||
if got, err := rotated.Open(sealedNew); err != nil || got != "CONFIG_SECRET" {
|
||||
t.Fatalf("new ciphertext unreadable: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecRotationWithoutOldKeyFailsLoudly(t *testing.T) {
|
||||
oldKey := bytes.Repeat([]byte{0x81}, 32)
|
||||
newKey := bytes.Repeat([]byte{0x82}, 32)
|
||||
old, _ := NewCodec(oldKey, "rotation-test")
|
||||
sealed, _ := old.Seal("MUST_NOT_DISAPPEAR")
|
||||
rotated, _ := NewCodec(newKey, "rotation-test")
|
||||
if _, err := rotated.Open(sealed); err == nil {
|
||||
t.Fatal("★ missing old key silently accepted ciphertext")
|
||||
}
|
||||
}
|
||||
144
internal/security/embedded.go
Normal file
144
internal/security/embedded.go
Normal file
@ -0,0 +1,144 @@
|
||||
package security
|
||||
|
||||
// 内嵌主密钥(**字面内嵌**,按用户 2026-10-16 的明确选择)。
|
||||
//
|
||||
// ## 这是什么
|
||||
//
|
||||
// 把主密钥在**编译期**烧进二进制,运行期作为密钥来源链的**最高优先级**
|
||||
// (在 systemd 凭据之前)。目的是「防丢失」:二进制在,密钥就在,
|
||||
// 不存在"security.key 被误删/没跟着备份"导致全部密文永久解不开。
|
||||
//
|
||||
// ## ★ 安全代价(必须写清,不能含糊)
|
||||
//
|
||||
// 这条改动的代价是**真实的、且方向与加密的初衷相反**:
|
||||
//
|
||||
// 加密要防的是「文件被读走」。而内嵌后:
|
||||
// · homed 是 0755、config.db 是 0644 —— 二者同级可达
|
||||
// · 于是"能读二进制"⇒"能拿密钥"⇒"能解密 config.db"
|
||||
// ⇒ **对本机读取者,加密不再提供任何保护**
|
||||
// · 且二进制会进备份/镜像/分发包
|
||||
// (实测:/home/program/_guard_backup/*/ 里 homed 与 config.db 成对出现)
|
||||
//
|
||||
// 换言之:内嵌换来"不会因丢文件而丢数据",代价是"文件被读走就等于全泄"。
|
||||
// 这是用户知情后的选择,不是我们的疏忽 —— 但**任何降级/绕过都必须留痕**,
|
||||
// 所以下面每次用到内嵌钥都会 log.Printf 说明当前密钥来自内嵌。
|
||||
//
|
||||
// ## 为什么用 ldflags 而不是 go:embed 一个文件
|
||||
//
|
||||
// 本仓有两个**公开**远端(gitcode / github)。`go:embed` 要求密钥是一个
|
||||
// 仓库内的文件 —— 那意味着它会被提交、被推送、永久留在 git 历史里,
|
||||
// 且任何 clone 的人都能拿到。`-ldflags -X` 让密钥只存在于**构建命令行**
|
||||
// 与最终二进制中,仓库里一行都不留。
|
||||
//
|
||||
// go build -ldflags "-X .../internal/security.embeddedKeyHex=<64位hex>" ./cmd/homed
|
||||
//
|
||||
// ## 为什么不是"只信内嵌"(丢数据的陷阱)
|
||||
//
|
||||
// 最危险的做法是:有内嵌钥就**一切以它为准**、忽略别处。
|
||||
// 那样一台已经用磁盘钥加密过的老机器,升级到这个二进制后会:
|
||||
//
|
||||
// ① 用内嵌钥去解磁盘钥加的密文 → 全部解不开
|
||||
// ② 若此时"解不开就重建库"→ **静默丢掉全部凭据**
|
||||
//
|
||||
// 所以这里**只把内嵌钥当一个来源**,并且在磁盘/keyring 存在时把它作为
|
||||
// **额外的解密钥**(可读旧密文),而不是替换现有钥。优先顺序见
|
||||
// LoadOrCreateKey:内嵌 > systemd > env > 磁盘。
|
||||
//
|
||||
// ★ 并且内嵌钥与磁盘钥**不一致**时必须响亮告警(不能静默选一把继续):
|
||||
// 那意味着这台机器上存在两套互不可解的密文,用户必须知道。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// embeddedKeyHex 由构建时的 -ldflags 注入(64 位 hex)。
|
||||
//
|
||||
// ★ 默认空值:没有注入时,行为与改动前**逐字相同**(走 systemd/env/磁盘链)。
|
||||
// 这是必须的 —— 否则用默认 Makefile 构建出的二进制会在所有老部署上
|
||||
// "找不到密钥",那是一个破坏性变更。
|
||||
var embeddedKeyHex string
|
||||
|
||||
// EmbeddedKeyPresent 报告本二进制是否内嵌了主密钥。
|
||||
func EmbeddedKeyPresent() bool { return embeddedKeyHex != "" }
|
||||
|
||||
// embeddedKey 解出内嵌钥(未内嵌或格式非法时返回 nil)。
|
||||
//
|
||||
// 格式非法**不** Fatal:内嵌是由构建脚本注入的,格式错是构建问题;
|
||||
// 运行期直接崩会让"二进制起来了但服务全挂"难以归因。这里降级为
|
||||
// 响亮告警 + 回退到其它来源(安全性上不损失:回退路径本身是可信的)。
|
||||
func embeddedKey() []byte {
|
||||
if embeddedKeyHex == "" {
|
||||
return nil
|
||||
}
|
||||
k, err := parseKey(embeddedKeyHex)
|
||||
if err != nil {
|
||||
log.Printf("[security] ★ 内嵌主密钥格式非法(构建时 -ldflags 注入有误),已忽略: %v", err)
|
||||
return nil
|
||||
}
|
||||
if len(k) != 32 {
|
||||
log.Printf("[security] ★ 内嵌主密钥长度 %d 非法(需 32 字节),已忽略", len(k))
|
||||
return nil
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// loadEmbeddedKey 取内嵌钥并说明来源(供 LoadOrCreateKey 使用)。
|
||||
//
|
||||
// 它额外做一件 LoadOrCreateKey 不该独自承担的事:**报告与磁盘钥是否一致**。
|
||||
func loadEmbeddedKey(dataDir string) ([]byte, bool) {
|
||||
k := embeddedKey()
|
||||
if k == nil {
|
||||
return nil, false
|
||||
}
|
||||
// 与磁盘钥比对(仅提示,不阻断)
|
||||
if diskPath := filepath.Join(dataDir, KeyFileName); fileExistsSimple(diskPath) {
|
||||
if b, err := os.ReadFile(diskPath); err == nil {
|
||||
if dk, err := parseKey(string(trimSpaceBytes(b))); err == nil {
|
||||
if !sameKey(dk, k) {
|
||||
log.Printf("[security] ★★ 内嵌主密钥与 %s **不一致**:"+
|
||||
"该机器上存在两套互不可解的密文(内嵌钥能解一部分、磁盘钥能解另一部分)。"+
|
||||
"本次以**内嵌钥为当前钥**,磁盘钥请通过 security.keyring 的 history "+
|
||||
"或 HOMED_SECURITY_KEY 保留,否则旧密文会读不出来", diskPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return k, true
|
||||
}
|
||||
|
||||
// EmbeddedKeyFingerprint 返回内嵌钥的短指纹(用于日志/自检,**不泄漏密钥**)。
|
||||
//
|
||||
// 用 hex 前 8 位:够区分两把不同的钥,又不足以从指纹还原。
|
||||
// 绝不打印密钥本身 —— 日志会进 journal、被导出、被贴进 issue。
|
||||
func EmbeddedKeyFingerprint() string {
|
||||
k := embeddedKey()
|
||||
if k == nil {
|
||||
return ""
|
||||
}
|
||||
s := fmt.Sprintf("%x", k)
|
||||
return s[:8]
|
||||
}
|
||||
|
||||
func trimSpaceBytes(b []byte) []byte {
|
||||
i, j := 0, len(b)
|
||||
for i < j && isSpaceByte(b[i]) {
|
||||
i++
|
||||
}
|
||||
for j > i && isSpaceByte(b[j-1]) {
|
||||
j--
|
||||
}
|
||||
return b[i:j]
|
||||
}
|
||||
|
||||
func isSpaceByte(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
}
|
||||
|
||||
// fileExistsSimple 是本地最小实现(避免与包内其它 fileExists 重名冲突)。
|
||||
func fileExistsSimple(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && !st.IsDir()
|
||||
}
|
||||
269
internal/security/embedded_test.go
Normal file
269
internal/security/embedded_test.go
Normal file
@ -0,0 +1,269 @@
|
||||
package security
|
||||
|
||||
// 内嵌主密钥的判据。
|
||||
//
|
||||
// 这组判据要证明的核心**不是**"内嵌能生效",而是
|
||||
// **"内嵌不会把已有数据变成读不出来的垃圾"** —— 那才是这条改动真正的风险。
|
||||
// 一个只测"有内嵌钥就用它"的判据会全绿,而线上老机器升级后会解不开库。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withEmbeddedKey 临时把内嵌钥设成 k(测试结束还原)。
|
||||
//
|
||||
// 用直接赋值而不是 ldflags:判据要能覆盖"内嵌/未内嵌"两种形态。
|
||||
func withEmbeddedKey(t *testing.T, k string) {
|
||||
t.Helper()
|
||||
old := embeddedKeyHex
|
||||
embeddedKeyHex = k
|
||||
t.Cleanup(func() { embeddedKeyHex = old })
|
||||
}
|
||||
|
||||
// ★★★ 判据:未内嵌时行为**逐字不变**(否则是个破坏性变更)。
|
||||
//
|
||||
// 默认 Makefile 构建出的二进制没有内嵌钥,必须在所有老部署上照常工作:
|
||||
// 仍走 systemd/env/磁盘链,且没有磁盘钥时仍会生成。
|
||||
func TestNoEmbeddedKeyKeepsLegacyBehavior(t *testing.T) {
|
||||
withEmbeddedKey(t, "")
|
||||
if EmbeddedKeyPresent() {
|
||||
t.Fatal("空串不应算作已内嵌")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
// ① 无磁盘钥 → 生成并落盘(老行为)
|
||||
k1, src1, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src1 == "" || src1[0:9] == "embedded" {
|
||||
t.Errorf("未内嵌时来源不该是 embedded: %q", src1)
|
||||
}
|
||||
p := filepath.Join(dir, KeyFileName)
|
||||
if fi, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("应生成 %s: %v", KeyFileName, err)
|
||||
} else if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("密钥文件权限应为 0600,实际 %v", fi.Mode().Perm())
|
||||
}
|
||||
// ② 再读应拿到同一把(稳定)
|
||||
k2, _, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameKey(k1, k2) {
|
||||
t.Fatal("同一目录两次读取应得到同一把钥")
|
||||
}
|
||||
// ③ 无额外解密钥
|
||||
if extra := AdditionalDecryptKeys(dir, k1); len(extra) != 0 {
|
||||
t.Errorf("未内嵌时不该有额外解密钥: %d", len(extra))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:内嵌钥被采用,且**来源可辨识**、指纹不泄漏密钥本身。
|
||||
func TestEmbeddedKeyIsUsed(t *testing.T) {
|
||||
hexKey := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
||||
withEmbeddedKey(t, hexKey)
|
||||
if !EmbeddedKeyPresent() {
|
||||
t.Fatal("应报告已内嵌")
|
||||
}
|
||||
want, _ := parseKey(hexKey)
|
||||
|
||||
dir := t.TempDir()
|
||||
got, src, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameKey(got, want) {
|
||||
t.Fatal("★ 未采用内嵌钥")
|
||||
}
|
||||
// 来源必须能看出是内嵌(运维要能一眼判断"这把钥从哪来")
|
||||
if len(src) < 8 || src[:8] != "embedded" {
|
||||
t.Errorf("来源应标明 embedded: %q", src)
|
||||
}
|
||||
// 指纹不得包含完整密钥
|
||||
fp := EmbeddedKeyFingerprint()
|
||||
if fp == "" || len(fp) != 8 {
|
||||
t.Errorf("指纹形状不对: %q", fp)
|
||||
}
|
||||
if len(fp) >= len(hexKey) {
|
||||
t.Error("★ 指纹不得等于/长于密钥本身(会泄漏)")
|
||||
}
|
||||
// 内嵌优先于磁盘:即使磁盘有另一把钥,也以内嵌为准
|
||||
other, _ := parseKey("1111111111111111111111111111111111111111111111111111111111111111")
|
||||
if err := os.WriteFile(filepath.Join(dir, KeyFileName), []byte(hexKeyOf(other)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got2, _, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameKey(got2, want) {
|
||||
t.Error("★ 内嵌钥应优先于磁盘钥")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ **最关键的一条**:内嵌钥与磁盘钥不同时,磁盘钥必须进入**解密集合**。
|
||||
//
|
||||
// 这是"内嵌"与"防丢失"的交界处。反例(本判据要挡住的):
|
||||
//
|
||||
// 老机器用磁盘钥加密过 config.db → 升级到内嵌了另一把钥的二进制
|
||||
// → 只信内嵌钥 → 旧密文全解不开 → 内核响亮失败 → 用户"重建库" ⇒ **凭据全丢**
|
||||
//
|
||||
// 那恰好与内嵌的初衷相反。所以磁盘钥必须继续参与 Open。
|
||||
func TestEmbeddedKeyKeepsDiskKeyForDecrypt(t *testing.T) {
|
||||
hexKey := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
||||
withEmbeddedKey(t, hexKey)
|
||||
embedded, _ := parseKey(hexKey)
|
||||
|
||||
dir := t.TempDir()
|
||||
// 磁盘上是**另一把**钥(模拟已存在的旧部署)
|
||||
disk, _ := parseKey("2222222222222222222222222222222222222222222222222222222222222222")
|
||||
if err := os.WriteFile(filepath.Join(dir, KeyFileName), []byte(hexKeyOf(disk)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
extra := AdditionalDecryptKeys(dir, embedded)
|
||||
if len(extra) != 1 {
|
||||
t.Fatalf("★ 磁盘钥必须作为额外解密钥装入,否则旧密文全丢;实际 %d 把", len(extra))
|
||||
}
|
||||
if !sameKey(extra[0], disk) {
|
||||
t.Fatal("额外解密钥不是磁盘那把")
|
||||
}
|
||||
|
||||
// ★ 端到端:用磁盘钥加密的密文,在"当前钥=内嵌钥 + 额外钥=磁盘钥"下**必须能解开**
|
||||
dc, err := NewCodec(disk, "config-at-rest")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipherText, err := dc.Seal("旧的敏感值")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nc, err := NewCodecWithOldKeys(embedded, "config-at-rest", extra)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := nc.Open(cipherText)
|
||||
if err != nil {
|
||||
t.Fatalf("★ 旧密文解不开(这正是内嵌会导致的数据丢失): %v", err)
|
||||
}
|
||||
if plain != "旧的敏感值" {
|
||||
t.Fatalf("解出的值不对: %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:磁盘钥与内嵌钥**相同**时不重复添加(幂等、不污染解密集合)。
|
||||
func TestEmbeddedKeySameAsDiskNoDuplicate(t *testing.T) {
|
||||
hexKey := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
||||
withEmbeddedKey(t, hexKey)
|
||||
k, _ := parseKey(hexKey)
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, KeyFileName), []byte(hexKeyOf(k)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if extra := AdditionalDecryptKeys(dir, k); len(extra) != 0 {
|
||||
t.Errorf("同钥不该产生额外解密钥: %d", len(extra))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:内嵌钥格式非法时**不崩**、且退回其它来源。
|
||||
//
|
||||
// 内嵌是构建脚本注入的,格式错是构建问题。运行期直接崩会让
|
||||
// "二进制起来了但服务全挂"难以归因;而回退路径本身是可信的,安全性不损失。
|
||||
func TestEmbeddedKeyMalformedFallsBack(t *testing.T) {
|
||||
for _, bad := range []string{"not-hex", "abcd", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"} {
|
||||
withEmbeddedKey(t, bad)
|
||||
dir := t.TempDir()
|
||||
k, src, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("格式 %q 不该导致失败: %v", bad, err)
|
||||
}
|
||||
if len(k) != 32 {
|
||||
t.Errorf("格式 %q 应回退到 32 字节钥,实际 %d", bad, len(k))
|
||||
}
|
||||
if len(src) >= 8 && src[:8] == "embedded" {
|
||||
t.Errorf("★ 非法内嵌钥不该被采用(否则会用一把错钥加密新数据): %q", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:内嵌升级场景下,**keyring 的 current 也要进解密集合**(不能 Fatal)。
|
||||
//
|
||||
// 一台已经轮换过钥的机器(有 security.keyring)升级到内嵌二进制时,
|
||||
// keyring 的 current 是**旧磁盘钥**,与当前(内嵌)钥不同。
|
||||
// 按原有逻辑这会 Fatal「拒绝静默选错钥」—— 但内嵌场景下它不是错钥,
|
||||
// 是"密钥来源升级了",必须按额外解密钥保留。
|
||||
func TestKeyringCurrentBecomesDecryptKeyUnderEmbedded(t *testing.T) {
|
||||
hexKey := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
||||
withEmbeddedKey(t, hexKey)
|
||||
embedded, _ := parseKey(hexKey)
|
||||
|
||||
dir := t.TempDir()
|
||||
// 老机器状态:磁盘钥 + keyring(current = 磁盘钥,含一把更老的历史钥)
|
||||
disk, _ := parseKey("3333333333333333333333333333333333333333333333333333333333333333")
|
||||
older, _ := parseKey("4444444444444444444444444444444444444444444444444444444444444444")
|
||||
if err := os.WriteFile(filepath.Join(dir, KeyFileName), []byte(hexKeyOf(disk)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RotateKeyFile(dir, older, disk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ★ 必须成功(不 Fatal),且能把 disk 与 older 都作为解密钥拿到
|
||||
old, err := LoadKeyring(dir, embedded)
|
||||
if err != nil {
|
||||
t.Fatalf("★ 内嵌升级场景下 LoadKeyring 不该失败(会让老机器起不来): %v", err)
|
||||
}
|
||||
has := func(k []byte) bool {
|
||||
for _, o := range old {
|
||||
if sameKey(o, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has(disk) {
|
||||
t.Error("★ keyring 的 current(旧磁盘钥)必须进解密集合")
|
||||
}
|
||||
if !has(older) {
|
||||
t.Error("keyring 的 history 必须进解密集合")
|
||||
}
|
||||
// 当前(内嵌)钥不该出现在解密集合里(它本来就在)
|
||||
if has(embedded) {
|
||||
t.Error("当前钥不该重复出现在解密集合里")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**没有**内嵌时,keyring 与磁盘钥不一致仍然必须响亮失败。
|
||||
//
|
||||
// 上面那条放宽只对内嵌场景成立;没有内嵌时"选错钥"依然是严重错误,
|
||||
// 必须保持原有的拒绝行为(否则这个放宽会变成普遍性的安全退化)。
|
||||
func TestKeyringMismatchStillFatalWithoutEmbedded(t *testing.T) {
|
||||
withEmbeddedKey(t, "")
|
||||
dir := t.TempDir()
|
||||
disk, _ := parseKey("5555555555555555555555555555555555555555555555555555555555555555")
|
||||
older, _ := parseKey("6666666666666666666666666666666666666666666666666666666666666666")
|
||||
if err := os.WriteFile(filepath.Join(dir, KeyFileName), []byte(hexKeyOf(disk)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RotateKeyFile(dir, older, disk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 传一把**不相干**的当前钥 → 必须报错
|
||||
stranger, _ := parseKey("7777777777777777777777777777777777777777777777777777777777777777")
|
||||
if _, err := LoadKeyring(dir, stranger); err == nil {
|
||||
t.Fatal("★ 无内嵌时 keyring current 与当前钥不一致必须响亮失败(不得静默选错钥)")
|
||||
}
|
||||
}
|
||||
|
||||
func hexKeyOf(k []byte) string {
|
||||
const hx = "0123456789abcdef"
|
||||
out := make([]byte, 0, 64)
|
||||
for _, b := range k {
|
||||
out = append(out, hx[b>>4], hx[b&0xf])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
101
internal/security/global.go
Normal file
101
internal/security/global.go
Normal file
@ -0,0 +1,101 @@
|
||||
package security
|
||||
|
||||
// 进程级单例与两个入向填充入口。
|
||||
//
|
||||
// ## 依赖方向(重要)
|
||||
//
|
||||
// internal/security ← 纯逻辑,不 import 任何 agent 包
|
||||
// ↑
|
||||
// internal/agent/api provider 装饰器(出向改写)
|
||||
// internal/agent/core 工具参数/载荷填充(入向回填)
|
||||
//
|
||||
// 是 DAG,**没有 import 环**。所以装饰器可以直接用内核的 Provider 接口,
|
||||
// 不必做接口投影。(初版误以为会成环而写了一层投影接口,已删除。)
|
||||
//
|
||||
// ## 为何单例必须是进程级
|
||||
//
|
||||
// 已核:主 / 子 / 驻留子 agent 共享同一个 ProviderManager
|
||||
//(`resident.go:185`;manager 全进程只建一份 `bootstrap.go:249`)。
|
||||
// 所以变量库挂在 provider 层天然就是进程级 —— **不需要跨 agent 同步**。
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
globalMu sync.RWMutex
|
||||
global *Store
|
||||
)
|
||||
|
||||
// InitGlobal 装配进程级单例。**必须在任何 agent 启动前调用**。
|
||||
func InitGlobal(opt Options) (*Store, error) {
|
||||
s := New(opt)
|
||||
if err := s.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
globalMu.Lock()
|
||||
global = s
|
||||
globalMu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetGlobal 直接装入一个已构造的 Store(测试与特殊装配用)。
|
||||
func SetGlobal(s *Store) {
|
||||
globalMu.Lock()
|
||||
global = s
|
||||
globalMu.Unlock()
|
||||
}
|
||||
|
||||
// Global 取进程级单例;未初始化时返回 nil。
|
||||
//
|
||||
// ★ 为何返回 nil 而不报错:调用点散布在内核热路径上,
|
||||
// 未启用时必须**零成本跳过**。所有调用方都写成
|
||||
// `if s := Global(); s != nil { ... }`。
|
||||
func Global() *Store {
|
||||
globalMu.RLock()
|
||||
defer globalMu.RUnlock()
|
||||
return global
|
||||
}
|
||||
|
||||
// Enabled 报告安全模块是否已启用。
|
||||
func Enabled() bool {
|
||||
s := Global()
|
||||
return s != nil && s.enabled
|
||||
}
|
||||
|
||||
// ---------------- 入向填充(插件无感知的关键) ----------------
|
||||
|
||||
// FillToolArgs 把一个工具调用的参数里的 `@{别名}` 换回明文。
|
||||
//
|
||||
// 这是**内核工具链的入口**(`core.StageHost.ExecuteTool`):
|
||||
// 插件收到的参数已经填好,它完全不知道有替换这回事。
|
||||
//
|
||||
// 返回 (填充处数, 用到的别名)。未初始化时返回 (0, nil)。
|
||||
func FillToolArgs(tool string, args map[string]interface{}) (int, []string) {
|
||||
s := Global()
|
||||
if s == nil || args == nil {
|
||||
return 0, nil
|
||||
}
|
||||
n, used := s.FillMap(args)
|
||||
if n > 0 {
|
||||
log.Printf("[security] 工具 %s 参数回填 %d 处(别名 %v)", tool, n, used)
|
||||
}
|
||||
return n, used
|
||||
}
|
||||
|
||||
// FillPayload 把一段文本载荷里的 `@{别名}` 换回明文。
|
||||
//
|
||||
// 用于**设备输出通道**(`core` 的 `dev.Execute`):发给设备的指令同理
|
||||
// 需要真实凭据,而设备侧对替换无感知。
|
||||
func FillPayload(where, text string) string {
|
||||
s := Global()
|
||||
if s == nil || text == "" {
|
||||
return text
|
||||
}
|
||||
out, n, used := s.Fill(text)
|
||||
if n > 0 {
|
||||
log.Printf("[security] %s 载荷回填 %d 处(别名 %v)", where, n, used)
|
||||
}
|
||||
return out
|
||||
}
|
||||
168
internal/security/harden.go
Normal file
168
internal/security/harden.go
Normal file
@ -0,0 +1,168 @@
|
||||
package security
|
||||
|
||||
// 数据目录与敏感文件的权限收敛。
|
||||
//
|
||||
// ## 为什么必须有这一层(与内嵌主密钥配套)
|
||||
//
|
||||
// 用户选择把主密钥**内嵌**进二进制(`embedded.go`)。那条路的代价是:
|
||||
// 密钥就在 `homed` 里,而 `homed` 出厂是 0755、`config.db` 是 0644 ——
|
||||
// 二者同级可达,于是「能读二进制 ⇒ 能拿密钥 ⇒ 能解密数据库」。
|
||||
//
|
||||
// 缓解手段很直接:**把可达性收窄到 root**。这不是把内嵌变成安全设计,
|
||||
// 而是把它的暴露面从"本机任何用户"缩小到"root"——而那正是唯一本来就
|
||||
// 能读全部凭据的身份(服务以 root 运行,进程内存里本就有明文)。
|
||||
//
|
||||
// ## 为什么写进代码而不是手工 chmod
|
||||
//
|
||||
// 手工 chmod 会在下一次重建/换机器/恢复备份时**悄悄失效**,
|
||||
// 而症状是"加密还在,但保护已经没了"——没人会注意到。
|
||||
// 所以收敛必须每次启动都跑(幂等),且**收紧失败要响亮**:
|
||||
// 权限没收紧意味着"以为受保护其实没有",属于必须知道的安全事实。
|
||||
//
|
||||
// ## 只收紧、不放松
|
||||
//
|
||||
// 绝不把已经更严的权限改回宽(除非显式要求):一个被管理员手工设成 0400
|
||||
// 的文件不该被启动流程"修正"成 0600。所以只在**比目标宽**时才动。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 目标权限(尽量小;全部服务以 root 运行,实测无其他读者)。
|
||||
const (
|
||||
// DirPerm 数据目录:0750 —— 同组(root)可进入,其他用户不可。
|
||||
// 不用 0700 是因为保留 group 访问的余地(将来若拆出专用组,只需改组属主)。
|
||||
DirPerm = 0o750
|
||||
|
||||
// FilePerm 敏感文件(config.db 及其 WAL/SHM、密钥、密钥环、变量库):0600。
|
||||
FilePerm = 0o600
|
||||
)
|
||||
|
||||
// HardenResult 报告收敛动作(供日志与自检工具使用)。
|
||||
type HardenResult struct {
|
||||
// Changed 是被实际改动的路径(含从→到)。
|
||||
Changed []string
|
||||
// Errors 是收紧失败的项。**非空必须响亮**:权限没收紧 = 保护没生效。
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// sensitiveFileNames 是需要 0600 的文件/通配(相对数据目录)。
|
||||
//
|
||||
// 为什么把 config.db 的 `-wal` / `-shm` 也算上:SQLite 的 WAL 模式会把
|
||||
// **未合并的最新数据**放在 `-wal` 里 —— 只收紧主库而留下 0644 的 `-wal`,
|
||||
// 等于把最近写入的(可能正是密钥的)内容继续暴露给所有人。
|
||||
// 这个坑很隐蔽:`ls -l config.db` 看起来是对的。
|
||||
var sensitiveFileNames = []string{
|
||||
"config.db",
|
||||
"config.db-wal",
|
||||
"config.db-shm",
|
||||
KeyFileName, // security.key
|
||||
KeyringFileName, // security.keyring
|
||||
}
|
||||
|
||||
// sensitiveDirs 是需要 0750 的子目录(相对数据目录)。
|
||||
var sensitiveDirs = []string{
|
||||
"security", // 变量库 variables.json 所在处
|
||||
}
|
||||
|
||||
// HardenDataDir 幂等地把数据目录与敏感文件收敛到最小权限。
|
||||
//
|
||||
// 它**不**因为个别失败就中止启动:一个权限收紧失败不该让服务不可用
|
||||
// (那会把安全加固变成可用性事故)。但失败项会逐条返回,
|
||||
// 调用方必须**响亮记录**。
|
||||
func HardenDataDir(dataDir string) HardenResult {
|
||||
var res HardenResult
|
||||
if dataDir == "" {
|
||||
return res
|
||||
}
|
||||
|
||||
// ① 数据目录本身
|
||||
if ch, err := tightenPath(dataDir, DirPerm); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", dataDir, err))
|
||||
} else if ch != "" {
|
||||
res.Changed = append(res.Changed, ch)
|
||||
}
|
||||
|
||||
// ② 敏感子目录
|
||||
for _, d := range sensitiveDirs {
|
||||
p := filepath.Join(dataDir, d)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
continue // 不存在就跳过(不是错误)
|
||||
}
|
||||
if ch, err := tightenPath(p, DirPerm); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", p, err))
|
||||
} else if ch != "" {
|
||||
res.Changed = append(res.Changed, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 敏感文件
|
||||
for _, f := range sensitiveFileNames {
|
||||
p := filepath.Join(dataDir, f)
|
||||
st, err := os.Stat(p)
|
||||
if err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
if ch, err := tightenPath(p, FilePerm); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", p, err))
|
||||
} else if ch != "" {
|
||||
res.Changed = append(res.Changed, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 变量库(在 security/ 下)
|
||||
for _, f := range []string{"variables.json", "variables.json.tmp"} {
|
||||
p := filepath.Join(dataDir, "security", f)
|
||||
st, err := os.Stat(p)
|
||||
if err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
if ch, err := tightenPath(p, FilePerm); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", p, err))
|
||||
} else if ch != "" {
|
||||
res.Changed = append(res.Changed, ch)
|
||||
}
|
||||
}
|
||||
|
||||
if len(res.Errors) > 0 {
|
||||
// 响亮:权限没收紧意味着"以为受保护其实没有"。
|
||||
for _, e := range res.Errors {
|
||||
log.Printf("[security] ★★ 权限收紧失败(该项仍可能对同机其他用户可读): %s", e)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// tightenPath 在**当前权限比目标宽**时收紧到目标。
|
||||
//
|
||||
// 返回非空字符串表示确实改了(内容形如 "0755→0750 /path"),
|
||||
// 便于日志说清"改了什么"。
|
||||
//
|
||||
// ★ 只收紧不放松:已经更严(如 0400)的文件保持原样 ——
|
||||
// 一个被管理员特意设严的文件不该被启动流程"修正"。
|
||||
func tightenPath(p string, want os.FileMode) (string, error) {
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cur := st.Mode().Perm()
|
||||
if cur == want || !isLooser(cur, want) {
|
||||
return "", nil
|
||||
}
|
||||
if err := os.Chmod(p, want); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%04o→%04o %s", cur, want, p), nil
|
||||
}
|
||||
|
||||
// isLooser 报告 cur 是否比 want 宽松(任一位是 cur 有而 want 没有的)。
|
||||
//
|
||||
// 例如 cur=0644 / want=0600 → 宽松(group/other 可读);
|
||||
//
|
||||
// cur=0400 / want=0600 → **不**宽松(反而更严,不该动)
|
||||
func isLooser(cur, want os.FileMode) bool {
|
||||
return cur&^want != 0
|
||||
}
|
||||
198
internal/security/key.go
Normal file
198
internal/security/key.go
Normal file
@ -0,0 +1,198 @@
|
||||
package security
|
||||
|
||||
// 主密钥管理(设计文档 T3)。
|
||||
//
|
||||
// ## 密钥从哪来
|
||||
//
|
||||
// 优先顺序(第一个可用者胜出):
|
||||
//
|
||||
// 1. systemd 凭据文件 $CREDENTIALS_DIRECTORY/homed_security_key
|
||||
// —— 生产首选:systemd 解密到 tmpfs(只读、不在磁盘、不进 environ)
|
||||
// 2. 环境变量 HOMED_SECURITY_KEY(hex 64 字符 / base64)
|
||||
// —— 兼容手写 systemd / 容器注入
|
||||
// 3. <dataDir>/security.key 文件(0600,首次启动自动生成)
|
||||
// —— 开发机 / 无 systemd 场景
|
||||
//
|
||||
// ## 为什么把 systemd 凭据放在 env 之前
|
||||
//
|
||||
// 环境变量对同用户进程**可见**(/proc/<pid>/environ),任何以该用户身份运行的
|
||||
// 进程都能读到主密钥 —— 那等于把整层加密打开。systemd 的 LoadCredential 把
|
||||
// 内容落到一个 tmpfs 文件($CREDENTIALS_DIRECTORY,仅本进程可读 0400),
|
||||
// 既不在磁盘上留副本,也不出现在 environ 里。
|
||||
//
|
||||
// 详见 docs/zh/kernel-secret-firewall-design.md E-4。
|
||||
//
|
||||
// ## 为何不放进 config.db
|
||||
//
|
||||
// config.db 是 0644 **全局可读**的。密钥进去等于没有加密
|
||||
//(设计文档硬约束:凭据绝不进 config.db)。
|
||||
//
|
||||
// ## 密钥丢失的后果(必须响亮)
|
||||
//
|
||||
// 密钥变了 ⇒ 已落盘的变量全部解不开。这时**不能静默重建库**
|
||||
//(那会看起来"一切正常"而实际所有凭据都丢了)。
|
||||
// 判据 TestWrongKeyFailsLoudly 固化这个行为。
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnvKeyName 是主密钥的环境变量名。
|
||||
const EnvKeyName = "HOMED_SECURITY_KEY"
|
||||
|
||||
// CredentialDirEnv 是 systemd 提供的凭据目录(`LoadCredential`/`SetCredential`)。
|
||||
const CredentialDirEnv = "CREDENTIALS_DIRECTORY"
|
||||
|
||||
// CredentialName 是 systemd 凭据名。
|
||||
//
|
||||
// ★ 这个名字必须与 unit 里的 `LoadCredential=<ID>:<path>` **逐字符一致**:
|
||||
// 实测(systemd 262)下划线**不会**被改写 —— `LoadCredential=homed_security_key:...`
|
||||
// 产生的文件名就是 `homed_security_key`。这个名字是我们自己选的,
|
||||
// 只要两边一致即可(文档只要求"可作为文件名的短 ASCII 串")。
|
||||
const CredentialName = "homed_security_key"
|
||||
|
||||
// KeyFileName 是密钥文件名(位于数据目录)。
|
||||
const KeyFileName = "security.key"
|
||||
|
||||
// LoadOrCreateKey 按优先级取主密钥;没有则生成一个并落盘。
|
||||
//
|
||||
// dataDir 是内核数据目录(config.db 所在目录)。
|
||||
func LoadOrCreateKey(dataDir string) ([]byte, string, error) {
|
||||
// ⓪ 内嵌主密钥(构建期 -ldflags 注入)——**最高优先级**。
|
||||
//
|
||||
// 为何最高:内嵌的目的就是「二进制在、密钥就在」,若被 systemd/env/磁盘
|
||||
// 盖过,那它在那些来源缺失时仍需回退,"不丢"就不成立。
|
||||
//
|
||||
// ★ 但它**不删除**其它来源:旧密文仍需旧钥可读,见 AdditionalDecryptKeys。
|
||||
if k, ok := loadEmbeddedKey(dataDir); ok {
|
||||
return k, "embedded (构建期注入,指纹 " + EmbeddedKeyFingerprint() + ")", nil
|
||||
}
|
||||
|
||||
// ① systemd 凭据(生产首选:tmpfs、不在磁盘、不进 environ)
|
||||
if dir := strings.TrimSpace(os.Getenv(CredentialDirEnv)); dir != "" {
|
||||
path := filepath.Join(dir, CredentialName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// CredentialsDirectory 存在但凭据缺失 ⇒ **响亮失败**。
|
||||
// 静默回退到磁盘文件会让「忘记配凭据」表现成「一切正常」,
|
||||
// 而实际用的是旧密钥 —— 那正是本模块最怕的静默降级。
|
||||
return nil, "", fmt.Errorf(
|
||||
"security: systemd 凭据目录已设置(%s)但读不到 %s —— "+
|
||||
"unit 里应配 LoadCredential=%s:<密钥文件路径>;"+
|
||||
"拒绝静默回退到磁盘密钥: %w",
|
||||
dir, CredentialName, CredentialName, err)
|
||||
}
|
||||
key, err := parseKey(strings.TrimSpace(string(b)))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("security: systemd 凭据 %s 格式非法: %w", path, err)
|
||||
}
|
||||
return key, "systemd credential " + CredentialName, nil
|
||||
}
|
||||
|
||||
// ② 环境变量(兼容手写 systemd / 容器)
|
||||
if raw := strings.TrimSpace(os.Getenv(EnvKeyName)); raw != "" {
|
||||
key, err := parseKey(raw)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("security: %s 格式非法: %w", EnvKeyName, err)
|
||||
}
|
||||
return key, EnvKeyName, nil
|
||||
}
|
||||
|
||||
// ③ 磁盘文件
|
||||
path := filepath.Join(dataDir, KeyFileName)
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
if fi, serr := os.Stat(path); serr == nil {
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
return nil, "", fmt.Errorf(
|
||||
"security: 主密钥文件权限不安全:%s 是 %04o,必须是 0600",
|
||||
path, perm)
|
||||
}
|
||||
}
|
||||
key, err := parseKey(strings.TrimSpace(string(b)))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("security: 主密钥文件损坏: %w", err)
|
||||
}
|
||||
return key, path, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// ③ 首次启动:生成
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, "", fmt.Errorf("security: 生成主密钥失败: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(hex.EncodeToString(key)), 0o600); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return key, path, nil
|
||||
}
|
||||
|
||||
// AdditionalDecryptKeys 返回**当前钥之外**、为了能读出已落盘密文而必须装入
|
||||
// 解密集合的钥。
|
||||
//
|
||||
// ## 为何必需(否则内嵌会一次性丢光数据)
|
||||
//
|
||||
// 一台机器先用磁盘钥 `security.key` 加密过 config.db / 变量库,然后升级到
|
||||
// **内嵌了另一把钥**的二进制。此时若只信内嵌钥:
|
||||
//
|
||||
// ① 旧密文用内嵌钥解 → 全部失败
|
||||
// ② 内核遇到解不开的密文会**响亮失败**(这是对的设计,不静默重建)
|
||||
// ⇒ 服务起不来;若用户此时选择"重建库"⇒ **凭据全丢**
|
||||
//
|
||||
// 所以内嵌钥上台时,磁盘钥(以及 keyring history)必须继续参与**解密**,
|
||||
// 只是不再负责加密新数据。这正是 E-1 密钥环的同一套思路:
|
||||
// 「当前钥负责 Seal;旧钥只参与 Open」。
|
||||
//
|
||||
// 内嵌钥与磁盘钥相同、或磁盘钥不存在时,返回空(无额外钥)。
|
||||
func AdditionalDecryptKeys(dataDir string, current []byte) [][]byte {
|
||||
var out [][]byte
|
||||
if !EmbeddedKeyPresent() {
|
||||
return nil
|
||||
}
|
||||
path := filepath.Join(dataDir, KeyFileName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
diskKey, err := parseKey(string(trimSpaceBytes(b)))
|
||||
if err != nil || len(diskKey) != 32 {
|
||||
return nil
|
||||
}
|
||||
if !sameKey(diskKey, current) {
|
||||
out = append(out, diskKey)
|
||||
log.Printf("[security] 已把 %s 作为**额外解密钥**装入(内嵌钥为当前钥)——"+
|
||||
"旧密文仍可读;请勿删除该文件,直到确认全部密文已重加密", path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseKey 接受 hex(64) 或原样 32 字节。
|
||||
func parseKey(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 64 {
|
||||
if b, err := hex.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
if len(s) == 32 {
|
||||
return []byte(s), nil
|
||||
}
|
||||
return nil, fmt.Errorf("长度 %d 非法(需 64 位 hex 或 32 字节)", len(s))
|
||||
}
|
||||
253
internal/security/key_test.go
Normal file
253
internal/security/key_test.go
Normal file
@ -0,0 +1,253 @@
|
||||
package security
|
||||
|
||||
// 主密钥管理的判据。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateKeyCreatesFile0600(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(EnvKeyName, "") // 确保走磁盘路径
|
||||
|
||||
k1, src, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(k1) != 32 {
|
||||
t.Fatalf("密钥应 32 字节,实际 %d", len(k1))
|
||||
}
|
||||
p := filepath.Join(dir, KeyFileName)
|
||||
if src != p {
|
||||
t.Errorf("来源应为密钥文件,实际 %q", src)
|
||||
}
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("密钥文件必须 0600,实际 %04o", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
// 再次调用必须得到**同一把**密钥(否则每次重启都解不开旧变量)
|
||||
k2, _, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(k1) != string(k2) {
|
||||
t.Fatal("重启后密钥必须一致")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 环境变量优先(生产用 systemd 注入)。
|
||||
func TestEnvKeyTakesPrecedence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
|
||||
t.Setenv(EnvKeyName, want)
|
||||
|
||||
k, src, err := LoadOrCreateKey(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src != EnvKeyName {
|
||||
t.Errorf("来源应为环境变量,实际 %q(不应落盘)", src)
|
||||
}
|
||||
if hexEncode(k) != want {
|
||||
t.Errorf("密钥不符: %s", hexEncode(k))
|
||||
}
|
||||
// 不应因此创建文件
|
||||
if _, err := os.Stat(filepath.Join(dir, KeyFileName)); !os.IsNotExist(err) {
|
||||
t.Error("用环境变量时不应创建密钥文件")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:密钥文件权限宽松 ⇒ 拒绝启动(响亮失败)。
|
||||
func TestRejectsLooseKeyFilePerms(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(EnvKeyName, "")
|
||||
p := filepath.Join(dir, KeyFileName)
|
||||
if err := os.WriteFile(p, []byte("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := LoadOrCreateKey(dir); err == nil {
|
||||
t.Fatal("0644 的密钥文件必须被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 环境变量格式非法 ⇒ 拒绝(不悄悄忽略)。
|
||||
func TestRejectsMalformedEnvKey(t *testing.T) {
|
||||
t.Setenv(EnvKeyName, "tooshort")
|
||||
if _, _, err := LoadOrCreateKey(t.TempDir()); err == nil {
|
||||
t.Fatal("非法密钥长度必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKeyAcceptsHexAndRaw32(t *testing.T) {
|
||||
// 64 位 hex → 32 字节
|
||||
k, err := parseKey("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899")
|
||||
if err != nil || len(k) != 32 {
|
||||
t.Fatalf("hex 解析失败: %v len=%d", err, len(k))
|
||||
}
|
||||
// 32 字节原文
|
||||
k2, err := parseKey("0123456789abcdef0123456789abcdef")
|
||||
if err != nil || len(k2) != 32 {
|
||||
t.Fatalf("原样 32 字节解析失败: %v", err)
|
||||
}
|
||||
if string(k2) != "0123456789abcdef0123456789abcdef" {
|
||||
t.Error("原样 32 字节应保持原值")
|
||||
}
|
||||
// 长度非法
|
||||
if _, err := parseKey("short"); err == nil {
|
||||
t.Error("长度非法必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**用错密钥必须响亮失败**,不能静默重建库。
|
||||
//
|
||||
// 这是最危险的静默降级:密钥换了若不报错,看起来一切正常,
|
||||
// 实际所有凭据都解不开 —— 而 agent 会继续用别名去调用,全线失败。
|
||||
func TestWrongKeyFailsLoudly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "v.json")
|
||||
|
||||
s1 := New(Options{Path: p, KeyFn: testKey, Enabled: true})
|
||||
if err := s1.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias, err := s1.Submit(testToken, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 换一把密钥重新加载(模拟密钥丢失/轮换失误)
|
||||
other := func() ([]byte, error) { return []byte("ffffffffffffffffffffffffffffffff"), nil }
|
||||
s2 := New(Options{Path: p, KeyFn: other, Enabled: true})
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatalf("加载本身应成功(只是解不开): %v", err)
|
||||
}
|
||||
// ★ 回填必须失败(返回原文,不 panic、不吐垃圾)
|
||||
out, n, _ := s2.Fill("x=@{" + alias + "}")
|
||||
if n != 0 {
|
||||
t.Errorf("用错密钥时不应回填成功,实际 n=%d out=%q", n, out)
|
||||
}
|
||||
if out != "x=@{"+alias+"}" {
|
||||
t.Errorf("用错密钥应原样保留引用(可诊断),实际 %q", out)
|
||||
}
|
||||
// 记录仍在(没被静默清空)
|
||||
if s2.Count() != 1 {
|
||||
t.Errorf("用错密钥不应清空记录,实际 %d 条", s2.Count())
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:库文件损坏必须报错,**不静默重建**("不静默重建")。
|
||||
func TestCorruptStoreFailsLoudly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "v.json")
|
||||
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(Options{Path: p, KeyFn: testKey, Enabled: true})
|
||||
if err := s.Load(); err == nil {
|
||||
t.Fatal("损坏的变量库必须报错,不能静默重建")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 首次启动(文件不存在)不是错误。
|
||||
func TestMissingStoreIsNotError(t *testing.T) {
|
||||
s := New(Options{Path: filepath.Join(t.TempDir(), "nope.json"), KeyFn: testKey, Enabled: true})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatalf("首次启动不应报错: %v", err)
|
||||
}
|
||||
if s.Count() != 0 {
|
||||
t.Error("新库应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- E-4:systemd 凭据生产注入 ----------------
|
||||
|
||||
// ★ 判据:systemd 凭据目录被设置且凭据存在 ⇒ 用它(且**优先于 env**)。
|
||||
func TestSystemdCredentialIsPreferred(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cred := make([]byte, 32)
|
||||
for i := range cred {
|
||||
cred[i] = byte(i + 1)
|
||||
}
|
||||
want := hex.EncodeToString(cred)
|
||||
if err := os.WriteFile(filepath.Join(dir, CredentialName), []byte(want), 0o400); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 同时设一个**不同的** env,验证凭据优先
|
||||
t.Setenv(CredentialDirEnv, dir)
|
||||
t.Setenv(EnvKeyName, strings.Repeat("ff", 32))
|
||||
|
||||
key, src, err := LoadOrCreateKey(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(src, "systemd") {
|
||||
t.Errorf("来源应标明 systemd 凭据,实际 %q", src)
|
||||
}
|
||||
if !bytes.Equal(key, cred) {
|
||||
t.Error("★ 应取 systemd 凭据的值,而不是 env 的值(env 对同用户进程可见)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:凭据目录设置但**凭据缺失** ⇒ 响亮失败,**不得**静默回退到磁盘密钥。
|
||||
//
|
||||
// 为何重要:静默回退会让「忘记配凭据」表现成「一切正常」,
|
||||
// 而实际用的是磁盘上的旧密钥 —— 本模块最怕的静默降级。
|
||||
func TestMissingSystemdCredentialFailsLoudly(t *testing.T) {
|
||||
dir := t.TempDir() // 空目录:没有凭据文件
|
||||
t.Setenv(CredentialDirEnv, dir)
|
||||
t.Setenv(EnvKeyName, "")
|
||||
|
||||
dataDir := t.TempDir()
|
||||
// 磁盘上放一把**可用**的密钥 —— 诱惑实现去回退
|
||||
diskKey := hex.EncodeToString(bytes.Repeat([]byte{0xAB}, 32))
|
||||
if err := os.WriteFile(filepath.Join(dataDir, KeyFileName), []byte(diskKey), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := LoadOrCreateKey(dataDir)
|
||||
if err == nil {
|
||||
t.Fatal("★ 凭据缺失却成功返回 —— 静默回退到了磁盘密钥")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "拒绝静默回退") {
|
||||
t.Errorf("错误应说明拒绝回退,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:凭据内容格式非法 ⇒ 响亮失败(不当作空串)。
|
||||
func TestBadSystemdCredentialFailsLoudly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, CredentialName), []byte("not-a-valid-key"), 0o400)
|
||||
t.Setenv(CredentialDirEnv, dir)
|
||||
t.Setenv(EnvKeyName, "")
|
||||
|
||||
if _, _, err := LoadOrCreateKey(t.TempDir()); err == nil {
|
||||
t.Fatal("格式非法应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:没设凭据目录时,行为与从前一致(env > 文件)—— 不能破坏开发机路径。
|
||||
func TestNoCredentialDirFallsBackToEnv(t *testing.T) {
|
||||
t.Setenv(CredentialDirEnv, "")
|
||||
want := strings.Repeat("cd", 32)
|
||||
t.Setenv(EnvKeyName, want)
|
||||
|
||||
key, src, err := LoadOrCreateKey(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src != EnvKeyName {
|
||||
t.Errorf("来源应为 %s,实际 %q", EnvKeyName, src)
|
||||
}
|
||||
if hex.EncodeToString(key) != want {
|
||||
t.Error("应取 env 的值")
|
||||
}
|
||||
}
|
||||
138
internal/security/keyring.go
Normal file
138
internal/security/keyring.go
Normal file
@ -0,0 +1,138 @@
|
||||
package security
|
||||
|
||||
// 密钥环持久化(E-1)。
|
||||
//
|
||||
// 主密钥轮换不能只替换 security.key:变量库与 config.db 里已有密文仍由旧钥
|
||||
// 加密。keyring 文件保存「当前钥 + 历史只读钥」,权限 0600;进程启动时把
|
||||
// 历史钥装入 Store/Codec,读旧密文,写新密文。
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const KeyringFileName = "security.keyring"
|
||||
|
||||
type keyringFile struct {
|
||||
Current string `json:"current"`
|
||||
History []string `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
// LoadKeyring 读取持久化密钥环。没有 keyring 时返回 current 和空 history,
|
||||
// 保持已有 security.key 部署完全兼容。
|
||||
func LoadKeyring(dataDir string, current []byte) ([][]byte, error) {
|
||||
path := filepath.Join(dataDir, KeyringFileName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm() != 0o600 {
|
||||
return nil, fmt.Errorf("security: 密钥环权限不安全:%s 是 %04o,必须是 0600", path, fi.Mode().Perm())
|
||||
}
|
||||
var f keyringFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return nil, fmt.Errorf("security: 密钥环损坏: %w", err)
|
||||
}
|
||||
if f.Current != "" {
|
||||
k, err := parseKey(f.Current)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("security: 密钥环 current 无效: %w", err)
|
||||
}
|
||||
// ★ 内嵌钥场景:keyring 的 current 是**旧的磁盘钥**,而本进程当前钥是内嵌钥。
|
||||
// 这不是「选错钥」,而是「密钥来源升级了」——必须把 keyring current
|
||||
// 当作**额外解密钥**保留(否则它加的密文全读不出来),而不是拒绝启动。
|
||||
//
|
||||
// 为何不能按旧逻辑 Fatal:内嵌的目的就是防丢;一个"有 keyring 的老机器
|
||||
// 升级到内嵌二进制就再也起不来"的行为,恰好造成它要防的那个后果。
|
||||
if !sameKey(k, current) && EmbeddedKeyPresent() {
|
||||
log.Printf("[security] 密钥环 current 与当前(内嵌)钥不同——"+
|
||||
"按**额外解密钥**保留,旧密文仍可读: %s", filepath.Join(dataDir, KeyringFileName))
|
||||
return appendHistoryKeys(f.History, current, k)
|
||||
}
|
||||
if len(current) != 32 || !sameKey(k, current) {
|
||||
return nil, fmt.Errorf("security: security.key 与密钥环 current 不一致(拒绝静默选错钥)")
|
||||
}
|
||||
}
|
||||
var old [][]byte
|
||||
for i, raw := range f.History {
|
||||
k, err := parseKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("security: 密钥环 history[%d] 无效: %w", i, err)
|
||||
}
|
||||
if !sameKey(k, current) {
|
||||
old = append(old, k)
|
||||
}
|
||||
}
|
||||
return old, nil
|
||||
}
|
||||
|
||||
// RotateKeyFile 原子地把 current 换成 newKey,并把旧 current 放入 history。
|
||||
// 调用方随后应以 newKey + 返回的旧钥初始化 Store/Codec,并执行数据重加密。
|
||||
func RotateKeyFile(dataDir string, current, newKey []byte) error {
|
||||
if len(current) != 32 || len(newKey) != 32 {
|
||||
return fmt.Errorf("security: 轮换钥匙必须都是 32 字节")
|
||||
}
|
||||
path := filepath.Join(dataDir, KeyringFileName)
|
||||
f := keyringFile{Current: hex.EncodeToString(newKey), History: []string{hex.EncodeToString(current)}}
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
var old keyringFile
|
||||
if json.Unmarshal(b, &old) == nil {
|
||||
f.History = append(f.History, old.History...)
|
||||
}
|
||||
}
|
||||
data, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// appendHistoryKeys 把 history 全部、加上 extra 里不等于 current 的钥,组成解密集合。
|
||||
//
|
||||
// 与正常路径的区别:正常情况下 `current` 必须是 keyring 的 current;
|
||||
// 内嵌升级场景下 keyring current 反而成为"额外钥",
|
||||
// 于是解密集合 = history + keyring.current。
|
||||
func appendHistoryKeys(history []string, current, extra []byte) ([][]byte, error) {
|
||||
var out [][]byte
|
||||
if len(extra) == 32 && !sameKey(extra, current) {
|
||||
out = append(out, extra)
|
||||
}
|
||||
for i, raw := range history {
|
||||
k, err := parseKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("security: 密钥环 history[%d] 无效: %w", i, err)
|
||||
}
|
||||
if !sameKey(k, current) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sameKey(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var x byte
|
||||
for i := range a {
|
||||
x |= a[i] ^ b[i]
|
||||
}
|
||||
return x == 0
|
||||
}
|
||||
135
internal/security/patterns.go
Normal file
135
internal/security/patterns.go
Normal file
@ -0,0 +1,135 @@
|
||||
package security
|
||||
|
||||
// 敏感信息模式库。
|
||||
//
|
||||
// ## 设计原则:**宁可漏,不可误伤**(文档 S4)
|
||||
//
|
||||
// 误报比漏报严重得多:它会把正常文本换成别名提示、把任务搞坏,
|
||||
// 而且现象会出现在很后面(agent 拿到错误的参数),归因极难。
|
||||
// 所以每条规则都要求**强特征**(固定前缀 + 长度 + 字符集),不用泛化启发式。
|
||||
//
|
||||
// ## 覆盖范围
|
||||
//
|
||||
// 主流平台的访问令牌 + 常见凭据形态(赋值语句、私钥块、认证头)。
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Pattern 是一条识别规则。
|
||||
type Pattern struct {
|
||||
Name string
|
||||
RE *regexp.Regexp
|
||||
|
||||
// Prefilter 是**必含的字面量**:不含它就一定不匹配,可直接跳过 RE。
|
||||
//
|
||||
// ★ 为什么需要(实测的性能缺陷):本文件所有模式都以 `\b` 开头,
|
||||
// 而 Go 的 RE2 对 `\b` 要逐 rune 做 Unicode 词边界判定 ——
|
||||
// 实测 50KB 文本上**单条模式 660µs**,9 条合计 ~6ms;
|
||||
// 而同样形状但去掉 `\b` 的模式只需 **1.6µs(快 400 倍)**。
|
||||
//
|
||||
// `\b` 不能删(它防 `xghp_xxx` 这类误伤,见 boundary_need_test.go),
|
||||
// 所以改为**先用 O(n) 的字面量包含检查快速排除**:
|
||||
// 绝大多数文本根本不含 `ghp_`/`glpat-` 等前缀,连正则都不用跑。
|
||||
//
|
||||
// 空串表示"没有可用前缀",那就直接跑正则(这类模式本身很快)。
|
||||
//
|
||||
// ★ 支持**多个候选**(用 `|` 分隔,任一中即放行)——
|
||||
// 因为有些模式的正则本身有多个备选分支(如 assigned_secret 的
|
||||
// `[:=]`)。初版只允许单个前缀,于是把 `:` 当 prefilter 后
|
||||
// `api_key = "..."` 就被挡在外面 ⇒ **漏报**。
|
||||
//
|
||||
// 规则:prefilter 的候选集**必须覆盖**正则里所有"必含"的字面量分支。
|
||||
// `TestPrefilterCoversAllAlternatives` 逐个形态验证这一点。
|
||||
Prefilter string
|
||||
}
|
||||
|
||||
// prefilterAlternatives 把 Prefilter 拆成候选列表。
|
||||
func (p Pattern) prefilterAlternatives() []string {
|
||||
if p.Prefilter == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p.Prefilter, "|")
|
||||
}
|
||||
|
||||
// matchesPrefilter 报告文本是否**可能**匹配该模式。
|
||||
//
|
||||
// 返回 false ⇒ 一定不匹配,可安全跳过正则。
|
||||
// 返回 true ⇒ 可能匹配,需跑正则确认。
|
||||
//
|
||||
// ★ 保守方向必须是"宁可漏判为可能":前缀检查只用来**排除**,
|
||||
// 不能用来**认定** —— 认定仍交给正则(S4:宁可漏,不可误伤)。
|
||||
func (p Pattern) matchesPrefilter(text string) bool {
|
||||
alts := p.prefilterAlternatives()
|
||||
if len(alts) == 0 {
|
||||
return true // 没有前缀可依,交给正则
|
||||
}
|
||||
for _, a := range alts {
|
||||
if a != "" && strings.Contains(text, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Patterns 是内置模式库。
|
||||
//
|
||||
// 排序无要求(每条独立替换)。
|
||||
var Patterns = []Pattern{
|
||||
// —— 平台访问令牌(强前缀,几乎不会误伤)——
|
||||
// GitHub: ghp_/gho_/ghu_/ghs_/ghr_ + 36 位
|
||||
{Name: "github_token", RE: regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{36}\b`), Prefilter: "gh"},
|
||||
// GitHub fine-grained
|
||||
{Name: "github_fine_grained", RE: regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9_]{60,}\b`), Prefilter: "github_pat_"},
|
||||
// GitLab
|
||||
{Name: "gitlab_token", RE: regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{20,}\b`), Prefilter: "glpat-"},
|
||||
// OpenAI
|
||||
{Name: "openai_key", RE: regexp.MustCompile(`\bsk-(proj-)?[A-Za-z0-9_-]{20,}\b`), Prefilter: "sk-"},
|
||||
// Anthropic
|
||||
{Name: "anthropic_key", RE: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_-]{20,}\b`), Prefilter: "sk-ant-"},
|
||||
// AWS Access Key ID
|
||||
{Name: "aws_access_key", RE: regexp.MustCompile(`\b(AKIA|ASIA)[0-9A-Z]{16}\b`), Prefilter: "A"},
|
||||
// Slack
|
||||
{Name: "slack_token", RE: regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}\b`), Prefilter: "xox"},
|
||||
// 认证头里的长 token
|
||||
{Name: "bearer_token", RE: regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._\-]{30,}`), Prefilter: "earer"},
|
||||
|
||||
// —— 私钥块(多行;单独一条,因为长度阈值不同)——
|
||||
{Name: "private_key_block", RE: regexp.MustCompile(
|
||||
`-----BEGIN [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----[\s\S]{64,}?-----END [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----`), Prefilter: "-----BEGIN"},
|
||||
|
||||
// —— 赋值语句里的长密钥 ——
|
||||
//
|
||||
// ⚠️ 这条最容易误伤,所以限制得很死:
|
||||
// · 键名必须是明确的凭据词(api_key / token / password …)
|
||||
// · 值必须有 16+ 位、且是常见的密钥字符集
|
||||
// (像 `version = "1.2.3"`、`name = "hello"` 都不会命中)
|
||||
// assigned_secret 没有**单一**字面量前缀(它匹配多种键名)。
|
||||
//
|
||||
// ★ prefilter 必须覆盖正则里的**两种**分隔符(`:` 与 `=`)——
|
||||
// 初版只写 `"="`,于是 `password: "..."`(冒号形态)被 prefilter
|
||||
// 静默挡住 ⇒ **漏报**。这个 bug 是"盘点什么没被拦"时发现的:
|
||||
// 判据样本恰好用了 `=`,所以测试全绿而真实场景有洞。
|
||||
//
|
||||
// 现在用 `":"` 作 prefilter(冒号在 JSON/YAML/日志里更常见),
|
||||
// 并由 TestPrefilterCoversAllAlternatives 逐个分隔符验证。
|
||||
{Name: "assigned_secret", Prefilter: ":|=",
|
||||
RE: regexp.MustCompile(
|
||||
`(?i)\b(api[_-]?key|access[_-]?token|auth[_-]?token|secret[_-]?key|client[_-]?secret|password|passwd)\b\s*[:=]\s*["']?([A-Za-z0-9/+_\-]{16,})["']?`)},
|
||||
}
|
||||
|
||||
// 注意:**刻意不包含**"裸的 40 位 hex"(Gitea token 形状)。
|
||||
//
|
||||
// 为什么:40 位 hex 与 git SHA-1(commit id)**形态完全相同**。
|
||||
// 把 commit hash 换成别名提示会直接把任务搞坏(agent 拿不到正确的 SHA)。
|
||||
// 这是"宁可漏,不可误伤"的典型取舍:
|
||||
// · 漏掉:Gitea token 在裸文本里没被拦(但它通常会出现在赋值语句里,被上一条覆盖)
|
||||
// · 误伤:每个 commit id 都变成别名提示 —— 后果严重得多
|
||||
|
||||
// ---------------- base64 辅助(集中一处,便于审计) ----------------
|
||||
|
||||
func encodeB64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
|
||||
|
||||
func decodeB64(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
|
||||
210
internal/security/prefilter_test.go
Normal file
210
internal/security/prefilter_test.go
Normal file
@ -0,0 +1,210 @@
|
||||
package security
|
||||
|
||||
// 前置过滤(Prefilter)的判据。
|
||||
//
|
||||
// ## 为什么需要它 + 为什么必须单独验
|
||||
//
|
||||
// 加 prefilter 是为了修一个**实测的性能缺陷**:所有模式以 `\b` 开头,
|
||||
// 而 Go 的 RE2 对 `\b` 要逐 rune 判定 —— 50KB 文本上单条 660µs,
|
||||
// 9 条合计 ~6ms。常被调用的路径(每次 LLM 调用、每次工具调用)
|
||||
// 白付这个成本。加字面量前缀后降到 10µs(~400×)。
|
||||
//
|
||||
// ★ 但优化有个致命风险:**prefilter 若与正则的必含字符不一致,
|
||||
// 就会造成漏报** —— 而漏报(凭据没被拦下)比性能差严重得多,
|
||||
// 且**不会报错**,只会在某天出事时才发现。
|
||||
//
|
||||
// 所以本文件逐条验证:「正则能匹配的文本,prefilter 一定放行」。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ 判据:**每一条模式的 prefilter 都必须是该模式真会匹配的文本的子串**。
|
||||
//
|
||||
// 做法:为每条模式准备一份"应命中"样本,断言
|
||||
//
|
||||
// ① 正则确实命中它
|
||||
// ② prefilter 也放行它
|
||||
//
|
||||
// 若 ② 失败 ⇒ 该样本永远被 prefilter 挡在门外 ⇒ **漏报**。
|
||||
func TestPrefilterNeverBlocksRealMatches(t *testing.T) {
|
||||
samples := map[string]string{
|
||||
"github_token": "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
"github_fine_grained": "github_pat_" + strings.Repeat("A", 60),
|
||||
"gitlab_token": "glpat-FAKE-TEST-TOKEN-NOT-REAL",
|
||||
"openai_key": "sk-FAKE-TEST-KEY-NOT-REAL-0000000",
|
||||
"anthropic_key": "sk-ant-FAKE-TEST-KEY-NOT-REAL-00",
|
||||
"aws_access_key": "AKIA0000FAKE0000TEST",
|
||||
"slack_token": "xoxb-FAKE-TEST-TOKEN-NOT-REAL",
|
||||
"bearer_token": "Bearer FAKE-TEST-BEARER-TOKEN-NOT-REAL",
|
||||
"private_key_block": "-----BEGIN RSA PRIVATE KEY-----\n" +
|
||||
strings.Repeat("MIIEowIBAAKCAQEA", 8) + "\n-----END RSA PRIVATE KEY-----",
|
||||
"assigned_secret": `api_key = "FAKE-TEST-ASSIGNED-VALUE-000"`,
|
||||
}
|
||||
|
||||
for _, p := range Patterns {
|
||||
sample, ok := samples[p.Name]
|
||||
if !ok {
|
||||
t.Errorf("模式 %s 没有样本 —— 新增模式时必须补样本(否则判据会漏掉它)", p.Name)
|
||||
continue
|
||||
}
|
||||
// ① 正则必须命中
|
||||
if !p.RE.MatchString(sample) {
|
||||
t.Errorf("模式 %s 的正则不匹配它的样本(样本过期了?): %q", p.Name, sample)
|
||||
continue
|
||||
}
|
||||
// ② prefilter 必须放行(否则这条永远漏报)
|
||||
if !p.matchesPrefilter(sample) {
|
||||
t.Errorf("★ 模式 %s 的 prefilter %q 挡住了自己的样本 %q —— 会造成**静默漏报**",
|
||||
p.Name, p.Prefilter, sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**反向** —— prefilter 挡下的文本,正则确实不该匹配。
|
||||
//
|
||||
// 这一条防的是"prefilter 太宽"吗?不是 —— 太宽只会变慢,不会出错。
|
||||
// 它防的是相反的错误:有人把 prefilter 写成**排除式**(如"不含 X 才跑"),
|
||||
// 那就反了。这里给出一个"不含任何前缀"的文本,断言:
|
||||
//
|
||||
// 跳过它不会漏掉任何真命中(即正则对它本来就不匹配)。
|
||||
func TestPrefilterSkipIsSafe(t *testing.T) {
|
||||
benign := "这是一段普通的技术讨论:架构、实现、测试与部署。没有凭据。"
|
||||
for _, p := range Patterns {
|
||||
if p.matchesPrefilter(benign) {
|
||||
continue // 放行也无妨(会跑正则,只是慢)
|
||||
}
|
||||
if p.RE.MatchString(benign) {
|
||||
t.Errorf("★ 模式 %s 被 prefilter 跳过,但正则其实匹配这段文本 —— 漏报!",
|
||||
p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:**大文本下的正确性**(prefilter 在长文本上不能出错)。
|
||||
//
|
||||
// 慢路径 bug 常在边界出现:prefilter 命中但在文本很靠后的位置。
|
||||
func TestPrefilterWorksDeepInLargeText(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// 把 token 埋在 50KB 文本的**中间**
|
||||
pad := strings.Repeat("普通技术讨论内容,不含凭据。", 1500)
|
||||
text := pad[:len(pad)/2] + " token=" + tok + " " + pad[len(pad)/2:]
|
||||
|
||||
out, evts := s.Replace(text, "bench", Patterns)
|
||||
if len(evts) == 0 {
|
||||
t.Fatal("★ 埋在长文本中段的凭据未被识别(prefilter 在长文本上漏报)")
|
||||
}
|
||||
if strings.Contains(out, tok) {
|
||||
t.Fatal("明文未被替换")
|
||||
}
|
||||
if len(evts[0].Alias) != 20 {
|
||||
t.Errorf("别名形态异常: %s", evts[0].Alias)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**prefilter 必须覆盖正则里所有"必含"的备选分支**。
|
||||
//
|
||||
// ## 这条判据的由来(真实漏报)
|
||||
//
|
||||
// `assigned_secret` 的正则是 `\b(...)\b\s*[:=]\s*...` —— 分隔符有
|
||||
// **两个**备选(`:` 与 `=`)。初版 prefilter 只写 `"="`,于是
|
||||
// `password: "hunter2..."`(冒号形态)被 prefilter 静默挡住 ⇒ **漏报**。
|
||||
//
|
||||
// 更值得记的是**为什么没被发现**:当时的判据样本恰好用了 `=`,
|
||||
// 所以测试全绿 —— 判据覆盖了"能用的形态",漏了"另一种同样合法的形态"。
|
||||
//
|
||||
// ⇒ 现在逐形态验证:正则支持的每种写法,prefilter 都必须放行。
|
||||
func TestPrefilterCoversAllAlternatives(t *testing.T) {
|
||||
// 每个形态都**必须是正则能匹配的**,且 prefilter 必须放行。
|
||||
forms := map[string][]string{
|
||||
"assigned_secret": {
|
||||
`api_key = "FAKE-TEST-ASSIGNED-VALUE-000"`, // =
|
||||
`api_key: "FAKE-TEST-ASSIGNED-VALUE-000"`, // :
|
||||
`password="FAKE-TEST-TOKEN-NOT-REAL-001234"`,
|
||||
`password:'FAKE-TEST-TOKEN-NOT-REAL-001234'`,
|
||||
`access_token = FAKE-TEST-TOKEN-NOT-REAL-0012`,
|
||||
`client_secret: FAKE-TEST-TOKEN-NOT-REAL-0012`,
|
||||
`auth_token := FAKE-TEST-TOKEN-NOT-REAL-00123`,
|
||||
`secret_key = FAKE-TEST-TOKEN-NOT-REAL-0012345`,
|
||||
`passwd = FAKE-TEST-ASSIGNED-VALUE-000`,
|
||||
},
|
||||
}
|
||||
|
||||
for name, samples := range forms {
|
||||
var pat *Pattern
|
||||
for i := range Patterns {
|
||||
if Patterns[i].Name == name {
|
||||
pat = &Patterns[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if pat == nil {
|
||||
t.Fatalf("找不到模式 %s", name)
|
||||
}
|
||||
for _, sample := range samples {
|
||||
if !pat.RE.MatchString(sample) {
|
||||
// 样本本身不被正则支持 —— 那是样本写错了,不是漏报
|
||||
t.Logf(" (正则不支持该形态,跳过)%q", sample)
|
||||
continue
|
||||
}
|
||||
if !pat.matchesPrefilter(sample) {
|
||||
t.Errorf("★ 模式 %s 的 prefilter %q 挡住了**正则能匹配**的形态 %q —— 静默漏报",
|
||||
name, pat.Prefilter, sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:多候选 prefilter 的"任一命中即放行"语义。
|
||||
func TestPrefilterAlternativesSemantics(t *testing.T) {
|
||||
p := Pattern{Name: "t", Prefilter: "a|bb|ccc"}
|
||||
|
||||
if !p.matchesPrefilter("xax") {
|
||||
t.Error("含第一个候选应放行")
|
||||
}
|
||||
if !p.matchesPrefilter("xbbx") {
|
||||
t.Error("含第二个候选应放行")
|
||||
}
|
||||
if !p.matchesPrefilter("xcccx") {
|
||||
t.Error("含第三个候选应放行")
|
||||
}
|
||||
if p.matchesPrefilter("none of them") {
|
||||
t.Error("一个都不含时不应放行")
|
||||
}
|
||||
// 空候选要忽略(防 `"a||b"` 这种写法把空串当"总匹配")
|
||||
p2 := Pattern{Name: "t2", Prefilter: "a||b"}
|
||||
if p2.matchesPrefilter("zzz") {
|
||||
t.Error("空候选不该被当成匹配(否则 prefilter 形同虚设)")
|
||||
}
|
||||
// 空 prefilter = 总放行(交给正则)
|
||||
p3 := Pattern{Name: "t3", Prefilter: ""}
|
||||
if !p3.matchesPrefilter("anything") {
|
||||
t.Error("无 prefilter 时应总放行")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:**prefilter 必须真的起作用**(否则优化是空谈)。
|
||||
//
|
||||
// 用一个只含无关字符的大文本,断言它比"含前缀"的文本快一个数量级。
|
||||
// 这条是"优化没有偷偷失效"的哨兵。
|
||||
func TestPrefilterActuallySkipsWork(t *testing.T) {
|
||||
p := Patterns[0] // github_token
|
||||
// 找一条有 prefilter 的模式
|
||||
for _, q := range Patterns {
|
||||
if q.Prefilter != "" {
|
||||
p = q
|
||||
break
|
||||
}
|
||||
}
|
||||
if p.Prefilter == "" {
|
||||
t.Skip("没有带 prefilter 的模式")
|
||||
}
|
||||
benign := strings.Repeat("普通文本,无凭据。", 1000)
|
||||
if p.matchesPrefilter(benign) {
|
||||
t.Errorf("模式 %s 的 prefilter %q 不该在无关文本上放行(优化失效)",
|
||||
p.Name, p.Prefilter)
|
||||
}
|
||||
}
|
||||
184
internal/security/rotation_test.go
Normal file
184
internal/security/rotation_test.go
Normal file
@ -0,0 +1,184 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func rotationKey(fill byte) []byte { return bytes.Repeat([]byte{fill}, 32) }
|
||||
|
||||
// ★ E-1:轮换后既有别名**不变**、旧值仍可回填。
|
||||
func TestRotateKeyPreservesAliasesAndReadsOldEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldKey, newKey := rotationKey(0x11), rotationKey(0x22)
|
||||
s := New(Options{Path: dir + "/variables.json", KeyFn: func() ([]byte, error) { return oldKey, nil }, Enabled: true})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias, err := s.Submit("SUPER_SECRET", "rotation")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _, _ := s.Fill("@{" + alias + "}")
|
||||
if before != "SUPER_SECRET" {
|
||||
t.Fatalf("轮换前回填失败: %q", before)
|
||||
}
|
||||
if err := s.RotateKey(newKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _, _ := s.Fill("@{" + alias + "}")
|
||||
if after != "SUPER_SECRET" {
|
||||
t.Fatalf("★ 轮换后旧别名/旧值不可用: %q", after)
|
||||
}
|
||||
alias2, err := s.Alias("SUPER_SECRET")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alias2 != alias {
|
||||
t.Fatalf("★ 轮换改变了别名: before=%s after=%s", alias, alias2)
|
||||
}
|
||||
if !strings.Contains(string(mustRead(t, dir+"/variables.json")), alias) {
|
||||
t.Fatal("轮换落盘缺少旧别名")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 轮换落盘后重启:只注入 new + old ring,也必须能回填旧条目。
|
||||
func TestRotatedStoreRestartsWithOldKeyRing(t *testing.T) {
|
||||
dir := t.TempDir() + "/variables.json"
|
||||
oldKey, newKey := rotationKey(0x31), rotationKey(0x32)
|
||||
s := New(Options{Path: dir, KeyFn: func() ([]byte, error) { return oldKey, nil }, Enabled: true})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias, err := s.Submit("ROTATE_ME", "restart")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RotateKey(newKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 新进程:current=new,history=old。
|
||||
s2 := New(Options{Path: dir, KeyFn: func() ([]byte, error) { return newKey, nil }, OldKeys: [][]byte{oldKey}, Enabled: true})
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, _ := s2.Fill("@{" + alias + "}")
|
||||
if got != "ROTATE_ME" {
|
||||
t.Fatalf("重启后历史密钥环无法解密: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 没有旧钥环时换钥必须响亮失败,而不是把库当成空库重建。
|
||||
func TestRotateKeyRefusesWrongCurrentKey(t *testing.T) {
|
||||
dir := t.TempDir() + "/variables.json"
|
||||
oldKey := rotationKey(0x41)
|
||||
s := New(Options{Path: dir, KeyFn: func() ([]byte, error) { return oldKey, nil }, Enabled: true})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Submit("DO_NOT_LOSE", "wrong-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong := New(Options{Path: dir, KeyFn: func() ([]byte, error) { return rotationKey(0x42), nil }, Enabled: true})
|
||||
if err := wrong.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := wrong.RotateKey(rotationKey(0x43)); err == nil {
|
||||
t.Fatal("★ 错钥轮换却成功 —— 可能静默丢数据")
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ★ keyring 文件:轮换后重启必须能自动装回历史钥(否则 E-1 只是"能轮换一次")。
|
||||
func TestKeyringPersistAndRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldKey, newKey := rotationKey(0x51), rotationKey(0x52)
|
||||
if err := RotateKeyFile(dir, oldKey, newKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err := LoadKeyring(dir, newKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(history) != 1 || !sameKey(history[0], oldKey) {
|
||||
t.Fatalf("历史钥未装回: %v", history)
|
||||
}
|
||||
// 与 security.key 不一致时必须拒绝(否则会拿错钥去解,静默丢数据)
|
||||
if _, err := LoadKeyring(dir, rotationKey(0x53)); err == nil {
|
||||
t.Fatal("★ keyring 与 security.key 不一致却未被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 端到端:轮换 → 重启(只给 new + keyring history)→ 旧条目仍可回填。
|
||||
func TestRotationSurvivesRestartViaKeyring(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
varPath := dir + "/variables.json"
|
||||
oldKey, newKey := rotationKey(0x61), rotationKey(0x62)
|
||||
s := New(Options{Path: varPath, KeyFn: func() ([]byte, error) { return oldKey, nil }, Enabled: true})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias, err := s.Submit("RESTART_SECRET", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RotateKey(newKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RotateKeyFile(dir, oldKey, newKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err := LoadKeyring(dir, newKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := New(Options{Path: varPath, KeyFn: func() ([]byte, error) { return newKey, nil }, OldKeys: history, Enabled: true})
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, _ := s2.Fill("@{" + alias + "}")
|
||||
if got != "RESTART_SECRET" {
|
||||
t.Fatalf("重启后无法回填: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 轮换必须**真的重加密并落盘**:重启时只给新钥(不带任何历史钥)也必须能读。
|
||||
//
|
||||
// 为何这条不可省:只把旧钥留在环里能让旧密文"看起来还能用",但磁盘上
|
||||
// 仍是旧钥密文 —— 那意味着旧钥永远退不掉,轮换等于没做。
|
||||
// 这条判据正是用来咬住"内存换了、磁盘没换"的半轮换。
|
||||
func TestRotationPersistsReEncryptionToDisk(t *testing.T) {
|
||||
dir := t.TempDir() + "/variables.json"
|
||||
oldKey, newKey := rotationKey(0x91), rotationKey(0x92)
|
||||
s := New(Options{Path: dir, KeyFn: func() ([]byte, error) { return oldKey, nil }, Enabled: true})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias, err := s.Submit("RE_ENCRYPT_ME", "persist")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.RotateKey(newKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 只有新钥,没有历史钥 —— 能读出来才说明磁盘上已是新钥密文。
|
||||
fresh := New(Options{Path: dir, KeyFn: func() ([]byte, error) { return newKey, nil }, Enabled: true})
|
||||
if err := fresh.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, _ := fresh.Fill("@" + "{" + alias + "}")
|
||||
if got != "RE_ENCRYPT_ME" {
|
||||
t.Fatalf("★ 只用新钥读不出来 ⇒ 轮换没有真正重加密落盘(半轮换): %q", got)
|
||||
}
|
||||
}
|
||||
50
internal/security/ssh_fp_test.go
Normal file
50
internal/security/ssh_fp_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★★ 判据:私钥块规则**不能误伤**。
|
||||
//
|
||||
// 放宽了尾部词匹配后必须确认:正常文本里的 "PRIVATE KEY" 字样不该被拦。
|
||||
func TestSSHKeys_NoFalsePositives(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
|
||||
benign := []struct{ desc, text string }{
|
||||
{"文档里提私钥概念", "私钥文件以 -----BEGIN OPENSSH PRIVATE KEY----- 开头,以对应的 END 行结尾。"},
|
||||
{"只引用头尾两行(无内容)",
|
||||
"-----BEGIN RSA PRIVATE KEY-----\n-----END RSA PRIVATE KEY-----"},
|
||||
{"短内容(< 64 字节)",
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nshortcontent\n-----END RSA PRIVATE KEY-----"},
|
||||
{"讨论 SSH 配置", "把 PRIVATE KEY 权限设成 0600,authorized_keys 用 0644"},
|
||||
{"代码里的常量名", `const PRIVATE_KEY_HEADER = "-----BEGIN"`},
|
||||
{"正常的技术文档", "本章讨论密钥管理与轮换策略,涉及 PRIVATE KEY 的使用规范。"},
|
||||
}
|
||||
for _, c := range benign {
|
||||
out, evts := s.Replace(c.text, "sshfp", Patterns)
|
||||
if len(evts) > 0 {
|
||||
t.Errorf("★ 误伤「%s」: %q → %v", c.desc, c.text, evts)
|
||||
}
|
||||
if out != c.text {
|
||||
t.Errorf("未命中却改动了文本「%s」", c.desc)
|
||||
}
|
||||
}
|
||||
t.Log("✅ 私钥块规则无误伤(含短内容/只引头尾/文档讨论)")
|
||||
}
|
||||
|
||||
// ★ 判据:真正的长私钥体必须拦(与上面的"短内容"形成对照)。
|
||||
func TestSSHKeys_LongBodyIsCaught(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
body := strings.Repeat("MIIEowIBAAKCAQEA7xYz9aBc", 6) // > 64 字节
|
||||
text := "-----BEGIN RSA PRIVATE KEY-----\n" + body + "\n-----END RSA PRIVATE KEY-----"
|
||||
|
||||
out, evts := s.Replace(text, "sshl", Patterns)
|
||||
if len(evts) == 0 {
|
||||
t.Fatal("★ 真实长度的私钥体未被拦截")
|
||||
}
|
||||
if strings.Contains(out, "MIIEowIBAAKCAQEA") {
|
||||
t.Fatal("密钥体残留")
|
||||
}
|
||||
t.Logf("✅ %d 字节密钥体被拦(别名 %s)", len(text), evts[0].Alias)
|
||||
}
|
||||
62
internal/security/ssh_surface_test.go
Normal file
62
internal/security/ssh_surface_test.go
Normal file
@ -0,0 +1,62 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SSH 相关面的完整盘点:哪些拦、哪些不拦、为什么。
|
||||
func TestSSHSurface_Complete(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
// 读一份真实 authorized_keys 与 ssh config
|
||||
readIf := func(rel string) string {
|
||||
b, err := os.ReadFile(filepath.Join(home, ".ssh", rel))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
t.Log("=== 会拦 ===")
|
||||
// ① 私钥文件内容
|
||||
if txt := readIf("id_rsa"); txt != "" {
|
||||
_, evts := s.Replace(txt, "ssh", Patterns)
|
||||
t.Logf(" ✓ 私钥文件内容(%d 字节)→ %d 处拦截", len(txt), len(evts))
|
||||
}
|
||||
// ② ssh-agent 环境变量里的私钥?—— 那只是 socket 路径,不是密钥
|
||||
t.Log("=== 不拦(各有权衡)===")
|
||||
t.Logf(" - %-28s ssh-agent 只是 socket 路径,不含密钥", "SSH_AUTH_SOCK")
|
||||
t.Logf(" - %-28s 公钥不是秘密(可公开)", "authorized_keys")
|
||||
t.Logf(" - %-28s 配置里没有密钥本体", "~/.ssh/config")
|
||||
t.Logf(" - %-28s 拦路径不拦内容;内容被读出时才命中", ".ssh/id_rsa 路径")
|
||||
t.Logf(" - %-28s 无固定形态,与正常 hostname 无区别", "known_hosts")
|
||||
|
||||
// ③ 验证不动 authorized_keys(它是公钥,不该被拦)
|
||||
if txt := readIf("authorized_keys"); txt != "" {
|
||||
out, evts := s.Replace(txt, "ssh", Patterns)
|
||||
if len(evts) > 0 {
|
||||
t.Logf(" ⚠️ authorized_keys 被拦了 %d 处(公钥本可公开,但拦了也不致命)", len(evts))
|
||||
} else if out == txt {
|
||||
t.Log(" ✓ authorized_keys 未被改动(正确:公钥不是秘密)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:**公钥不该被当私钥拦**(两者形态不同,别混)。
|
||||
func TestSSHSurface_PublicKeyNotCaught(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
// 真实的 ed25519 公钥形态
|
||||
pub := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8 root@host"
|
||||
if _, evts := s.Replace(pub, "ssh", Patterns); len(evts) > 0 {
|
||||
t.Errorf("★ 公钥被误拦(公钥可公开,拦了会让正常运维操作坏掉): %v", evts)
|
||||
}
|
||||
// RSA 公钥
|
||||
pub2 := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDZx user@host"
|
||||
if _, evts := s.Replace(pub2, "ssh", Patterns); len(evts) > 0 {
|
||||
t.Errorf("★ RSA 公钥被误拦: %v", evts)
|
||||
}
|
||||
t.Log("✅ 公钥不被拦(正确的边界)")
|
||||
}
|
||||
70
internal/security/ssh_test.go
Normal file
70
internal/security/ssh_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 用**本机真实密钥**验证(比构造样本可靠)。
|
||||
func TestSSHKeys_RealFiles(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
cands := []string{
|
||||
filepath.Join(home, ".ssh", "id_rsa"),
|
||||
filepath.Join(home, ".ssh", "id_ed25519"),
|
||||
filepath.Join(home, ".ssh", "id_ecdsa"),
|
||||
}
|
||||
found := 0
|
||||
for _, p := range cands {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
text := string(b)
|
||||
out, evts := s.Replace(text, "ssh", Patterns)
|
||||
hdr := strings.SplitN(text, "\n", 2)[0]
|
||||
if len(evts) == 0 {
|
||||
t.Errorf("★ %s(%s)**未被拦截** —— 私钥明文会进上下文", filepath.Base(p), hdr)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(out, "PRIVATE KEY") {
|
||||
t.Errorf("★ %s 拦了但密钥内容仍残留", filepath.Base(p))
|
||||
}
|
||||
t.Logf(" ✓ %-14s %-38s → %s", filepath.Base(p), hdr, evts[0].Alias)
|
||||
}
|
||||
if found == 0 {
|
||||
t.Skip("本机没有密钥文件")
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖各类真实密钥头的**合成样本**(PEM 与 OpenSSH 都有)。
|
||||
func TestSSHKeys_AllHeaderForms(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
body := strings.Repeat("MIIEowIBAAKCAQEA7xYz", 8) +
|
||||
"\n" + strings.Repeat("b3BlbnNzaC1rZXkAAAAB", 8)
|
||||
|
||||
headers := []string{
|
||||
"-----BEGIN PRIVATE KEY-----", // PKCS#8
|
||||
"-----BEGIN RSA PRIVATE KEY-----", // PKCS#1
|
||||
"-----BEGIN DSA PRIVATE KEY-----",
|
||||
"-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----", // 现代 ssh-keygen 默认
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----", // 带口令
|
||||
"-----BEGIN PGP PRIVATE KEY BLOCK-----", // PGP
|
||||
"-----BEGIN SSH2 ENCRYPTED PRIVATE KEY-----",
|
||||
}
|
||||
for _, h := range headers {
|
||||
footer := strings.Replace(h, "BEGIN", "END", 1)
|
||||
text := h + "\n" + body + "\n" + footer
|
||||
_, evts := s.Replace(text, "ssh", Patterns)
|
||||
if len(evts) == 0 {
|
||||
t.Errorf(" ✗ %-42s 未拦截", h)
|
||||
} else {
|
||||
t.Logf(" ✓ %-42s", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
667
internal/security/store.go
Normal file
667
internal/security/store.go
Normal file
@ -0,0 +1,667 @@
|
||||
// Package security 是内核的**敏感信息防火模块**(Secret Firewall)。
|
||||
//
|
||||
// 设计文档:docs/zh/kernel-secret-firewall-design.md
|
||||
//
|
||||
// ## 它做什么
|
||||
//
|
||||
// ① 拦截 LLM 出向前扫到明文 → 换成 @{别名}
|
||||
// ② 加密落盘 明文 AES-GCM 持久化(不落明文)
|
||||
// ③ 别名 ★ 哈希键:sec_ + HMAC-SHA256(masterKey, plain)[:8]
|
||||
// ④ 回填 插件调用(toolcall / outputch)前,把 @{别名} 换回明文
|
||||
//
|
||||
// ## 三条不可让步的约束(文档 §3)
|
||||
//
|
||||
// S1 不提供"取回明文"的任何接口 —— 只能**替代引用**。
|
||||
// 一旦有取回路径,LLM 就能把明文写回上下文,整层作废。
|
||||
// S3 别名必须 HMAC 派生,**不能裸哈希**:别名会进上下文/日志/记忆,
|
||||
// 裸哈希对弱口令可被字典枚举(实测 hunter2 当场命中)。
|
||||
// 也**不加 salt** —— 加了就失去合并能力(确定性是特性)。
|
||||
// S10 变量系统**全局唯一**:主 / 子 / 驻留子 agent 同一套。
|
||||
//
|
||||
// ## 为什么是"全局单例"
|
||||
//
|
||||
// 已核:驻留子共享父的 ProviderManager(resident.go:185),而它全进程只建一份
|
||||
// (bootstrap.go:249)⇒ 安全模块挂在 provider 层就天然是进程级单例,
|
||||
// **不需要任何跨 agent 同步**。
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AliasPrefix 是所有变量别名的前缀。
|
||||
const AliasPrefix = "sec_"
|
||||
|
||||
// aliasHexLen 是别名里哈希的十六进制长度(8 字节)。
|
||||
//
|
||||
// 为何选 8 字节:碰撞概率对本场景可忽略,且别名会进上下文,短才省预算。
|
||||
// 实测别名形如 `sec_3718c87a11e9478b`(20 字符)。
|
||||
const aliasHexLen = 16
|
||||
|
||||
// refPattern 匹配文本里的变量引用 `@{sec_xxx}`。
|
||||
//
|
||||
// 为什么用 @{...} 而不是 ${...}:后者与 shell 变量语法冲突,
|
||||
// agent 极易误用;@{...} 不与任何常见语法撞车。
|
||||
var refPattern = regexp.MustCompile(`@\{([A-Za-z0-9_]{1,64})\}`)
|
||||
|
||||
// Sentinel errors(调用方靠它们区分处置,不靠字符串匹配)。
|
||||
var (
|
||||
// ErrNoMasterKey 表示没有可用密钥 ⇒ **拒绝产出别名**。
|
||||
//
|
||||
// 为何要拒绝而不是退化:没有密钥就只能用裸哈希,而裸哈希会泄露
|
||||
// secret 内容(可字典枚举)。**宁可漏报,不可降级**。
|
||||
ErrNoMasterKey = errors.New("security: 无可用主密钥")
|
||||
|
||||
// ErrUnknownAlias 表示引用了未登记的别名。
|
||||
ErrUnknownAlias = errors.New("security: 未知变量别名")
|
||||
)
|
||||
|
||||
// Entry 是一条变量记录。
|
||||
type Entry struct {
|
||||
Alias string `json:"alias"`
|
||||
Sealed string `json:"sealed"` // AES-256-GCM(base64, nonce 前置)
|
||||
Source string `json:"source"` // intercepted | submitted
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
// Hits 是命中次数(可观测:同一变量被用了多少次)。
|
||||
Hits int `json:"hits,omitempty"`
|
||||
}
|
||||
|
||||
// Store 是变量库(进程级单例)。
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
data map[string]*Entry
|
||||
|
||||
// keyFn 返回主导主密钥(**当前**用来加密的那把)。为 nil 或报错时
|
||||
// **不做任何别名产出**。
|
||||
keyFn func() ([]byte, error)
|
||||
|
||||
// keys 是密钥环(E-1 密钥轮换)。
|
||||
//
|
||||
// keys[0] 是当前主密钥(与 keyFn 同源);其余是**只用于解密**的历史密钥。
|
||||
// 轮换时:ciphertext 用新钥重加密(此时新钥先在头部),
|
||||
// 旧钥保留在环里,好在过渡期能解开“尚未轮到”的旧主密钥密文。
|
||||
//
|
||||
// ★ 为何必须有环:只换 keyFn 会让所有旧密文立刻解不开
|
||||
// (响亮失败但服务不可用);而保持旧别名又要求旧密钥**不能立刻丢弃**
|
||||
// —— 因为新密文是按新钥加密的,两者必须同时可读才能平滑迁移。
|
||||
keys []keyGen
|
||||
|
||||
// enabled 支持显式关闭(本仓「显式才是特权」)。
|
||||
enabled bool
|
||||
|
||||
// onIntercept 是拦截回调(可观测:每次拦截都通知出去)。
|
||||
onIntercept func(Evt)
|
||||
}
|
||||
|
||||
// keyGen 是一代主密钥(含代号,便于审计“这条密文是哪一代加的”)。
|
||||
type keyGen struct {
|
||||
Key []byte
|
||||
// ID 是代号(短哈希),仅用于日志/审计,**不参与加解密**。
|
||||
ID string
|
||||
}
|
||||
|
||||
// Evt 是一次拦截事件(不含明文)。
|
||||
type Evt struct {
|
||||
Alias string `json:"alias"`
|
||||
Kind string `json:"kind"` // 模式名
|
||||
Where string `json:"where"` // input | tool_result:<tool>
|
||||
Len int `json:"len"`
|
||||
Merged bool `json:"merged"` // true = 复用已有别名(同变量合并)
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
// Options 构造参数。
|
||||
type Options struct {
|
||||
// Path 是变量库落盘路径(注意:**落盘**,不是内存)。
|
||||
Path string
|
||||
// KeyFn 返回主密钥(32 字节 AES-256)。
|
||||
KeyFn func() ([]byte, error)
|
||||
// OldKeys 是历史主密钥(解密用,E-1 密钥轮换)。
|
||||
//
|
||||
// 轮换后旧密文仍需可读 ⇒ 旧钥必须在环里;未轮换时为空。
|
||||
// 顺序无关(尝试顺序 = 当前钥后依次)。
|
||||
OldKeys [][]byte
|
||||
// Enabled 是否启用;默认 true。
|
||||
Enabled bool
|
||||
// OnIntercept 拦截回调(可 nil)。
|
||||
OnIntercept func(Evt)
|
||||
}
|
||||
|
||||
// New 构造变量库。
|
||||
func New(opt Options) *Store {
|
||||
s := &Store{
|
||||
path: opt.Path,
|
||||
data: map[string]*Entry{},
|
||||
keyFn: opt.KeyFn,
|
||||
enabled: opt.Enabled,
|
||||
onIntercept: opt.OnIntercept,
|
||||
}
|
||||
// 建初始密钥环:当前钥在头,历史钥在后。
|
||||
if opt.KeyFn != nil {
|
||||
if k, err := opt.KeyFn(); err == nil && len(k) == 32 {
|
||||
s.keys = append(s.keys, keyGen{Key: k, ID: keyID(k)})
|
||||
}
|
||||
}
|
||||
for _, k := range opt.OldKeys {
|
||||
if len(k) == 32 {
|
||||
s.keys = append(s.keys, keyGen{Key: k, ID: keyID(k)})
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// keyID 给一把密钥一个**不可逆的短代号**(仅用于日志/审计)。
|
||||
//
|
||||
// 不直接打印密钥,也不打印可被拿去比对完整密钥的东西。
|
||||
func keyID(k []byte) string {
|
||||
sum := sha256.Sum256(k)
|
||||
return hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
// Load 读入已落盘的变量库。
|
||||
//
|
||||
// 权限不正确时**响亮失败**(响亮失败,不静默降级):
|
||||
// 该文件含加密后的凭据,属高价值资产。
|
||||
func (s *Store) Load() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // 首次启动
|
||||
}
|
||||
return err
|
||||
}
|
||||
if fi, err := os.Stat(s.path); err == nil {
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
return fmt.Errorf(
|
||||
"security: 变量库权限不安全:%s 是 %04o,必须是 0600(它含加密凭据)",
|
||||
s.path, perm)
|
||||
}
|
||||
}
|
||||
var entries []*Entry
|
||||
if err := json.Unmarshal(b, &entries); err != nil {
|
||||
return fmt.Errorf("security: 变量库损坏(拒绝静默重建): %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e != nil && e.Alias != "" {
|
||||
s.data[e.Alias] = e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked 落盘并确权(调用方持锁)。
|
||||
func (s *Store) saveLocked() error {
|
||||
list := make([]*Entry, 0, len(s.data))
|
||||
for _, e := range s.data {
|
||||
list = append(list, e)
|
||||
}
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// ---------------- 别名(哈希键) ----------------
|
||||
|
||||
// Alias 由明文派生**确定性**别名。
|
||||
//
|
||||
// ★ 必须是 HMAC:别名会进上下文/日志/记忆。裸 SHA256 对弱口令可被字典枚举
|
||||
// (实测:明文 hunter2 → 裸哈希前 8 字节作别名,5 个常见弱口令里当场命中)。
|
||||
//
|
||||
// ★ 必须不加 salt:加了同一明文会得到不同别名 ⇒ 失去"同变量合并"。
|
||||
// 这里"确定性"是**特性**,安全靠 HMAC 的密钥而不是 salt。
|
||||
func (s *Store) Alias(plain string) (string, error) {
|
||||
key, err := s.key()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// ★ 轮换后同一明文必须继续得到**已有别名**:
|
||||
// 别名若随新主密钥改变,记忆/上下文里的旧 @{sec_...} 会失效,
|
||||
// 且同一个凭据会出现两份变量。先在现有条目中按密钥环解密比对;
|
||||
// 找到就复用旧别名,找不到才用当前钥派生新别名。
|
||||
cands := s.keysForDecrypt()
|
||||
if len(cands) == 0 {
|
||||
cands = [][]byte{key}
|
||||
}
|
||||
s.mu.RLock()
|
||||
for alias, e := range s.data {
|
||||
if got, openErr := openWithRing(cands, e.Sealed); openErr == nil && got == plain {
|
||||
s.mu.RUnlock()
|
||||
return alias, nil
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(plain))
|
||||
return AliasPrefix + hex.EncodeToString(m.Sum(nil)[:aliasHexLen/2]), nil
|
||||
}
|
||||
|
||||
func (s *Store) key() ([]byte, error) {
|
||||
if len(s.keys) > 0 {
|
||||
return s.keys[0].Key, nil
|
||||
}
|
||||
if s.keyFn == nil {
|
||||
return nil, ErrNoMasterKey
|
||||
}
|
||||
k, err := s.keyFn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(k) != 32 {
|
||||
return nil, fmt.Errorf("security: 主密钥必须是 32 字节(AES-256),实际 %d", len(k))
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// keysForDecrypt 返回**按尝试顺序**排列的解密候选密钥。
|
||||
//
|
||||
// 当前钥优先(Hits 高的常态路径一击即中),历史钥依次尝试。
|
||||
func (s *Store) keysForDecrypt() [][]byte {
|
||||
if len(s.keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([][]byte, 0, len(s.keys))
|
||||
for _, g := range s.keys {
|
||||
out = append(out, g.Key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openWithRing 用密钥环解开一条密文:依次尝试,返回第一个成功的明文。
|
||||
//
|
||||
// ★ 为何不是“记录每条密文属于哪一代”:那需要**改 Entry 结构**(加世代字段),
|
||||
// 而密文本身已经能被正确的那把钥唯一解开(AES-GCM 带认证)。
|
||||
// 逐个尝试的成本是常数级(代数量 <= 2 是常态),换来的是**零 schema 迁移**。
|
||||
func openWithRing(cands [][]byte, sealed string) (string, error) {
|
||||
if len(cands) == 0 {
|
||||
return "", ErrNoMasterKey
|
||||
}
|
||||
var lastErr error
|
||||
for _, k := range cands {
|
||||
plain, err := open(k, sealed)
|
||||
if err == nil {
|
||||
return plain, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// RotateKey 平滑轮换变量库主密钥,并保持**已有别名不变**。
|
||||
//
|
||||
// 事务顺序:先用旧密钥解开全部条目 → 用新密钥重加密到内存副本 →
|
||||
// 一次性落盘 → 最后切换当前密钥。任一步失败都不切换当前密钥,
|
||||
// 避免出现“内存已换、磁盘没换”的半轮换状态。
|
||||
//
|
||||
// oldKey 会留在进程内的历史环里,直到下一次进程重启;调用方必须把旧钥
|
||||
// 作为 OldKeys 在重启时注入(后续 keyring 接入会自动化这个步骤)。
|
||||
func (s *Store) RotateKey(newKey []byte) error {
|
||||
if len(newKey) != 32 {
|
||||
return fmt.Errorf("security: 新主密钥必须是 32 字节,实际 %d", len(newKey))
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
oldCandidates := s.keysForDecrypt()
|
||||
if len(oldCandidates) == 0 {
|
||||
if old, err := s.key(); err == nil {
|
||||
oldCandidates = [][]byte{old}
|
||||
}
|
||||
}
|
||||
if len(oldCandidates) == 0 {
|
||||
return ErrNoMasterKey
|
||||
}
|
||||
copyData := make(map[string]*Entry, len(s.data))
|
||||
for alias, e := range s.data {
|
||||
plain, err := openWithRing(oldCandidates, e.Sealed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("security: 轮换时解密变量 %s 失败,未切换密钥: %w", alias, err)
|
||||
}
|
||||
sealed, err := seal(newKey, plain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("security: 轮换时加密变量 %s 失败,未切换密钥: %w", alias, err)
|
||||
}
|
||||
cp := *e
|
||||
cp.Sealed = sealed
|
||||
cp.UpdatedAt = nowISO()
|
||||
copyData[alias] = &cp
|
||||
}
|
||||
oldCurrent := oldCandidates[0]
|
||||
s.data = copyData
|
||||
s.keys = append([]keyGen{{Key: newKey, ID: keyID(newKey)}, {Key: oldCurrent, ID: keyID(oldCurrent)}}, s.keys[1:]...)
|
||||
// keyFn 也切到新钥(未来没有用 New 构造的特殊调用路径仍一致)。
|
||||
s.keyFn = func() ([]byte, error) { return newKey, nil }
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return fmt.Errorf("security: 轮换落盘失败,当前内存已保留双钥环: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------- 加密 ----------------
|
||||
|
||||
func seal(key []byte, plain string) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encodeB64(gcm.Seal(nonce, nonce, []byte(plain), nil)), nil
|
||||
}
|
||||
|
||||
func open(key []byte, enc string) (string, error) {
|
||||
raw, err := decodeB64(enc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("security: 密文太短")
|
||||
}
|
||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("security: 解密失败(密钥不对或数据损坏): %w", err)
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// ---------------- 核心:拦截(入向明文 → 别名) ----------------
|
||||
|
||||
// Replace 把文本里的**明文**换成别名(LLM 出向用)。
|
||||
//
|
||||
// 返回替换后的文本与拦截事件。
|
||||
// ★ 同明文自动合并:因为别名是哈希键,同一明文天然得到同一别名。
|
||||
func (s *Store) Replace(text, where string, detect []Pattern) (string, []Evt) {
|
||||
if !s.enabled || strings.TrimSpace(text) == "" {
|
||||
return text, nil
|
||||
}
|
||||
// ★ 拿不到密钥就不产出别名(宁可漏报,不用裸哈希)
|
||||
if _, err := s.key(); err != nil {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
var evts []Evt
|
||||
out := text
|
||||
for _, p := range detect {
|
||||
// ★ 前置过滤:不含该模式必含的字面量就直接跳过正则。
|
||||
//
|
||||
// 性能理由(实测):本文件所有模式都以 `\b` 开头,而 Go 的 RE2
|
||||
// 对 `\b` 要逐 rune 做 Unicode 词边界判定 —— 50KB 文本上单条
|
||||
// 660µs,9 条合计 ~6ms。而绝大多数文本根本不含凭据前缀,
|
||||
// 用 strings.Contains 一挡,常态路径从毫秒级降到微秒级。
|
||||
//
|
||||
// ★ 正确性:prefilter 只用来**排除**(不含就一定不匹配),
|
||||
// 绝不用来**认定** —— 认定仍交给正则。所以它不会造成漏报。
|
||||
if !p.matchesPrefilter(out) {
|
||||
continue
|
||||
}
|
||||
out = p.RE.ReplaceAllStringFunc(out, func(hit string) string {
|
||||
secret, prefix := splitKeyValue(hit)
|
||||
alias, err := s.Alias(secret)
|
||||
if err != nil {
|
||||
return hit // 派生失败:原样保留(不产出坏别名)
|
||||
}
|
||||
merged, err := s.Put(alias, secret, "intercepted", "自动拦截 @"+where)
|
||||
if err != nil {
|
||||
return hit
|
||||
}
|
||||
evts = append(evts, Evt{
|
||||
Alias: alias, Kind: p.Name, Where: where,
|
||||
Len: len(secret), Merged: merged, At: nowISO(),
|
||||
})
|
||||
return prefix + fmt.Sprintf(
|
||||
"【信息被安全模块自动拦截,请使用变量 @{%s} 进行调用】", alias)
|
||||
})
|
||||
}
|
||||
return out, evts
|
||||
}
|
||||
|
||||
// Put 登记一条变量(幂等:同别名只更新,不重复插)。
|
||||
//
|
||||
// 返回 merged=true 表示这是**合并到已有别名**(同变量合并能力)。
|
||||
func (s *Store) Put(alias, plain, source, note string) (merged bool, err error) {
|
||||
key, err := s.key()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sealed, err := seal(key, plain)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
prev, exists := s.data[alias]
|
||||
if exists {
|
||||
prev.Hits++
|
||||
prev.UpdatedAt = nowISO()
|
||||
if note != "" {
|
||||
prev.Note = note
|
||||
}
|
||||
_ = s.saveLocked()
|
||||
return true, nil
|
||||
}
|
||||
s.data[alias] = &Entry{
|
||||
Alias: alias, Sealed: sealed, Source: source,
|
||||
Note: note, CreatedAt: nowISO(), Hits: 1,
|
||||
}
|
||||
return false, s.saveLocked()
|
||||
}
|
||||
|
||||
// Submit 由 agent **主动提交**一条明文,返回其别名。
|
||||
//
|
||||
// 用途:agent 知道后面要用某个凭据,可先登记(此时它可能还没在文本里出现过)。
|
||||
func (s *Store) Submit(plain, note string) (string, error) {
|
||||
alias, err := s.Alias(plain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := s.Put(alias, plain, "submitted", note); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return alias, nil
|
||||
}
|
||||
|
||||
// Forget 由 agent **主动删除**一条变量(用户要求:不自动淘汰,但可主动删)。
|
||||
func (s *Store) Forget(alias string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.data[alias]; !ok {
|
||||
return ErrUnknownAlias
|
||||
}
|
||||
delete(s.data, alias)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// ---------------- 核心:回填(别名 → 明文,插件入向用) ----------------
|
||||
|
||||
// Fill 把文本里的 `@{alias}` 换回明文(路由到插件前用)。
|
||||
//
|
||||
// ★ 这是"插件能用密钥"的唯一通道,也是"插件无感知"的实现:
|
||||
// 插件只看到已经填好的明文,不知道有替换这回事。
|
||||
//
|
||||
// 返回 (替换后文本, 替换了几处, 用到的别名)。
|
||||
// 未知别名**原样保留**:不报错也不吞掉,以免把正常文本(恰好像引用)改坏。
|
||||
func (s *Store) Fill(text string) (string, int, []string) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return text, 0, nil
|
||||
}
|
||||
cands := s.keysForDecrypt()
|
||||
if len(cands) == 0 {
|
||||
// 环为空时退回 keyFn(未用 New 构造的路径,如测试里 SetGlobal)
|
||||
if k, err := s.key(); err == nil {
|
||||
cands = [][]byte{k}
|
||||
}
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return text, 0, nil
|
||||
}
|
||||
var used []string
|
||||
n := 0
|
||||
out := refPattern.ReplaceAllStringFunc(text, func(m string) string {
|
||||
sub := refPattern.FindStringSubmatch(m)
|
||||
if len(sub) != 2 {
|
||||
return m
|
||||
}
|
||||
alias := sub[1]
|
||||
s.mu.RLock()
|
||||
e, ok := s.data[alias]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return m // 未知别名:原样保留
|
||||
}
|
||||
plain, err := openWithRing(cands, e.Sealed)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
n++
|
||||
used = append(used, alias)
|
||||
return plain
|
||||
})
|
||||
return out, n, used
|
||||
}
|
||||
|
||||
// FillMap 对 map 的字符串值做回填(工具参数是 map)。
|
||||
//
|
||||
// 递归处理嵌套 map / slice —— 工具参数常常是嵌套结构。
|
||||
func (s *Store) FillMap(args map[string]interface{}) (int, []string) {
|
||||
var total int
|
||||
var all []string
|
||||
var walk func(v interface{}) interface{}
|
||||
walk = func(v interface{}) interface{} {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
out, n, used := s.Fill(t)
|
||||
total += n
|
||||
all = append(all, used...)
|
||||
return out
|
||||
case map[string]interface{}:
|
||||
for k := range t {
|
||||
t[k] = walk(t[k])
|
||||
}
|
||||
return t
|
||||
case []interface{}:
|
||||
for i := range t {
|
||||
t[i] = walk(t[i])
|
||||
}
|
||||
return t
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
for k := range args {
|
||||
args[k] = walk(args[k])
|
||||
}
|
||||
return total, all
|
||||
}
|
||||
|
||||
// ---------------- 查询(★ 永不返回明文) ----------------
|
||||
|
||||
// Info 是变量的元信息(不含明文)。
|
||||
type Info struct {
|
||||
Alias string `json:"alias"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Hits int `json:"hits,omitempty"`
|
||||
}
|
||||
|
||||
// List 列出全部变量元信息。
|
||||
//
|
||||
// ★ 不提供 Get/明文接口 —— 见 S1。要看值就在命令里用 @{alias}。
|
||||
func (s *Store) List() []Info {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Info, 0, len(s.data))
|
||||
for _, e := range s.data {
|
||||
out = append(out, Info{
|
||||
Alias: e.Alias, Source: e.Source, Note: e.Note,
|
||||
CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, Hits: e.Hits,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Count 返回变量条数(供状态显示与规模告警)。
|
||||
func (s *Store) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.data)
|
||||
}
|
||||
|
||||
// ---------------- 辅助 ----------------
|
||||
|
||||
// splitKeyValue 把 `key=value` / `key = "value"` 拆成前缀与值,
|
||||
// 使替换后保留键名(否则人看不出发生了什么)。
|
||||
//
|
||||
// ★ 键名后常带空格,所以必须 trim 后再判;初版把"含空格"当成
|
||||
// "不像键名"就放弃拆分,导致 `api_key = "xxx"` 整段被当 secret(键名丢失)。
|
||||
func splitKeyValue(hit string) (secret, prefix string) {
|
||||
for _, sep := range []string{"=", ":"} {
|
||||
i := strings.Index(hit, sep)
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
// 排除 URL 的 `://`
|
||||
if sep == ":" && i+1 < len(hit) && hit[i+1] == '/' {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(hit[:i])
|
||||
if key == "" || len(key) > 40 || strings.ContainsAny(key, "/@ \t\n") {
|
||||
continue
|
||||
}
|
||||
val := strings.Trim(strings.TrimSpace(hit[i+1:]), "\"'`")
|
||||
return val, key + sep + " "
|
||||
}
|
||||
return hit, ""
|
||||
}
|
||||
|
||||
func nowISO() string { return time.Now().Format(time.RFC3339) }
|
||||
|
||||
// sha256Sum / hexEncode 是给判据用的薄封装(仅测试引用)。
|
||||
func sha256Sum(b []byte) [32]byte { return sha256.Sum256(b) }
|
||||
|
||||
func hexEncode(b []byte) string { return hex.EncodeToString(b) }
|
||||
443
internal/security/store_test.go
Normal file
443
internal/security/store_test.go
Normal file
@ -0,0 +1,443 @@
|
||||
package security
|
||||
|
||||
// 安全模块的判据集。
|
||||
//
|
||||
// 每条判据对应设计文档里的一条硬约束(S1–S12)。
|
||||
// 纪律:**判据必须先跑通,再接内核** —— 否则接线出错时无法区分
|
||||
// 「安全模块坏了」还是「接线错了」。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testKey() ([]byte, error) {
|
||||
return []byte("0123456789abcdef0123456789abcdef"), nil
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T, enabled bool) *Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s := New(Options{
|
||||
Path: filepath.Join(dir, "variables.json"),
|
||||
KeyFn: testKey,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// const testToken 是一个形状合法的 GitHub token(用于触发模式库)。
|
||||
const testToken = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// ★★ S3 + S11:**同明文自动合并到同一别名**(哈希键的天然性质)。
|
||||
func TestSameSecretMergesToSameAlias(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
|
||||
a1, err := s.Alias(testToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a2, _ := s.Alias(testToken)
|
||||
if a1 != a2 {
|
||||
t.Fatalf("同一明文必须得到同一别名,实际 %s vs %s", a1, a2)
|
||||
}
|
||||
// 别名形态:sec_ + 16 hex = 20 字符
|
||||
if !strings.HasPrefix(a1, AliasPrefix) || len(a1) != 20 {
|
||||
t.Errorf("别名形态异常: %q (len=%d)", a1, len(a1))
|
||||
}
|
||||
// 不同明文 ⇒ 不同别名
|
||||
other, _ := s.Alias("ghp_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ")
|
||||
if other == a1 {
|
||||
t.Error("不同明文不应合并")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ S3:别名必须**依赖主密钥**(即 HMAC,不是裸哈希)。
|
||||
//
|
||||
// 裸哈希可被字典枚举(实测 hunter2 当场命中),而别名会进上下文/日志/记忆。
|
||||
func TestAliasDependsOnMasterKey(t *testing.T) {
|
||||
s1 := New(Options{Path: filepath.Join(t.TempDir(), "a.json"), KeyFn: testKey, Enabled: true})
|
||||
other := func() ([]byte, error) { return []byte("ffffffffffffffffffffffffffffffff"), nil }
|
||||
s2 := New(Options{Path: filepath.Join(t.TempDir(), "b.json"), KeyFn: other, Enabled: true})
|
||||
|
||||
a1, _ := s1.Alias("same-plain")
|
||||
a2, _ := s2.Alias("same-plain")
|
||||
if a1 == a2 {
|
||||
t.Fatal("不同主密钥必须产出不同别名(否则说明用了裸哈希)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:裸哈希可被字典枚举 —— 用真实数据证明 HMAC 的必要性。
|
||||
//
|
||||
// 这条不是测代码,而是**固化设计依据**:若有天有人把 HMAC 换成裸 SHA256,
|
||||
// 这条判据会连同其注释一起提醒他为什么不行。
|
||||
func TestBareHashIsEnumerable(t *testing.T) {
|
||||
// 攻击者视角:拿到别名(裸哈希前 16 hex),拿常见弱口令字典去试
|
||||
weak := "hunter2"
|
||||
sum := sha256Hex16(weak)
|
||||
// 字典命中(一行代码就够)
|
||||
hit := false
|
||||
for _, g := range []string{"password123", "admin", "letmein", "hunter2", "secret"} {
|
||||
if sha256Hex16(g) == sum {
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
t.Fatal("本判据的前提失效(裸哈希应可被字典命中)")
|
||||
}
|
||||
// 而 HMAC 版本:没有 key 无法枚举
|
||||
s := newTestStore(t, true)
|
||||
a, _ := s.Alias(weak)
|
||||
if a == AliasPrefix+sum {
|
||||
t.Fatal("别名用了裸哈希 —— 会泄露 secret 内容")
|
||||
}
|
||||
t.Logf("裸哈希(危险): %s%s ;HMAC(当前实现): %s", AliasPrefix, sum, a)
|
||||
}
|
||||
|
||||
// ★★ S1 + 插件无感知:拦截把明文换成别名,稍后能用别名换回(回填)。
|
||||
func TestReplaceThenFill(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
|
||||
text := "我的 token 是 " + testToken + " 请使用"
|
||||
out, evts := s.Replace(text, "input", Patterns)
|
||||
|
||||
if strings.Contains(out, testToken) {
|
||||
t.Fatalf("明文未被替换: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "安全模块自动拦截") {
|
||||
t.Errorf("缺少拦截提示: %s", out)
|
||||
}
|
||||
if len(evts) != 1 {
|
||||
t.Fatalf("应恰好 1 条拦截事件,实际 %d", len(evts))
|
||||
}
|
||||
alias := evts[0].Alias
|
||||
if !strings.Contains(out, "@{"+alias+"}") {
|
||||
t.Errorf("提示里应含 @{%s}: %s", alias, out)
|
||||
}
|
||||
|
||||
// ★ 回填:把别名换回明文(这是插件能用密钥的唯一通道)
|
||||
back, n, used := s.Fill("Authorization: token @{" + alias + "}")
|
||||
if n != 1 {
|
||||
t.Fatalf("应回填 1 处,实际 %d", n)
|
||||
}
|
||||
if !strings.Contains(back, testToken) {
|
||||
t.Fatalf("回填未生效: %s", back)
|
||||
}
|
||||
if len(used) != 1 || used[0] != alias {
|
||||
t.Errorf("应报告用到的别名: %v", used)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ S4:**不可误伤** —— 正常文本绝不能被替换。
|
||||
//
|
||||
// 误报比漏报严重得多:它会把正常内容换掉、把任务搞坏,且归因极难。
|
||||
func TestNoFalsePositives(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
benign := []string{
|
||||
"请修复这个 bug,提交到 feat/login 分支",
|
||||
"git commit -m 'add feature'",
|
||||
"https://github.com/JianFeeeee/HomeAgent/pull/42",
|
||||
"这个函数返回 404 或者 500",
|
||||
"hex 颜色 #4f46e5 很好看",
|
||||
// ★ 关键:40 位 git SHA-1 不得被误伤(否则 agent 拿不到 commit id)
|
||||
"commit 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b",
|
||||
"版本号 v1.2.3",
|
||||
"文件 /home/program/TrueAgent/docs/zh/kernel-secret-firewall-design.md",
|
||||
"时间戳 2026-10-14T17:27:16+08:00",
|
||||
"UUID 550e8400-e29b-41d4-a716-446655440000",
|
||||
"讨论一下 secret 管理和 token 轮换的策略", // 含关键词但不是赋值形态
|
||||
"password 字段的长度校验有问题", // 同上
|
||||
}
|
||||
for _, s0 := range benign {
|
||||
out, evts := s.Replace(s0, "input", Patterns)
|
||||
if len(evts) > 0 {
|
||||
t.Errorf("误伤正常文本 %q → %v", s0, evts)
|
||||
}
|
||||
if out != s0 {
|
||||
t.Errorf("未命中却改动了文本: %q → %q", s0, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 各类凭据形状都要能识别(覆盖面)。
|
||||
func TestRecognizesCommonShapes(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
cases := []struct{ name, text string }{
|
||||
{"github", testToken},
|
||||
{"gitlab", "glpat-FAKE-TEST-TOKEN-NOT-REAL"},
|
||||
{"openai", "sk-FAKE-TEST-KEY-NOT-REAL-0000000"},
|
||||
{"anthropic", "sk-ant-FAKE-TEST-KEY-NOT-REAL-00"},
|
||||
{"aws", "AKIA0000FAKE0000TEST"},
|
||||
{"slack", "xoxb-FAKE-TEST-TOKEN-NOT-REAL"},
|
||||
{"assignment", `api_key = "FAKE-TEST-ASSIGNED-VALUE-000"`},
|
||||
{"bearer", "Authorization: Bearer FAKE-TEST-BEARER-TOKEN-NOT-REAL"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, evts := s.Replace(c.text, "input", Patterns)
|
||||
if len(evts) == 0 {
|
||||
t.Errorf("%s 形状未被识别: %q", c.name, c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:赋值形态保留键名(否则人看不懂发生了什么)。
|
||||
func TestAssignmentKeepsKeyName(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
out, evts := s.Replace(`api_key = "FAKE-TEST-ASSIGNED-VALUE-000"`, "input", Patterns)
|
||||
if len(evts) == 0 {
|
||||
t.Fatal("未命中")
|
||||
}
|
||||
if !strings.Contains(out, "api_key") {
|
||||
t.Errorf("应保留键名: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ S1:**不提供取回明文的接口**(看值只能靠回填)。
|
||||
//
|
||||
// 这条是防回归:若有人加了 Get/Reveal 之类的方法,判据通过编译期就能发现。
|
||||
func TestNoPlaintextReadAPI(t *testing.T) {
|
||||
// 语义判据:List() 只给元信息;明文只能经 Fill 出现在"待发送的文本"里
|
||||
s := newTestStore(t, true)
|
||||
alias, err := s.Submit(testToken, "测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
infos := s.List()
|
||||
if len(infos) != 1 {
|
||||
t.Fatalf("应有 1 条记录,实际 %d", len(infos))
|
||||
}
|
||||
b, _ := json.Marshal(infos)
|
||||
if strings.Contains(string(b), testToken) {
|
||||
t.Fatalf("List 泄露了明文: %s", b)
|
||||
}
|
||||
if !strings.Contains(string(b), alias) {
|
||||
t.Error("List 应给出别名")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 落盘:明文绝不落盘,且文件必须 0600。
|
||||
func TestPersistsCiphertextOnlyWith0600(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
if _, err := s.Submit(testToken, "落盘测试"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), testToken) {
|
||||
t.Fatalf("磁盘文件出现明文: %s", b)
|
||||
}
|
||||
if fi, err := os.Stat(s.path); err == nil {
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("变量库权限应为 0600,实际 %04o", fi.Mode().Perm())
|
||||
}
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:权限不安全的变量库**拒绝加载**(响亮失败,不静默降级)。
|
||||
func TestRejectsLoosePermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "v.json")
|
||||
if err := os.WriteFile(p, []byte("[]"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(Options{Path: p, KeyFn: testKey, Enabled: true})
|
||||
if err := s.Load(); err == nil {
|
||||
t.Fatal("0644 的变量库必须被拒绝")
|
||||
}
|
||||
if err := os.Chmod(p, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatalf("0600 应可加载: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 持久化往返:重启后别名仍可用(用户要求落盘 + 不自动淘汰)。
|
||||
func TestReloadKeepsVariables(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "v.json")
|
||||
s1 := New(Options{Path: p, KeyFn: testKey, Enabled: true})
|
||||
if err := s1.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias, err := s1.Submit(testToken, "重启测试")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟重启
|
||||
s2 := New(Options{Path: p, KeyFn: testKey, Enabled: true})
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, n, _ := s2.Fill("x=@{" + alias + "}")
|
||||
if n != 1 || !strings.Contains(out, testToken) {
|
||||
t.Fatalf("重启后别名应仍可用: n=%d out=%q", n, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ S12:不自动淘汰,但 agent 可**主动删除**。
|
||||
func TestForgetRemovesVariable(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
alias, _ := s.Submit(testToken, "")
|
||||
if err := s.Forget(alias); err != nil {
|
||||
t.Fatalf("删除失败: %v", err)
|
||||
}
|
||||
out, n, _ := s.Fill("x=@{" + alias + "}")
|
||||
if n != 0 || strings.Contains(out, testToken) {
|
||||
t.Fatalf("删除后不应再回填: %q", out)
|
||||
}
|
||||
if err := s.Forget(alias); err != ErrUnknownAlias {
|
||||
t.Errorf("重复删除应返回 ErrUnknownAlias,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:拿不到密钥 ⇒ **拒绝产出别名**(宁可漏报,不用裸哈希)。
|
||||
//
|
||||
// 这里要**直接测 `Alias()`**,不能只测 Replace:
|
||||
// Replace 里另有一道外层护栏(无密钥就整体早退),会把 Alias 层的降级
|
||||
// 挡住 —— 变异实验证实了这一点(只测 Replace 时,把 Alias 改成裸哈希降级
|
||||
// 判据仍绿)。纵深防御是对的,但两层必须各自有判据盯着。
|
||||
func TestNoKeyMeansNoAlias(t *testing.T) {
|
||||
s := New(Options{Path: filepath.Join(t.TempDir(), "v.json"), KeyFn: nil, Enabled: true})
|
||||
|
||||
// ① Alias() 本身必须报错(不降级为裸哈希)
|
||||
if a, err := s.Alias("anything"); err != ErrNoMasterKey {
|
||||
t.Errorf("无密钥时 Alias 应返回 ErrNoMasterKey,实际 alias=%q err=%v", a, err)
|
||||
}
|
||||
// ② Submit() 也必须失败(不静默记个裸哈希别名)
|
||||
if a, err := s.Submit(testToken, ""); err == nil {
|
||||
t.Errorf("无密钥时 Submit 应失败,实际得到 %q", a)
|
||||
}
|
||||
// ③ outer guard:Replace 原样返回
|
||||
out, evts := s.Replace("token="+testToken, "input", Patterns)
|
||||
if len(evts) != 0 {
|
||||
t.Fatal("无密钥时不得产出别名")
|
||||
}
|
||||
if out != "token="+testToken {
|
||||
t.Errorf("无密钥时应原样保留: %s", out)
|
||||
}
|
||||
// ④ Fill 也应安全降级(不 panic、不改动)
|
||||
if got, n, _ := s.Fill("@{sec_x}"); n != 0 || got != "@{sec_x}" {
|
||||
t.Errorf("无密钥时 Fill 应原样返回: %q", got)
|
||||
}
|
||||
// ⑤ 密钥长度不对也必须拒绝(AES-256 要 32 字节)
|
||||
bad := New(Options{Path: filepath.Join(t.TempDir(), "w.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("too-short"), nil }, Enabled: true})
|
||||
if _, err := bad.Alias("x"); err == nil {
|
||||
t.Error("密钥长度不对时必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 开关:关闭后完全不做事(显式才是特权)。
|
||||
func TestDisabledDoesNothing(t *testing.T) {
|
||||
s := newTestStore(t, false)
|
||||
out, evts := s.Replace("token="+testToken, "input", Patterns)
|
||||
if len(evts) != 0 || out != "token="+testToken {
|
||||
t.Error("关闭后不应做任何替换")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ FillMap:工具参数是 map,要能递归回填(插件调用前用)。
|
||||
func TestFillMapRecursive(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
alias, _ := s.Submit(testToken, "")
|
||||
|
||||
args := map[string]interface{}{
|
||||
"url": "https://" + "@{" + alias + "}" + "@host/repo",
|
||||
"nested": map[string]interface{}{
|
||||
"token": "@{" + alias + "}",
|
||||
},
|
||||
"list": []interface{}{"plain", "@{" + alias + "}"},
|
||||
"num": 42,
|
||||
}
|
||||
n, used := s.FillMap(args)
|
||||
if n != 3 {
|
||||
t.Fatalf("应回填 3 处,实际 %d", n)
|
||||
}
|
||||
if len(used) != 3 {
|
||||
t.Errorf("应报告 3 次使用: %v", used)
|
||||
}
|
||||
if !strings.Contains(args["url"].(string), testToken) {
|
||||
t.Error("顶层未回填")
|
||||
}
|
||||
if !strings.Contains(args["nested"].(map[string]interface{})["token"].(string), testToken) {
|
||||
t.Error("嵌套 map 未回填")
|
||||
}
|
||||
if !strings.Contains(args["list"].([]interface{})[1].(string), testToken) {
|
||||
t.Error("数组未回填")
|
||||
}
|
||||
if args["num"].(int) != 42 {
|
||||
t.Error("非字符串值不应被改动")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 未知别名:原样保留(不报错、不吞掉),以免把正常文本改坏。
|
||||
func TestFillKeepsUnknownAlias(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
in := "看看 @{sec_notexist} 和 @{sec_0000000000000000}"
|
||||
out, n, _ := s.Fill(in)
|
||||
if n != 0 {
|
||||
t.Fatalf("未知别名不应被替换,实际 %d", n)
|
||||
}
|
||||
if out != in {
|
||||
t.Errorf("未知别名应原样保留: %q", out)
|
||||
}
|
||||
// 不像引用的文本也不该被碰
|
||||
out2, n2, _ := s.Fill("普通文本 @{bad-name!} 与 email@example.com")
|
||||
if n2 != 0 || out2 != "普通文本 @{bad-name!} 与 email@example.com" {
|
||||
t.Errorf("非引用文本被改动: %q", out2)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 拦截事件是**可观测**的(S5,不静默做事),且 merged 标明是否合并。
|
||||
func TestInterceptEventsObservable(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
_, e1 := s.Replace("a="+testToken, "input", Patterns)
|
||||
_, e2 := s.Replace("b="+testToken, "tool_result:cmd_run", Patterns)
|
||||
|
||||
if len(e1) != 1 || len(e2) != 1 {
|
||||
t.Fatal("两次都应各产生 1 条事件")
|
||||
}
|
||||
if e1[0].Merged {
|
||||
t.Error("首次应 merged=false")
|
||||
}
|
||||
if !e2[0].Merged {
|
||||
t.Error("第二次同明文应 merged=true(合并)")
|
||||
}
|
||||
if e1[0].Alias != e2[0].Alias {
|
||||
t.Error("同明文应同一别名")
|
||||
}
|
||||
if e2[0].Where != "tool_result:cmd_run" {
|
||||
t.Errorf("应记录来源位置: %q", e2[0].Where)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 判据:明文(高熵)不得从**别名**反推出来。
|
||||
//
|
||||
// 这是 HMAC + 截断的性质,用抽样验证(不是穷举证明,而是防低级错误)。
|
||||
func TestAliasDoesNotLeakPlaintext(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
alias, _ := s.Alias(testToken)
|
||||
// 别名里不能包含明文的任何连续 8 字符片段
|
||||
for i := 0; i+8 <= len(testToken); i++ {
|
||||
if strings.Contains(alias, testToken[i:i+8]) {
|
||||
t.Fatalf("别名泄露了明文片段: %s", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex16(s string) string {
|
||||
h := sha256Sum([]byte(s))
|
||||
return hexEncode(h[:8])
|
||||
}
|
||||
304
internal/security/stress_test.go
Normal file
304
internal/security/stress_test.go
Normal file
@ -0,0 +1,304 @@
|
||||
package security
|
||||
|
||||
// 并发压测 —— ★ 本文件存在的理由
|
||||
//
|
||||
// 安全模块挂在**热路径**上:
|
||||
//
|
||||
// 每次 LLM 调用 → provider 装饰器调 Replace
|
||||
// 每次工具调用 → StageHost.ExecuteTool 调 FillMap
|
||||
// 每次上下文写入 → Append 调 Replace
|
||||
//
|
||||
// 而内核是**并发**的:多 agent、多任务、调度器并行、插件回调在各自 goroutine。
|
||||
// 这意味着 Store 的每个方法都会被并发调用。
|
||||
//
|
||||
// ★ 初版判据全是**单线程**的,`-race` 通过毫无意义(没有并发就没有竞态可报)。
|
||||
// 本文件补上真正的并发压力:
|
||||
//
|
||||
// · 同一明文并发 Submit(考验"同别名合并"在竞态下是否仍成立)
|
||||
// · 并发 Put 同一条目(考验 Hits++ 是否丢更新/竞态)
|
||||
// · 并发 Fill 与 Put 交错(考验读写锁边界)
|
||||
// · 并发 Forget 与 Fill(考验"删了但仍在读")
|
||||
// · 并发落盘(考验 tmp 文件 + rename 是否互相踩)
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stressStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s := New(Options{
|
||||
Path: filepath.Join(dir, "vars.json"),
|
||||
KeyFn: testKey,
|
||||
Enabled: true,
|
||||
})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ★★ 判据:**同一明文并发 Submit ⇒ 仍只有一个条目**(合并语义在竞态下成立)。
|
||||
//
|
||||
// 这是 S11(同变量自动合并)在并发下的形态。若合并只在单线程下成立,
|
||||
// 并行 agent 会让别名表膨胀、且同一密钥在不同上下文里显示成不同的名字。
|
||||
func TestStress_ConcurrentSubmitSamePlainMergesToOne(t *testing.T) {
|
||||
s := stressStore(t)
|
||||
const plain = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
const goroutines = 64
|
||||
var wg sync.WaitGroup
|
||||
var errs atomic.Int64
|
||||
aliases := make([]string, goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
a, err := s.Submit(plain, fmt.Sprintf("g%d", i))
|
||||
if err != nil {
|
||||
errs.Add(1)
|
||||
return
|
||||
}
|
||||
aliases[i] = a
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if errs.Load() > 0 {
|
||||
t.Fatalf("%d 个 goroutine 提交失败", errs.Load())
|
||||
}
|
||||
// ① 只有一个条目
|
||||
if got := s.Count(); got != 1 {
|
||||
t.Fatalf("★ 同明文并发提交应合并为 1 条,实际 %d 条 —— "+
|
||||
"合并语义在并发下不成立,并行 agent 会让别名表膨胀", got)
|
||||
}
|
||||
// ② 所有 goroutine 拿到**同一个**别名
|
||||
for i, a := range aliases {
|
||||
if a != aliases[0] {
|
||||
t.Fatalf("★ 第 %d 个 goroutine 拿到不同别名:%s vs %s", i, a, aliases[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:并发 Put 同一条目时 **Hits 不丢更新**。
|
||||
//
|
||||
// Go 里 `prev.Hits++` 在持锁区内是安全的,但若有人把锁移出("优化"),
|
||||
// 就会静默丢计数 —— 而计数是"这个凭据被用了多少次"的唯一可观测,
|
||||
// 丢了就再也看不出异常用量。
|
||||
func TestStress_ConcurrentPutDoesNotLoseHits(t *testing.T) {
|
||||
s := stressStore(t)
|
||||
const plain = "secret-value-for-hits"
|
||||
alias, err := s.Alias(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const n = 200
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := s.Put(alias, plain, "submitted", ""); err != nil {
|
||||
t.Errorf("Put 失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
infos := s.List()
|
||||
if len(infos) != 1 {
|
||||
t.Fatalf("应只有 1 条,实际 %d", len(infos))
|
||||
}
|
||||
// 每个 Put 都 +1(首次 Put 建条目时 Hits=1,其余各 +1)
|
||||
if infos[0].Hits != n {
|
||||
t.Errorf("★ Hits 丢更新:期望 %d,实际 %d", n, infos[0].Hits)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发 Fill 与 Put 交错**不 panic、不返回坏值。
|
||||
//
|
||||
// Fill 走 RLock,Put 走 Lock —— 边界若错(如 Fill 里改了 map),
|
||||
// race detector 会报,或读到半写状态。
|
||||
func TestStress_ConcurrentFillAndPut(t *testing.T) {
|
||||
s := stressStore(t)
|
||||
const plain = "concurrent-fill-put-secret"
|
||||
alias, err := s.Submit(plain, "n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref := "@{" + alias + "}"
|
||||
|
||||
// 写者与读者**分别**用两个 WaitGroup。
|
||||
//
|
||||
// ★ 初版用一个 wg 同时等两者,读者又等 stop,而 stop 在 <-done 之后
|
||||
// 才关 ⇒ **测试自身死锁**(挂了 600s 才发现)。这不是 Store 的问题,
|
||||
// 但同样值得记:并发测试的等待结构写错,会把"测试挂了"伪装成"被测对象挂了"。
|
||||
var writers, readers sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
writers.Add(1)
|
||||
go func(i int) {
|
||||
defer writers.Done()
|
||||
for j := 0; j < 200; j++ {
|
||||
if _, err := s.Submit(fmt.Sprintf("other-secret-%d-%d", i, j), ""); err != nil {
|
||||
t.Errorf("Submit: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
out, n, _ := s.Fill("x=" + ref)
|
||||
if n != 1 {
|
||||
t.Errorf("★ 并发下 Fill 失败:n=%d out=%q", n, out)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(out, plain) {
|
||||
t.Errorf("★ 并发下 Fill 返回坏值: %q", out)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writers.Wait() // 等写者跑完
|
||||
close(stop) // 再停读者
|
||||
readers.Wait()
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发 Forget 与 Fill** 不 panic("删了但仍在读")。
|
||||
//
|
||||
// 这是最容易出事的组合之一:Fill 读到的条目可能刚好被 Forget 删掉。
|
||||
// 期望:要么填出明文,要么原样返回引用 —— 绝不能 panic 或吐垃圾。
|
||||
func TestStress_ConcurrentForgetAndFill(t *testing.T) {
|
||||
s := stressStore(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for round := 0; round < 40; round++ {
|
||||
plain := fmt.Sprintf("volatile-secret-%d-aaaaaaaaaaaaaaaa", round)
|
||||
alias, err := s.Submit(plain, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref := "@{" + alias + "}"
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _, _ = s.Fill("v=" + ref)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = s.Forget(alias)
|
||||
}()
|
||||
}
|
||||
wg.Wait() // 只要不 panic / 不报 race 就算过
|
||||
}
|
||||
|
||||
// ★★ 判据:**并发落盘不损坏文件**(tmp + rename 的原子性)。
|
||||
//
|
||||
// 每次 Put 都会写盘。并发写若直接写目标文件,会互相截断 ——
|
||||
// 而后果是"变量库损坏 ⇒ 拒绝启动",用户的所有凭据都读不出来。
|
||||
func TestStress_ConcurrentSaveKeepsFileValid(t *testing.T) {
|
||||
s := stressStore(t)
|
||||
|
||||
const n = 120
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if _, err := s.Submit(fmt.Sprintf("save-stress-%d-value", i), ""); err != nil {
|
||||
t.Errorf("Submit: %v", err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 文件必须能被**重新解析**(模拟重启加载)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
t.Fatalf("落盘失败: %v", err)
|
||||
}
|
||||
s2 := New(Options{Path: s.path, KeyFn: testKey, Enabled: true})
|
||||
if err := s2.Load(); err != nil {
|
||||
t.Fatalf("★ 并发写后变量库损坏(重启就读不出来): %v", err)
|
||||
}
|
||||
if s2.Count() != n {
|
||||
t.Errorf("重载后应有 %d 条,实际 %d", n, s2.Count())
|
||||
}
|
||||
// 权限仍须 0600
|
||||
if fi, err := os.Stat(s.path); err == nil {
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("并发写后权限变了: %04o", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 判据:`Replace` 与 `Fill` 并发(出向拦截 vs 入向回填同时发生)。
|
||||
//
|
||||
// 真实场景:一个 agent 在出向(扫明文),另一个在入向(填别名)。
|
||||
func TestStress_ConcurrentReplaceAndFill(t *testing.T) {
|
||||
s := stressStore(t)
|
||||
const plain = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
var writers, readers sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for i := 0; i < 6; i++ {
|
||||
writers.Add(1)
|
||||
go func() {
|
||||
defer writers.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
out, evts := s.Replace("token="+plain, "stress", Patterns)
|
||||
if len(evts) == 0 {
|
||||
t.Errorf("★ 并发下 Replace 未命中(模式库被改?)")
|
||||
return
|
||||
}
|
||||
if strings.Contains(out, plain) {
|
||||
t.Errorf("★ 并发下 Replace 泄露明文: %q", out)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
alias, _ := s.Alias(plain)
|
||||
ref := "@{" + alias + "}"
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
out, n, _ := s.Fill("x=" + ref)
|
||||
// n==0 是允许的(Replace 可能还没登记完);但填出来就必须是明文
|
||||
if n == 1 && !strings.Contains(out, plain) {
|
||||
t.Errorf("★ 并发下 Fill 返回坏值: %q", out)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
writers.Wait()
|
||||
close(stop)
|
||||
readers.Wait()
|
||||
}
|
||||
81
internal/security/survey_test.go
Normal file
81
internal/security/survey_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 盘点:什么会被拦、什么不会。这份清单直接对应生产行为。
|
||||
func TestSurvey_WhatIsCaught(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
|
||||
caught := []struct{ name, text string }{
|
||||
{"GitHub token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"},
|
||||
{"GitHub fine-grained", "github_pat_" + strings.Repeat("A", 60)},
|
||||
{"GitLab", "glpat-FAKE-TEST-TOKEN-NOT-REAL"},
|
||||
{"OpenAI", "sk-FAKE-TEST-KEY-NOT-REAL-0000000"},
|
||||
{"Anthropic", "sk-ant-FAKE-TEST-KEY-NOT-REAL-00"},
|
||||
{"AWS", "AKIA0000FAKE0000TEST"},
|
||||
{"AWS 临时", "ASIA0000FAKE0000TEST"},
|
||||
{"Slack", "xoxb-FAKE-TEST-TOKEN-NOT-REAL"},
|
||||
{"Bearer 头", "Authorization: Bearer " + strings.Repeat("a", 40)},
|
||||
{"私钥块", "-----BEGIN RSA PRIVATE KEY-----\n" + strings.Repeat("MIIE", 20) + "\n-----END RSA PRIVATE KEY-----"},
|
||||
{"赋值式 api_key", `api_key = "FAKE-TEST-ASSIGNED-VALUE-000"`},
|
||||
{"赋值式 password", `password: "FAKE-TEST-PASSWORD-NOT-REAL"`},
|
||||
{"赋值式 access_token", `access_token="FAKE-TEST-TOKEN-NOT-REAL-00"`},
|
||||
{"赋值式 client_secret", `client_secret = "FAKE-TEST-TOKEN-NOT-REAL-00"`},
|
||||
{"单引号", `api_key = 'FAKE-TEST-ASSIGNED-VALUE-000'`},
|
||||
{"无引号", `api_key=FAKE-TEST-ASSIGNED-VALUE-000`},
|
||||
}
|
||||
t.Log("=== 会被拦截(→ 换成 @{sec_xxx})===")
|
||||
for _, c := range caught {
|
||||
out, evts := s.Replace(c.text, "survey", Patterns)
|
||||
if len(evts) == 0 {
|
||||
t.Errorf(" ✗ %-22s 未拦截: %q", c.name, c.text)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(out, c.text) {
|
||||
t.Errorf(" ✗ %-22s 拦了但原文还在", c.name)
|
||||
continue
|
||||
}
|
||||
t.Logf(" ✓ %-22s → %s", c.name, evts[0].Alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurvey_WhatIsNotCaught(t *testing.T) {
|
||||
s := newTestStore(t, true)
|
||||
|
||||
// ★ 这些**刻意不拦**,每条都有理由。列出来是为了让人知道边界在哪。
|
||||
notCaught := []struct{ name, text, why string }{
|
||||
{"40位 hex(Gitea token 形态)", "0123456789abcdef0123456789abcdef01234567",
|
||||
"与 git commit SHA 形态完全相同 —— 拦它会误伤每个 commit id"},
|
||||
{"裸 32 位十六进制", "a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
"与 UUID/会话 id/哈希撞车,太泛"},
|
||||
{"JWT", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop",
|
||||
"形态宽泛,误伤风险高于收益(且通常带 Bearer 前缀,那条会拦)"},
|
||||
{"密码短语", "correct horse battery staple correct",
|
||||
"无固定形态,无法与正常文本区分"},
|
||||
{"PIN/短口令", "884213", "太短,任何数字都会撞"},
|
||||
{"手机号/身份证", "13800138000", "PII 不是本模块目标(且误伤面极大)"},
|
||||
{"普通 URL 里的密码", "https://user:pass@host/path",
|
||||
"需要专门规则;当前未覆盖(已知缺口)"},
|
||||
{"SSH 私钥**文件路径**", "/home/u/.ssh/id_rsa",
|
||||
"拦路径不拦内容 —— 文件内容被读出时会命中私钥块规则"},
|
||||
{"环境变量**名字**", "${GITEA_TOKEN}", "名字不是秘密,值是"},
|
||||
{"中文口令", "我的密码是甲乙丙丁戊己庚辛",
|
||||
"无固定字符集,正则无法可靠识别"},
|
||||
}
|
||||
t.Log("=== 刻意不拦(每条都有理由)===")
|
||||
for _, c := range notCaught {
|
||||
out, evts := s.Replace(c.text, "survey", Patterns)
|
||||
if len(evts) > 0 {
|
||||
t.Logf(" ! %-26s 实际被拦了({P}): %s", c.name, c.why)
|
||||
continue
|
||||
}
|
||||
if out != c.text {
|
||||
t.Errorf(" ✗ %-26s 未拦却改动了文本", c.name)
|
||||
continue
|
||||
}
|
||||
t.Logf(" - %-26s 原因: %s", c.name, c.why)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user