mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
批 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 后缀
417 lines
12 KiB
Go
417 lines
12 KiB
Go
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)
|
||
}
|
||
}
|