Files
HomeAgent/internal/config/secret_test.go
JianFeeeee 4e70b8d5d8 refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 1,不含 SDK 仓与目录改名。

- go.mod 第 1 行 module 路径
- 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换
  (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5)
- 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、
  homed/waiter 可执行名、.hmap 后缀
2026-10-21 12:17:19 +08:00

417 lines
12 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"
)
func testCodec(t *testing.T) {
t.Helper()
if _, err := security.InitConfigCodec([]byte("0123456789abcdef0123456789abcdef")); err != nil {
t.Fatal(err)
}
}
func newSecReg(t *testing.T) *ConfigRegistry {
t.Helper()
dir := t.TempDir()
testCodec(t)
return NewConfigRegistry(filepath.Join(dir, "config.db"))
}
// 直接在 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)
}
// 换一把主密钥
if _, err := security.InitConfigCodec([]byte("ffffffffffffffffffffffffffffffff")); err != nil {
t.Fatal(err)
}
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
security.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()
security.ClearConfigCodec()
defer testCodec(t) // 复原,避免影响其它判据
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)
}
}