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 测试全绿
## 数据风险
加密实现切换有丢数据风险,因此:
· 先写往返判据,再改实现
· 惰性迁移(读到才升级),不做启动全量重写
· 旧格式与旧密钥环**都保留**读取路径
128 lines
4.5 KiB
Go
128 lines
4.5 KiB
Go
package sdk
|
||
|
||
// SecretsAPI 的内核实现:桥接到注入的变量库(`*security.Store`)。
|
||
//
|
||
// ## 为何是"桥接"而不是"另建一套"
|
||
//
|
||
// 变量库是**全进程唯一的一份**:主/子/驻留子 agent 共享同一套别名表
|
||
// (设计 S10)。插件若自建一套,两库互不可见 —— 内核拦截时登记的变量
|
||
// 插件看不到,插件登记的拦截又不认识,同一个凭据可能两处各存一份而
|
||
// 使用者不知哪个权威。
|
||
//
|
||
// 所以这个实现只是把调用转发给注入的 Store。**没有第二份存储**。
|
||
//
|
||
// ## 注入而不是读单例(2026-11-04)
|
||
//
|
||
// 此前读 `security.Global()` 包级单例。那个单例绕开了内核的统一挂载点
|
||
// (kstate.State.Security),已删除。现在由 plugin.Registry 从
|
||
// ks.Security 取出 Store 传进来 —— 单一事实来源、单一注入路径。
|
||
//
|
||
// ## 未启用时的行为
|
||
//
|
||
// 注入的 Store 为 nil 时**不返回 nil API**,而是返回一个 Available()
|
||
// 为 false 的实现。理由:nil 会让插件到处判空,而判空漏一处就 panic
|
||
// (本仓在 `*SensitiveInterceptor` 上吃过这个亏)。给一个明确的
|
||
// "不可用" 实现,插件只需问 `Available()`。
|
||
|
||
import (
|
||
"fmt"
|
||
|
||
internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security"
|
||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||
)
|
||
|
||
// secretsImpl 实现 pubsdk.SecretsAPI。
|
||
type secretsImpl struct {
|
||
store *internalSecurity.Store
|
||
// codec 是配置落盘加密器(插件经 s.Secrets().Encrypt/Decrypt 使用)。
|
||
//
|
||
// ★ 2026-11-04(T3.2):此前读 security.ConfigCodec() 包级单例。
|
||
// 与 store 同理 —— 本插件的加密能力应当来自**它的内核实例**,
|
||
// 而不是"最后谁调了 InitConfigCodec"。
|
||
codec *internalSecurity.Codec
|
||
}
|
||
|
||
// NewSecretsAPI 构造变量库接口(内核装配时注入给每个插件)。
|
||
func NewSecretsAPI(store *internalSecurity.Store, codec *internalSecurity.Codec) pubsdk.SecretsAPI {
|
||
return &secretsImpl{store: store, codec: codec}
|
||
}
|
||
|
||
func (s *secretsImpl) Available() bool { return s.store != nil }
|
||
|
||
// AvailableForRPC 是 proc 桥用来**明确报告**可用性的入口。
|
||
//
|
||
// ★ 为什么不能靠 List 推断:未启用时 List 返回空列表(**不报错**),
|
||
// 于是"库是空的"与"库不存在"在 RPC 层长得一样 —— 实测踩到:
|
||
// 插件的 Available() 误判为 true,接着 Store 才失败,错误信息变成
|
||
// "存储失败"而不是"能力不可用",排查时方向完全错。
|
||
func (s *secretsImpl) AvailableForRPC() bool { return s.Available() }
|
||
|
||
func (s *secretsImpl) Store(value, note string) (string, error) {
|
||
st := s.store
|
||
if st == nil {
|
||
// ★ 响亮失败:不静默降级成"存到别处"或"不存"。
|
||
// 插件以为存好了而实际没存,比直接报错危险得多。
|
||
return "", fmt.Errorf("安全模块未启用(core.security.enabled=false),无法登记凭据")
|
||
}
|
||
return st.Submit(value, note)
|
||
}
|
||
|
||
func (s *secretsImpl) List() []pubsdk.SecretInfo {
|
||
st := s.store
|
||
if st == nil {
|
||
return nil
|
||
}
|
||
infos := st.List()
|
||
out := make([]pubsdk.SecretInfo, len(infos))
|
||
for i, in := range infos {
|
||
out[i] = pubsdk.SecretInfo{
|
||
Alias: in.Alias, Source: in.Source, Note: in.Note,
|
||
CreatedAt: in.CreatedAt, UpdatedAt: in.UpdatedAt, Hits: in.Hits,
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (s *secretsImpl) Forget(alias string) error {
|
||
st := s.store
|
||
if st == nil {
|
||
return fmt.Errorf("安全模块未启用,变量库不可用")
|
||
}
|
||
return st.Forget(alias)
|
||
}
|
||
|
||
func (s *secretsImpl) Fill(text string) (string, int, []string) {
|
||
st := s.store
|
||
if st == nil {
|
||
return text, 0, nil
|
||
}
|
||
return st.Fill(text)
|
||
}
|
||
|
||
func (s *secretsImpl) Encrypt(plain []byte) (string, error) {
|
||
c := s.codec
|
||
if c == nil || !c.Enabled() {
|
||
// ★ 拒绝明文落盘:插件以为存的是密文而实际是明文,是最危险的静默降级。
|
||
return "", fmt.Errorf("内核未装配加密能力,拒绝以明文存储(core.security.enabled=false?)")
|
||
}
|
||
return c.Seal(string(plain))
|
||
}
|
||
|
||
func (s *secretsImpl) Decrypt(stored string) ([]byte, error) {
|
||
// 非密文原样返回(兼容插件已有的明文文件,避免升级即坏)
|
||
if !internalSecurity.IsEncrypted(stored) {
|
||
return []byte(stored), nil
|
||
}
|
||
c := s.codec
|
||
if c == nil || !c.Enabled() {
|
||
return nil, fmt.Errorf("有密文但内核未装配加密能力(密钥换了?)")
|
||
}
|
||
plain, err := c.Open(stored)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return []byte(plain), nil
|
||
}
|
||
|
||
var _ pubsdk.SecretsAPI = (*secretsImpl)(nil)
|