Files
HomeAgent/internal/plugin/proc/e2e_secrets_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

214 lines
6.5 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 proc
// 端到端:插件的**变量库**能力(SDK `Secrets()`)。
//
// ## 这条链路为什么要真机验证
//
// 它与 settings 的 setSecret 是**同一个坑的翻版**:新增 proc method 要改
// **4 个地方**(协议常量 / 外层路由 / 内层分发 / 能力表),少一处就报
// "未知 method"。单测只能证明各段对,证不了接上了。
//
// 而且它跨了**三层**:
//
// ① 插件调 sdk.Secrets().Store
// ② 模板 procSecrets → RPC "secrets.store"
// ③ 内核 coreHandler.secrets → sdk.SecretsAPI(secrets_impl)
// ④ → internal/security.Global() 单例(真加密落盘)
//
// ## 为什么要有这个 API(去重的意义)
//
// 在此之前 commondev 插件自带一套 AES-256-GCM 存储,与内核完全重复。
// 两套库**互不可见**是这个设计最有害的地方 —— 现在统一到一个单例。
import (
"os"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
internalSDK "github.com/JianFeeeee/HologramAgent/internal/sdk"
internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// secretsE2EPlugin 用真实模板编译:只做变量库往返。
const secretsE2EPlugin = `package main
import (
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type secPlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &secPlugin{name: name}, nil
}
func (p *secPlugin) Name() string { return p.name }
func (p *secPlugin) Stop() error { return nil }
func (p *secPlugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool("e2e_secrets", sdk.ToolDef{
Description: "变量库往返",
Parameters: map[string]interface{}{"type": "object"},
}, func(args map[string]interface{}) (interface{}, error) {
sec := s.Secrets()
if sec == nil || !sec.Available() {
return "UNAVAILABLE", nil
}
alias, err := sec.Store("e2e-secret-value-9876543210", "e2e")
if err != nil {
return "STORE_ERR: " + err.Error(), nil
}
// ★ 只应拿到别名,拿不到明文
if alias == "" {
return "EMPTY_ALIAS", nil
}
out, n, _ := sec.Fill("token=@{" + alias + "}")
if n != 1 {
return "FILL_MISS", nil
}
return out, nil
})
return nil
}
`
// ★★ 判据:真实模板插件的变量库往返全链路可用。
func TestE2E_RealTemplateSecretsRoundTrip(t *testing.T) {
bin := buildPluginWithRealTemplate(t, secretsE2EPlugin)
dir := t.TempDir()
// 变量库(2026-11-04:不再有进程级单例,改为显式持有并注入)
store, err := internalSecurity.MustStore(internalSecurity.Options{
Path: filepath.Join(dir, "variables.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
_ = internalConfig.NewConfigRegistry
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
core.secretsAPI = internalSDK.NewSecretsAPI(store, nil)
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
core.mu.Lock()
handler, ok := core.tools["e2e_secrets"]
core.mu.Unlock()
if !ok {
t.Fatal("插件未注册 e2e_secrets 工具")
}
res, err := handler(map[string]interface{}{})
if err != nil {
t.Fatalf("调用失败: %v", err)
}
got, _ := res.(string)
if strings.HasPrefix(got, "UNAVAILABLE") || strings.HasPrefix(got, "STORE_ERR") ||
strings.HasPrefix(got, "EMPTY_ALIAS") || strings.HasPrefix(got, "FILL_MISS") {
t.Fatalf("变量库往返失败: %s", got)
}
// Fill 应把别名换回明文
if !strings.Contains(got, "e2e-secret-value-9876543210") {
t.Fatalf("Fill 未换回明文: %q", got)
}
// 变量库里应真的登记了
if store.Count() != 1 {
t.Fatalf("变量库应有 1 条,实际 %d", store.Count())
}
t.Logf("✅ 插件经内核变量库完成往返: %s", got)
}
// ★★ 判据:插件写入的是**内核那一个**变量库(不是自己又建了一套)。
//
// 这是去重的核心断言:插件 Store 之后,内核侧能立刻看到。
func TestE2E_PluginAndKernelShareOneVault(t *testing.T) {
bin := buildPluginWithRealTemplate(t, secretsE2EPlugin)
dir := t.TempDir()
st, err := internalSecurity.MustStore(internalSecurity.Options{
Path: filepath.Join(dir, "variables.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
host, _ := NewHost()
defer host.Close()
core := newFakeCore()
core.secretsAPI = internalSDK.NewSecretsAPI(st, nil)
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatal(err)
}
defer p.Close()
core.mu.Lock()
handler := core.tools["e2e_secrets"]
core.mu.Unlock()
if _, err := handler(map[string]interface{}{}); err != nil {
t.Fatal(err)
}
// ★ 内核侧(同一个单例)应看到插件登记的那条
infos := st.List()
if len(infos) != 1 {
t.Fatalf("内核单例应有 1 条(插件写的),实际 %d —— 说明插件自建了一套库", len(infos))
}
if infos[0].Note != "e2e" {
t.Errorf("备注未透传: %q", infos[0].Note)
}
// 内核的 Fill 也能用它
if out, n, _ := st.Fill("x=@{" + infos[0].Alias + "}"); n != 1 ||
!strings.Contains(out, "e2e-secret-value") {
t.Errorf("内核 Fill 用不了插件登记的变量: n=%d out=%q", n, out)
}
t.Logf("✅ 插件与内核共用同一个变量库(别名 %s)", infos[0].Alias)
}
// ★ 判据:安全模块未启用时 Available()==false(而非 nil panic)。
func TestE2E_SecretsUnavailableWhenDisabled(t *testing.T) {
bin := buildPluginWithRealTemplate(t, secretsE2EPlugin)
dir := t.TempDir()
host, _ := NewHost()
defer host.Close()
core := newFakeCore()
// 未启用安全模块 ⇒ 传 nil(Available() 必须为 false,且不得 panic)
core.secretsAPI = internalSDK.NewSecretsAPI(nil, nil)
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatal(err)
}
defer p.Close()
core.mu.Lock()
handler := core.tools["e2e_secrets"]
core.mu.Unlock()
res, err := handler(map[string]interface{}{})
if err != nil {
t.Fatalf("不该报错(应优雅报告不可用): %v", err)
}
if got, _ := res.(string); got != "UNAVAILABLE" {
t.Fatalf("未启用时应报 UNAVAILABLE,实际 %q", got)
}
_ = os.Remove
_ = pubsdk.SecretInfo{}
}