Files
HomeAgent/internal/security/singleton_test.go
JianFeeeee 5bdd0b5107 refactor(security): Store 复用 Codec 加密能力 + 删 globalCodec 单例
## 用户裁定

> store也应该调用security的加密能力!

## 改动前的事实(实测)

两套**完全重复**的 AES-256-GCM:

    Store.seal/open     store.go:372,388    裸 base64
    Codec.Seal/Open     codec.go:113,139    enc:v1: 前缀 + 域隔离 + 幂等

算法、nonce 布局(前置 + io.ReadFull(rand))、密钥环轮换逻辑全都一样,
只有 Codec 多了前缀与域隔离。

危害不是"代码重复"这么轻:

  · 两套实现各自演化,任何一侧的改动不会被另一侧的判据发现
  · Store 缺域隔离 ⇒ 变量库与配置共用密钥(安全倒退)
  · Store 缺前缀 ⇒ 无法区分"旧密文"与"明文"(惰性迁移的障碍)

## 本次做的

### Store 委托 Codec

    codecOf()            按当前 keys 环惰性构建(轮换后自动跟上)
    sealWithRing()       新格式(enc:v1: 前缀)
    openWithStoreRing()  兼容新旧两种格式,返回 needsMigrate

删除 Store 自带的 seal/open/openWithRing —— 现在**只有一处** AES-GCM。

### 域隔离(安全判据)

    storeCodecDomain  = "security/variables"
    configCodecDomain = "config-at-rest"

两者必须不同:同域时一处明文泄漏可推导另一处密文。

### 存量密文惰性迁移

旧格式是裸 base64 无前缀,而 Codec.Open 对无前缀输入**原样返回明文**
(那是给 config 的存量明文兼容)。直接交给 Open 会把密文当明文返回,
症状是"凭据回填出一个 base64 串"。

所以 Store 走 openWithStoreRing:

    enc:v1:... → Codec.Open
    裸 base64  → Codec.OpenLegacy(解开后立刻回写新格式)

不做启动时全量迁移(21G 数据目录的教训:迁移窗口越长越危险),
也不让两种格式永久共存(判据永不能要求"落盘都是新格式")。

### 删 globalCodec 单例 → 归 Runtime

    security 模块 → rt.SetCodec(c) → ks.Security
    config 模块   → cfgReg.SetCodec(ks.Security.Codec())

ConfigRegistry 加 codec 字段(注入而非读全局),6 处 ConfigCodec() 改为
r.codecOf();PluginSettings 经 p.registry 转调。
sdk.SecretsAPI 也加 codec 参数(插件加密能力来自它的内核实例)。

### 顺带

  · InitConfigCodec* / ClearConfigCodec 删除,改 NewConfigCodec 返回实例
  · homed-key-rotate 改为 构造 + reg.SetCodec(不再装全局)
  · bootstrap_order_test 的锚点从 InitConfigCodecWithOldKeys 改为
    NewConfigCodec(守的不变量不变,只是生效点写法变了)

## 判据(均经变异验证)

  TestNoPackageLevelSingletons          禁表补入 globalCodec
  TestStoreWritesPrefixedCiphertext     新密文必须带 enc:v1:(旧实现裸 base64 ⇒ 红)
  TestStoreAndConfigUseDifferentDomains 域隔离;改成同域 ⇒ 红(实测确认)
  TestLegacyCiphertextStillReadableAndMigrates  旧格式可读 + 落盘已升级
  TestRotationKeepsOldCiphertextReadable        轮换后旧条目仍可回填

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + security/config 测试全绿

## 数据风险

加密实现切换有丢数据风险,因此:
  · 先写往返判据,再改实现
  · 惰性迁移(读到才升级),不做启动全量重写
  · 旧格式与旧密钥环**都保留**读取路径
2026-11-04 12:22:08 +08:00

196 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package security
// 判据:**不存在包级单例**(2026-11-04 T3.1)。
//
// ## 这些判据守的是什么
//
// 用户裁定:
//
// > secutiry 的实现也是错误的,所有共享数据统一挂载一个 kernel 提供的
// > 挂载点上暴露。
//
// > store也应该调用security的加密能力!
//
// 本包原有**三份**独立包级单例:
//
// global *Store 变量库(凭据别名)
// runtime *Runtime 安全防火墙
// globalCodec *Codec 配置落盘加密
//
// 它们各自被 InitXxx / SetXxx 装入,各自有 Global() / RuntimeGlobal() /
// ConfigCodec() 访问器。三份都绕开了内核的统一挂载点 `kstate.State`。
//
// ## 为什么单例是错的(不是"多一个全局变量"这么轻)
//
// 本 agent 的 `a.security` 与 `security.Global()` 是**两个独立变量**。
// 正常装配下它们恰好指向同一份数据,但"恰好"不是保证:
//
// - 测试替身:测试装单例、被测代码用 agent 字段 ⇒ 测的是单例
// - 多 Runtime:两个内核实例(或热重载)时单例只有一份,另一方被覆盖
// - 装配顺序:单例装晚了,先注册的 provider 就没被装饰(静默漏一条出网路径)
//
// 最后一条**曾真实存在**:ProviderManager.Register 在注册时读单例套装饰器,
// 而 llm 模块原本不声明 FacSecurity 依赖 —— 顺序正确纯属巧合。
//
// ## 判据为什么是源码扫描
//
// 这是"不存在某物"的判据,没有可执行的行为可测。但**变异可验证**:
// 把 `var global *Store` 加回来 ⇒ 立刻红。
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// forbiddenPackageLevelVars 是**不得出现**的包级单例变量名。
//
// 每一条都对应一次真实事故或一次可复现的错配:
//
// global → 变量库:测试装单例、代码用 agent 字段,测的与跑的不是同一份
// runtime → 防火墙:多实例时被覆盖
// globalCodec → 配置加密:域隔离失效的隐患(T3.2 已删)
//
// ★ 三条全部落地后才纳入本表。
//
// - 半成品状态就纳入 ⇒ 判据长期红 ⇒ 被人习惯性忽略 ⇒ 等于不存在
// - 全部落定后仍不纳入 ⇒ 后来者能原样加回来
func forbiddenPackageLevelVars() []string {
return []string{"global", "runtime", "globalCodec"}
}
// TestNoPackageLevelSingletons 钉住本包不得声明这三个包级变量。
//
// 变红条件:把 `var global *Store`(或另外两个)加回来 ⇒ 红。
func TestNoPackageLevelSingletons(t *testing.T) {
fset := token.NewFileSet()
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("读目录失败: %v", err)
}
banned := make(map[string]bool, len(forbiddenPackageLevelVars()))
for _, n := range forbiddenPackageLevelVars() {
banned[n] = true
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0)
if err != nil {
t.Fatalf("解析 %s 失败: %v", name, err)
}
for _, decl := range f.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for _, ident := range vs.Names {
if banned[ident.Name] {
t.Fatalf("★ %s:%d 声明了包级单例 %q。\n\n"+
"本包的单例已按用户裁定删除 —— 共享数据统一挂在\n"+
"kstate.State 上,各消费方从自己的注入面取:\n\n"+
"\tAgentConfig.Security → a.security.Store()\n"+
"\tProviderManager.SetSecretStore\n"+
"\tplugin.Registry.SetSecretStore\n\n"+
"单例的问题不是'多一个全局变量',而是它与 a.security\n"+
"是两个独立变量,正常装配下'恰好'同一份,但测试替身/\n"+
"多 Runtime/装配顺序任一路径都可能分离。",
name, fset.Position(ident.Pos()).Line, ident.Name)
}
}
}
}
}
}
// TestMustStoreDoesNotRegisterGlobally 钉住 MustStore 只是构造+加载。
//
// 变异可验证:在 MustStore 里加一行 `global = s` ⇒ 上面的判据红
// (因为 global 变量本身不存在,得先加变量)。
//
// 本判据补的是另一半:**即使变量名换了**(比如叫 defaultStore),
// MustStore 也不得把结果存进任何包级位置。
func TestMustStoreDoesNotRegisterGlobally(t *testing.T) {
dir := t.TempDir()
s1, err := MustStore(Options{
Path: filepath.Join(dir, "a.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatalf("MustStore: %v", err)
}
// 第二次调用必须得到**另一个**实例 —— 若内部有包级缓存,
// 第二次会返回同一个(那就是单例还活着)。
s2, err := MustStore(Options{
Path: filepath.Join(dir, "b.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatalf("MustStore: %v", err)
}
if s1 == s2 {
t.Fatal("★ MustStore 两次返回同一实例 —— 说明它偷偷存了包级单例。\n" +
"它应当只做 New + Load,把实例交还调用方注入。")
}
}
// TestStoreEncryptsWithOwnKey 钉住变量库确实是密文落盘(加密能力归属本模块)。
//
// 这条是 T3.2(Store 复用 Codec)的前置事实:现在 Store 有自己的 seal/open。
// T3.2 之后它会改为委托 Codec,本判据仍然成立(落盘必须是密文)。
func TestStoreEncryptsWithOwnKey(t *testing.T) {
dir := t.TempDir()
const key = "0123456789abcdef0123456789abcdef"
path := filepath.Join(dir, "v.json")
s, err := MustStore(Options{
Path: path,
KeyFn: func() ([]byte, error) { return []byte(key), nil },
Enabled: true,
})
if err != nil {
t.Fatalf("MustStore: %v", err)
}
const plain = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
if _, err := s.Submit(plain, "test"); err != nil {
t.Fatalf("Submit: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读落盘文件: %v", err)
}
if strings.Contains(string(raw), plain) {
t.Fatal("★ 变量库落盘里有明文 —— 加密没有生效")
}
// 重新加载必须能解回来(密钥一致)。
s2, err := MustStore(Options{
Path: path,
KeyFn: func() ([]byte, error) { return []byte(key), nil },
Enabled: true,
})
if err != nil {
t.Fatalf("重新加载失败: %v", err)
}
if s2.Count() != 1 {
t.Fatalf("重新加载后应有 1 条,实际 %d", s2.Count())
}
}