Files
HomeAgent/internal/config/secret_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

422 lines
13 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 config
// 配置落盘加密的判据。
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// testCodec 造一把固定主密钥的 codec(不再装包级单例)。
func testCodec(t *testing.T) *security.Codec {
t.Helper()
c, err := security.NewConfigCodec([]byte("0123456789abcdef0123456789abcdef"), nil)
if err != nil {
t.Fatal(err)
}
return c
}
func newSecReg(t *testing.T) *ConfigRegistry {
t.Helper()
dir := t.TempDir()
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
// 按生产路径注入(config 模块从 ks.Security.Codec() 取)。
r.SetCodec(testCodec(t))
return r
}
// 直接在 sqlite 层面读原始值(绕过 ConfigRegistry 的解密)。
func rawValue(t *testing.T, r *ConfigRegistry, key string) string {
t.Helper()
var v string
if err := r.db.QueryRow(`SELECT value FROM config WHERE key = ?`, key).Scan(&v); err != nil {
t.Fatalf("读原始值失败: %v", err)
}
return v
}
// ★★ 判据:敏感配置**落盘是密文**,而读接口返回明文。
func TestSecretEncryptedOnDiskPlainOnRead(t *testing.T) {
r := newSecReg(t)
const secret = "sk-super-secret-value-1234567890"
if err := r.Set("core.llm.api_key", secret); err != nil {
t.Fatal(err)
}
// ① 磁盘上是密文
raw := rawValue(t, r, "core.llm.api_key")
if raw == secret {
t.Fatal("敏感项在磁盘上是明文")
}
if !security.IsEncrypted(raw) {
t.Fatalf("应有 enc:v1: 前缀,实际 %q", raw)
}
// ② 读接口返回明文(内部可信路径语义不变)
got, err := r.Get("core.llm.api_key")
if err != nil {
t.Fatal(err)
}
if got != secret {
t.Fatalf("Get 应返回明文,实际 %v", got)
}
// ③ GetString / Dump 同样给明文
if r.GetString("core.llm.api_key", "") != secret {
t.Error("GetString 应返回明文")
}
if r.Dump()["core.llm.api_key"] != secret {
t.Error("Dump 应返回明文(ToConfig 依赖它)")
}
}
// ★★ 判据:**非敏感项不受影响**(不能被误加密)。
func TestNonSecretStaysPlain(t *testing.T) {
r := newSecReg(t)
if err := r.Set("core.llm.base_url", "https://api.deepseek.com"); err != nil {
t.Fatal(err)
}
raw := rawValue(t, r, "core.llm.base_url")
if raw != "https://api.deepseek.com" {
t.Fatalf("非敏感项不应被加密: %q", raw)
}
}
// ★ 判据:`Secret: true` 与 `Type: "password"` 两种声明都生效。
func TestBothDeclarationFormsWork(t *testing.T) {
r := newSecReg(t)
r.RegisterDef(ConfigDef{Key: "core.a.token_field", Default: "", Secret: true})
r.RegisterDef(ConfigDef{Key: "core.b.password_field", Default: "", Type: "password"})
for _, k := range []string{"core.a.token_field", "core.b.password_field"} {
if err := r.Set(k, "value-1234567890"); err != nil {
t.Fatal(err)
}
if !security.IsEncrypted(rawValue(t, r, k)) {
t.Errorf("%s 未被加密(Secret 或 password 声明未生效)", k)
}
}
}
// ★★ 判据:**插件配置同样加密**(插件无感知)。
func TestPluginSecretEncrypted(t *testing.T) {
r := newSecReg(t)
ps := r.PluginConfig("demo")
ps.RegisterDef(ConfigDef{Key: "token", Default: "", Secret: true})
const secret = "plugin-token-abcdef123456"
if err := ps.Set("token", secret); err != nil {
t.Fatal(err)
}
// 磁盘(插件表)上是密文
var raw string
if err := r.db.QueryRow(`SELECT value FROM config_demo WHERE key = 'token'`).Scan(&raw); err != nil {
t.Fatal(err)
}
if !security.IsEncrypted(raw) {
t.Fatalf("插件敏感项应加密,实际 %q", raw)
}
// 插件读回明文
got, err := ps.Get("token")
if err != nil {
t.Fatal(err)
}
if got != secret {
t.Fatalf("插件应读回明文,实际 %v", got)
}
}
// ★ 判据:无 def 的插件键靠**键名模式**兜底加密。
func TestPluginSecretByKeyPattern(t *testing.T) {
r := newSecReg(t)
ps := r.PluginConfig("demo2")
// 先注册一个**非敏感** def:插件表只在 RegisterDef 时创建,
// 但 api_key 这个键本身没有 def —— 正好检验键名模式兜底。
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "true"})
if err := ps.Set("api_key", "abcdefgh12345678"); err != nil {
t.Fatal(err)
}
var raw string
r.db.QueryRow(`SELECT value FROM config_demo2 WHERE key = 'api_key'`).Scan(&raw)
if !security.IsEncrypted(raw) {
t.Fatalf("无 def 时 api_key 键名应触发加密,实际 %q", raw)
}
}
// ★★ 判据:存量明文在启动迁移后被加密(幂等)。
func TestMigrateExistingSecrets(t *testing.T) {
r := newSecReg(t)
// 模拟升级前的明文存量(直接写库,绕过 Set)
r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`,
"core.llm.api_key", "legacy-plaintext-key-123")
r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`,
"webui.listen_addr", ":8080")
n, err := r.EncryptExistingSecrets()
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("应加密 1 条,实际 %d", n)
}
if !security.IsEncrypted(rawValue(t, r, "core.llm.api_key")) {
t.Error("存量敏感项未被加密")
}
// 非敏感项不动
if rawValue(t, r, "webui.listen_addr") != ":8080" {
t.Error("非敏感项被误改")
}
// 迁移后仍可读明文
if v, _ := r.Get("core.llm.api_key"); v != "legacy-plaintext-key-123" {
t.Errorf("迁移后应可读明文,实际 %v", v)
}
// 幂等
if n2, _ := r.EncryptExistingSecrets(); n2 != 0 {
t.Errorf("迁移应幂等,第二次却加密了 %d 条", n2)
}
}
// ★★ 判据:**存量明文不需要迁移也能读**(老数据向后兼容)。
func TestLegacyPlaintextReadableWithoutMigration(t *testing.T) {
r := newSecReg(t)
r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`,
"core.llm.api_key", "still-plain")
v, err := r.Get("core.llm.api_key")
if err != nil {
t.Fatal(err)
}
if v != "still-plain" {
t.Fatalf("明文存量应可读,实际 %v", v)
}
}
// ★★ 判据:换密钥后**响亮失败**(不静默返回密文)。
func TestWrongKeyFailsLoudly(t *testing.T) {
r := newSecReg(t)
if err := r.Set("core.llm.api_key", "secret-value-1234567890"); err != nil {
t.Fatal(err)
}
// 换一把主密钥(同一 Registry 上重新注入,模拟轮换后配置错配)
other, err := security.NewConfigCodec([]byte("ffffffffffffffffffffffffffffffff"), nil)
if err != nil {
t.Fatal(err)
}
r.SetCodec(other)
if _, err := r.Get("core.llm.api_key"); err == nil {
t.Fatal("换密钥后必须报错,不能把密文当明文返回")
}
// Dump 也不得吐密文
if v := r.Dump()["core.llm.api_key"]; v != nil {
if s, ok := v.(string); ok && strings.HasPrefix(s, "enc:") {
t.Fatal("Dump 把密文交出去了")
}
}
}
// ★ 判据:未装配 codec 时退化为明文读写(本地开发/测试不阻塞)。
func TestNoCodecDegradesToPlain(t *testing.T) {
dir := t.TempDir()
// 不注入 codec = 未装配(原先是 ClearConfigCodec 清单例)
r := NewConfigRegistry(filepath.Join(dir, "c.db"))
if err := r.Set("core.llm.api_key", "plain-dev"); err != nil {
t.Fatal(err)
}
if rawValue(t, r, "core.llm.api_key") != "plain-dev" {
t.Error("未装配 codec 时应保持明文")
}
_ = os.Remove
}
// ★ 判据:域隔离 —— 配置子密钥与别名密钥不可互换。
func TestDomainSeparation(t *testing.T) {
master := []byte("0123456789abcdef0123456789abcdef")
cfgCodec, err := security.NewCodec(master, "config-at-rest")
if err != nil {
t.Fatal(err)
}
otherCodec, err := security.NewCodec(master, "some-other-domain")
if err != nil {
t.Fatal(err)
}
sealed, err := cfgCodec.Seal("hello")
if err != nil {
t.Fatal(err)
}
if _, err := otherCodec.Open(sealed); err == nil {
t.Fatal("不同域的子密钥不应能解开 —— 域隔离失效")
}
if got, _ := cfgCodec.Open(sealed); got != "hello" {
t.Fatal("同域应可解")
}
}
// ★★ 判据:**ToConfig 在加密开启后仍能取到真 api_key**。
//
// 这是最容易被加密改坏的一条:ToConfig 靠 Dump() 重建 LLMSource。
// 若 Dump 改返回掩码/密文,LLM 就会拿着垃圾去调上游 —— 而且
// 失败现象是「认证失败」,与配置加密毫无关联,归因极难。
func TestToConfigStillGetsRealAPIKey(t *testing.T) {
r := newSecReg(t)
const key = "sk-real-key-for-upstream-123456"
if err := r.Set("core.llm.api_key", key); err != nil {
t.Fatal(err)
}
if err := r.Set("core.llm.sources.probe.base_url", "https://example.com"); err != nil {
t.Fatal(err)
}
if err := r.Set("core.llm.sources.probe.api_key", key); err != nil {
t.Fatal(err)
}
if err := r.Set("core.llm.sources.probe.model", "m1"); err != nil {
t.Fatal(err)
}
cfg := r.ToConfig()
if cfg.LLM.APIKey != key {
t.Fatalf("ToConfig 的 APIKey 不真:%q", cfg.LLM.APIKey)
}
var found bool
for _, s := range cfg.LLM.Sources {
if s.Name == "probe" {
found = true
if s.APIKey != key {
t.Fatalf("source api_key 不真:%q", s.APIKey)
}
}
}
if !found {
t.Fatal("source probe 丢失")
}
}
// ★★ 判据:**数量类键名不得被误判为敏感**。
//
// 这是真机跑出来的缺陷:初版用 strings.Contains(key,"token"),
// 把 core.llm.max_tokens(输出上限)与
// core.agent.context.max_target_tokens(上下文预算)也加密了。
// 功能上无害,但语义错误、且库变得无法人工检查。
func TestNonSecretTokenCountsNotEncrypted(t *testing.T) {
r := newSecReg(t)
benign := []string{
"core.llm.max_tokens",
"core.agent.context.max_target_tokens",
"core.compact.target_tokens",
"core.keyboard_layout", // 含 "key" 但不是凭据
"core.monkey_mode", // 同上
"webui.listen_addr",
"core.llm.temperature",
}
for _, k := range benign {
if r.isSecretKey(k) {
t.Errorf("%q 被误判为敏感(会被无谓加密)", k)
}
}
}
// ★★ 判据:真敏感键名必须命中(含无 def 的插件键)。
func TestSecretKeyNamesDetected(t *testing.T) {
r := newSecReg(t)
sensitive := []string{
"core.llm.api_key",
"core.llm.sources.x.access_token",
"plugin.qq.token",
"plugin.demo.password",
"plugin.a.client_secret",
"plugin.b.apikey",
"plugin.c.key",
"core.memory.multimodal_space.options.api_key",
}
for _, k := range sensitive {
if !r.isSecretKey(k) {
t.Errorf("%q 应被判定为敏感", k)
}
}
}
// ★★ 判据:显式加密 API(SetSecret/GetSecret)**不看键名**也加密。
//
// 存在意义:插件可能用中性键名(blob/payload/conn)存凭据,
// 键名模式猜不到 ⇒ 必须能显式声明意图。
func TestExplicitSetSecretIgnoresKeyName(t *testing.T) {
r := newSecReg(t)
ps := r.PluginConfig("demo3")
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "1"})
const neutral = "blob" // 中性键名,模式判定为**非敏感**
if r.isSecretKey("plugin.demo3." + neutral) {
t.Fatal("前提失效:blob 不该被模式判定为敏感")
}
const secret = "creds-payload-with-neutral-key-name"
if err := ps.SetSecret(neutral, secret); err != nil {
t.Fatal(err)
}
// 磁盘上是密文
var raw string
if err := r.db.QueryRow(`SELECT value FROM config_demo3 WHERE key = ?`, neutral).Scan(&raw); err != nil {
t.Fatal(err)
}
if !security.IsEncrypted(raw) {
t.Fatalf("SetSecret 应强制加密,实际 %q", raw)
}
// GetSecret 读回明文
got, err := ps.GetSecret(neutral)
if err != nil {
t.Fatal(err)
}
if got != secret {
t.Fatalf("GetSecret 应返回明文,实际 %q", got)
}
}
// ★★ 判据:**密钥未装配时 SetSecret 拒绝写入**(不静默存明文)。
//
// 这是最危险的静默降级:插件以为存了密文,实际磁盘上是明文。
func TestSetSecretRefusesWithoutKey(t *testing.T) {
dir := t.TempDir()
// 不注入 codec = 未装配(原先是 ClearConfigCodec 清单例)。
// 每个 Registry 自带 codec,不再需要"复原全局状态"。
r := NewConfigRegistry(filepath.Join(dir, "c.db"))
ps := r.PluginConfig("demo4")
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "1"})
err := ps.SetSecret("blob", "must-not-be-plain")
if err == nil {
t.Fatal("无密钥时 SetSecret 必须报错,不能静默写明文")
}
// 且确实没写进去
var cnt int
r.db.QueryRow(`SELECT COUNT(*) FROM config_demo4 WHERE key = 'blob'`).Scan(&cnt)
if cnt != 0 {
t.Error("拒绝写入后库里不应有该键")
}
// 空值是允许的(删除语义)
if err := ps.SetSecret("blob", ""); err != nil {
t.Errorf("空值应允许写入: %v", err)
}
}
// ★ 判据:SetSecret 写入的值能被普通 Get 读到(两种读法互通,不产生孤岛)。
func TestSetSecretReadableByGet(t *testing.T) {
r := newSecReg(t)
ps := r.PluginConfig("demo5")
ps.RegisterDef(ConfigDef{Key: "enabled", Default: "1"})
if err := ps.SetSecret("blob", "value-xyz-123456"); err != nil {
t.Fatal(err)
}
v, err := ps.Get("blob") // Get 会走 openFromStorage
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(v) != "value-xyz-123456" {
t.Fatalf("Get 应能读到 SetSecret 写的值,实际 %v", v)
}
}