mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 用户裁定
> 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 测试全绿
## 数据风险
加密实现切换有丢数据风险,因此:
· 先写往返判据,再改实现
· 惰性迁移(读到才升级),不做启动全量重写
· 旧格式与旧密钥环**都保留**读取路径
214 lines
6.5 KiB
Go
214 lines
6.5 KiB
Go
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{}
|
||
}
|