Files
HomeAgent/internal/sdk/secrets_impl.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

128 lines
4.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 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)