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:
JianFeeeee
2026-10-17 19:18:24 +08:00
committed by GitHub
parent 03d87a4e21
commit a68d6a428c
266 changed files with 30185 additions and 1424 deletions

View 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)
}

View 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
View 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()
}

View 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")
}
}

View 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()
}

View 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
View 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
View 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
View 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))
}

View 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 的值")
}
}

View 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
}

View 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) }

View 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)
}
}

View 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)
}
}

View 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)
}

View 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("✅ 公钥不被拦(正确的边界)")
}

View 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
View 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) }

View 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])
}

View 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()
}

View 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)
}
}