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 测试全绿
## 数据风险
加密实现切换有丢数据风险,因此:
· 先写往返判据,再改实现
· 惰性迁移(读到才升级),不做启动全量重写
· 旧格式与旧密钥环**都保留**读取路径
422 lines
13 KiB
Go
422 lines
13 KiB
Go
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)
|
||
}
|
||
}
|