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 测试全绿
## 数据风险
加密实现切换有丢数据风险,因此:
· 先写往返判据,再改实现
· 惰性迁移(读到才升级),不做启动全量重写
· 旧格式与旧密钥环**都保留**读取路径
142 lines
4.5 KiB
Go
142 lines
4.5 KiB
Go
package proc
|
||
|
||
// 端到端:插件的**加密存储**能力(settings.setSecret/getSecret)。
|
||
//
|
||
// ## 为何必须做真机端到端
|
||
//
|
||
// 这条链路上有 5 段,任何一段漏掉都表现为「插件拿不到值」:
|
||
//
|
||
// ① 插件调 sdk.Settings().SetSecret
|
||
// ② 模板 procSettings.SetSecret → RPC "settings.setSecret"
|
||
// ③ proc 协议常量 MethodSettingsSetSecret
|
||
// ④ coreHandler 分发 → settingsSetSecret(可选接口断言)
|
||
// ⑤ ConfigRegistry.PluginSettings.SetSecret → 真加密落盘
|
||
//
|
||
// 单测只能证明 ⑤ 对。②③④ 是新写的,必须让**真实模板编译出的插件**
|
||
// 真的跑一遍 —— 否则就是「模板 e2e 报了 missing method,而我只在
|
||
// 内核侧修好了接口」这类错位(本次实际踩到过一次)。
|
||
|
||
import (
|
||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
|
||
internalSDK "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||
)
|
||
|
||
// secretE2EPlugin 是一个**用真实模板**编译的插件:它只做两件事 ——
|
||
// 把密钥写进加密存储,然后读回来并通过返回值报告。
|
||
const secretE2EPlugin = `package main
|
||
|
||
import (
|
||
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||
)
|
||
|
||
type secretPlugin struct{ name string }
|
||
|
||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return &secretPlugin{name: name}, nil
|
||
}
|
||
|
||
func (p *secretPlugin) Name() string { return p.name }
|
||
|
||
func (p *secretPlugin) Stop() error { return nil }
|
||
|
||
func (p *secretPlugin) Start(s *sdk.PluginSDK) error {
|
||
s.RegisterTool("e2e_secret", sdk.ToolDef{
|
||
Description: "加密存储往返测试",
|
||
Parameters: map[string]interface{}{"type": "object"},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
sett := s.Settings()
|
||
if sett == nil {
|
||
return "NO_SETTINGS", nil
|
||
}
|
||
if err := sett.SetSecret("blob", "e2e-secret-value-9876543210"); err != nil {
|
||
return "SET_ERR: " + err.Error(), nil
|
||
}
|
||
got, err := sett.GetSecret("blob")
|
||
if err != nil {
|
||
return "GET_ERR: " + err.Error(), nil
|
||
}
|
||
return got, nil
|
||
})
|
||
return nil
|
||
}
|
||
`
|
||
|
||
// ★★ 判据:真实模板编译出的插件,其 SetSecret/GetSecret 全链路可用,
|
||
// 且**磁盘上是密文**、插件读回是明文。
|
||
func TestE2E_RealTemplateSecretRoundTrip(t *testing.T) {
|
||
bin := buildPluginWithRealTemplate(t, secretE2EPlugin)
|
||
|
||
// 用**真实** ConfigRegistry(真加密)+ 真主密钥
|
||
dir := t.TempDir()
|
||
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||
reg.SetCodec(setTestCodec(t))
|
||
defer reg.Close()
|
||
|
||
host, err := NewHost()
|
||
if err != nil {
|
||
t.Fatalf("NewHost: %v", err)
|
||
}
|
||
defer host.Close()
|
||
|
||
core := newFakeCore()
|
||
// 用**生产同款**适配器(sdk.NewSettings),而不是直接塞 PluginSettings ——
|
||
// 后者缺 DataDir 等成员,不是完整 SettingsAPI;用生产适配器才能
|
||
// 顺带验证「内核装配的那一层」也是通的。
|
||
core.settingsAPI = internalSDK.NewSettings("e2e", reg)
|
||
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_secret"]
|
||
core.mu.Unlock()
|
||
if !ok {
|
||
t.Fatal("插件未注册 e2e_secret 工具")
|
||
}
|
||
|
||
res, err := handler(map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatalf("工具调用失败: %v", err)
|
||
}
|
||
got, _ := res.(string)
|
||
if strings.HasPrefix(got, "SET_ERR") || strings.HasPrefix(got, "GET_ERR") {
|
||
t.Fatalf("加密存储往返失败: %s", got)
|
||
}
|
||
if got != "e2e-secret-value-9876543210" {
|
||
t.Fatalf("插件应读回明文,实际 %q", got)
|
||
}
|
||
|
||
// ★ 磁盘上必须是密文(这才是本特性的目的)
|
||
var raw string
|
||
if err := reg.DB().QueryRow(
|
||
`SELECT value FROM config_e2e WHERE key = 'blob'`).Scan(&raw); err != nil {
|
||
t.Fatalf("读原始值失败: %v", err)
|
||
}
|
||
if strings.Contains(raw, "e2e-secret-value") {
|
||
t.Fatalf("磁盘上出现明文(加密未生效): %q", raw)
|
||
}
|
||
if !strings.HasPrefix(raw, "enc:v1:") {
|
||
t.Fatalf("磁盘值应为 enc:v1: 密文,实际 %q", raw)
|
||
}
|
||
t.Logf("✅ 插件读回明文(%d 字节),磁盘存密文(%d 字节)", len(got), len(raw))
|
||
_ = os.Remove
|
||
_ = pubsdk.ConfigDef{}
|
||
}
|
||
|
||
// setTestCodec 造一把测试主密钥的 codec(由调用方注入 Registry)。
|
||
//
|
||
// ★ 2026-11-04:包级单例已删,不能"装一次全局就到处生效"。
|
||
func setTestCodec(t *testing.T) *security.Codec {
|
||
t.Helper()
|
||
return internalConfig.NewTestCodec(t)
|
||
}
|