From 5bdd0b5107930121907b4da95fd817eb24ec0222 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 12:22:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor(security):=20Store=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=20Codec=20=E5=8A=A0=E5=AF=86=E8=83=BD=E5=8A=9B=20+=20?= =?UTF-8?q?=E5=88=A0=20globalCodec=20=E5=8D=95=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 用户裁定 > 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 测试全绿 ## 数据风险 加密实现切换有丢数据风险,因此: · 先写往返判据,再改实现 · 惰性迁移(读到才升级),不做启动全量重写 · 旧格式与旧密钥环**都保留**读取路径 --- cmd/homed-key-rotate/main.go | 5 +- cmd/homed/bootstrap_order_test.go | 17 +- internal/config/backup_diag_test.go | 29 +-- internal/config/registry.go | 62 +++-- internal/config/secret.go | 31 ++- internal/config/secret_test.go | 27 ++- internal/kernelmod/security/mod.go | 8 +- internal/plugin/proc/e2e_secret_test.go | 11 +- internal/plugin/proc/e2e_secrets_test.go | 8 +- .../plugin/proc/e2e_security_declare_test.go | 1 - internal/plugin/registry.go | 10 +- internal/plugins/webui/secret_meta_test.go | 4 +- internal/sdk/secrets_impl.go | 18 +- internal/security/codec.go | 136 ++++++++--- internal/security/codec_unify_test.go | 216 ++++++++++++++++++ internal/security/runtime.go | 30 +++ internal/security/singleton_test.go | 15 +- internal/security/store.go | 195 +++++++++++++--- 18 files changed, 665 insertions(+), 158 deletions(-) create mode 100644 internal/security/codec_unify_test.go diff --git a/cmd/homed-key-rotate/main.go b/cmd/homed-key-rotate/main.go index fb1178f7..27d41d95 100644 --- a/cmd/homed-key-rotate/main.go +++ b/cmd/homed-key-rotate/main.go @@ -112,10 +112,13 @@ func main() { } else { fmt.Printf("③ 当前钥来自 %s:请先更新该注入源为新钥,再重启 homed\n", src) } - if _, err := security.InitConfigCodecWithOldKeys(newKey, append([][]byte{oldKey}, history...)); err != nil { + // ★ 2026-11-04:不再装全局 codec(单例已删),改为构造后注入 Registry。 + codec, err := security.NewConfigCodec(newKey, append([][]byte{oldKey}, history...)) + if err != nil { fail("装配新配置 codec 失败: %v", err) } reg := config.NewConfigRegistry(filepath.Join(*dataDir, "config.db")) + reg.SetCodec(codec) defer reg.Close() rekeyed, failed, err := reg.RekeySecrets() if err != nil { diff --git a/cmd/homed/bootstrap_order_test.go b/cmd/homed/bootstrap_order_test.go index dd093c91..58e6822e 100644 --- a/cmd/homed/bootstrap_order_test.go +++ b/cmd/homed/bootstrap_order_test.go @@ -100,15 +100,20 @@ func TestSecurityModuleInitializesBeforeConfigSnapshot(t *testing.T) { "若方法改名/搬走,请把锚点更新到新的真实调用点," + "**不要**删掉这条判据:它守的是「主密钥必须先于配置快照」") } - // 安全模块的**生效点**是 InitConfigCodecWithOldKeys(配置加密子密钥派生) - // ★ 2026-10-26:模块内的调用是 sec.InitConfigCodecWithOldKeys(...), - // 裸源码读取(非 AST)匹配不到包名,故按函数名匹配。 - secLine := lineOf("InitConfigCodecWithOldKeys(") + // 安全模块的**生效点**是配置加密子密钥的构造与注入。 + // + // ★ 2026-11-04(T3.2):锚点从 `InitConfigCodecWithOldKeys`(装包级单例) + // 改为 `NewConfigCodec`(构造后注入 cfgReg)。函数改名不是"判据放宽": + // 守的不变量仍是「主密钥必须先于配置快照」,只是生效点的写法变了。 + secLine := lineOf("NewConfigCodec(") + if secLine < 0 { + secLine = lineOf("InitConfigCodecWithOldKeys(") + } if secLine < 0 { secLine = lineOf("InitConfigCodec(") } if secLine < 0 { - t.Fatal("找不到 InitConfigCodec 调用(安全模块装配点)") + t.Fatal("找不到配置 codec 装配点(安全模块生效点)") } // 密钥装载点也要在快照之前(两者都在安全块里) keyLine := lineOf("LoadOrCreateKey(") @@ -125,7 +130,7 @@ func TestSecurityModuleInitializesBeforeConfigSnapshot(t *testing.T) { } // 且模块内部**确实**装了密钥与子密钥(防止有人把 Prepare 改成空壳而判据仍绿)。 if secLine <= 0 || keyLine <= 0 { - t.Errorf("★ 安全模块实现里找不到密钥装配(InitConfigCodec=%d LoadOrCreateKey=%d)—— "+ + t.Errorf("★ 安全模块实现里找不到密钥装配(codec=%d LoadOrCreateKey=%d)—— "+ "Prepare() 退化成空壳,安全审查将不生效", secLine, keyLine) } // ★ 行号只在**同一文件内**可比。密钥装载与子密钥派生现在都在 diff --git a/internal/config/backup_diag_test.go b/internal/config/backup_diag_test.go index e0ce4958..5ed3f45b 100644 --- a/internal/config/backup_diag_test.go +++ b/internal/config/backup_diag_test.go @@ -15,17 +15,17 @@ import ( func TestAuditSecretsDetectsForeignKeyBackup(t *testing.T) { // 本包的 codec 是**进程级全局**,测试必须还原现场, // 否则会把密钥泄漏给同包其它判据(实测:导致 GetInt 读到密文)。 - t.Cleanup(security.ClearConfigCodec) dir := t.TempDir() keyA := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") keyB := []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") // 机器 A:写入一条加密配置 - security.ClearConfigCodec() - if _, err := security.InitConfigCodec(keyA); err != nil { - t.Fatal(err) + cA, cerr := security.NewConfigCodec(keyA, nil) + if cerr != nil { + t.Fatal(cerr) } regA := NewConfigRegistry(filepath.Join(dir, "config.db")) + regA.SetCodec(cA) regA.RegisterDef(cfgDef("core.llm.api_key")) if err := regA.Set("core.llm.api_key", "sk-machine-a"); err != nil { t.Fatal(err) @@ -37,11 +37,12 @@ func TestAuditSecretsDetectsForeignKeyBackup(t *testing.T) { regA.Close() // 机器 B:同一份 db,但密钥不同 - security.ClearConfigCodec() - if _, err := security.InitConfigCodec(keyB); err != nil { - t.Fatal(err) + cB, cerr := security.NewConfigCodec(keyB, nil) + if cerr != nil { + t.Fatal(cerr) } regB := NewConfigRegistry(filepath.Join(dir, "config.db")) + regB.SetCodec(cB) defer regB.Close() ok2, bad2, err := regB.AuditSecrets() if err != nil { @@ -61,25 +62,27 @@ func TestAuditSecretsDetectsForeignKeyBackup(t *testing.T) { // ★ 同一密钥下重启(正常恢复)必须全部可解 —— 不能把正常备份误报成坏。 func TestAuditSecretsCleanOnSameKey(t *testing.T) { - t.Cleanup(security.ClearConfigCodec) dir := t.TempDir() key := []byte("cccccccccccccccccccccccccccccccc") - security.ClearConfigCodec() - if _, err := security.InitConfigCodec(key); err != nil { - t.Fatal(err) + c, cerr := security.NewConfigCodec(key, nil) + if cerr != nil { + t.Fatal(cerr) } db := filepath.Join(dir, "config.db") reg := NewConfigRegistry(db) + reg.SetCodec(c) reg.RegisterDef(cfgDef("core.llm.api_key")) if err := reg.Set("core.llm.api_key", "sk-1"); err != nil { t.Fatal(err) } reg.Close() - if _, err := security.InitConfigCodec(key); err != nil { - t.Fatal(err) + cy, cerr := security.NewConfigCodec(key, nil) + if cerr != nil { + t.Fatal(cerr) } reg2 := NewConfigRegistry(db) + reg2.SetCodec(cy) defer reg2.Close() ok, bad, err := reg2.AuditSecrets() if err != nil || bad != 0 || ok != 1 { diff --git a/internal/config/registry.go b/internal/config/registry.go index f698102a..b88984cb 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/JianFeeeee/HologramAgent/internal/security" "log" "os" "path/filepath" @@ -64,6 +65,41 @@ type ConfigRegistry struct { // 为何缓存成集合而不是每次查 def:写入是热路径,而 def 查询要拿锁; // 且 secret 判定必须对**无 def 的键**也能生效(见 secretByKeyPattern)。 secrets map[string]bool + + // codec 是配置落盘加密执行者(由 security 模块注入)。 + // + // ★ 2026-11-04(T3.2):此前读 `security.ConfigCodec()` 包级单例。 + // 那个单例绕开了内核统一挂载点 —— 本 Registry 属于哪个内核实例就用 + // 哪个 codec,不该由"最后谁调了 InitConfigCodec"决定。 + // + // ★ 为什么是字段注入而不是让 config import kstate: + // kstate 依赖 config(State.CfgReg 的类型),config 再 import kstate + // 就是 import cycle。所以方向是 config 只持有它需要的**能力** + // (一个 *security.Codec),由上层装配时塞进来。 + codecMu sync.RWMutex + codec *security.Codec +} + +// SetCodec 注入配置落盘加密执行者(security 模块装配时调用)。 +// +// nil 合法:未启用加密时所有敏感项以明文落盘(并可由 IsEncrypted 看出)。 +func (r *ConfigRegistry) SetCodec(c *security.Codec) { + if r == nil { + return + } + r.codecMu.Lock() + r.codec = c + r.codecMu.Unlock() +} + +// codecOf 返回注入的 codec(未注入为 nil)。 +func (r *ConfigRegistry) codecOf() *security.Codec { + if r == nil { + return nil + } + r.codecMu.RLock() + defer r.codecMu.RUnlock() + return r.codec } func NewConfigRegistry(dbPath string) *ConfigRegistry { @@ -211,7 +247,7 @@ func (r *ConfigRegistry) Register(key string, value interface{}) { defer r.mu.Unlock() stored := fmt.Sprint(value) if r.isSecretKey(key) { - stored, _ = sealForStorage(key, stored) + stored, _ = r.sealForStorage(key, stored) } r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, key, stored) } @@ -235,7 +271,7 @@ func (r *ConfigRegistry) registerDefLocked(def ConfigDef) { // 默认值也要加密落盘(否则声明了 secret 的项一启动就是明文)。 stored := def.Default if r.isSecretKey(def.Key) { - stored, _ = sealForStorage(def.Key, stored) + stored, _ = r.sealForStorage(def.Key, stored) } r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, def.Key, stored) // 记录「本次运行确实为这个键铺了初始行」—— GetXxx 只对这类键回退到 @@ -383,7 +419,7 @@ func (r *ConfigRegistry) Get(key string) (interface{}, error) { return nil, err } // 敏感项在库里是密文 ⇒ 对调用方透明解密(读接口语义不变)。 - if plain, oerr := openFromStorage(key, val); oerr == nil { + if plain, oerr := r.openFromStorage(key, val); oerr == nil { return plain, nil } else { return nil, oerr @@ -399,7 +435,7 @@ func (r *ConfigRegistry) Set(key string, value interface{}) error { stored := fmt.Sprint(value) // ★ 敏感项在**写库前**加密:库文件里只有密文。 if r.isSecretKey(key) { - stored, _ = sealForStorage(key, stored) + stored, _ = r.sealForStorage(key, stored) } _, err := r.db.Exec(`INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)`, key, stored) if err == nil && strings.HasPrefix(key, "core.llm.sources.") { @@ -554,7 +590,7 @@ func (r *ConfigRegistry) listPrefixLocked(prefix string) map[string]string { for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err == nil { - if plain, oerr := openFromStorage(k, v); oerr == nil { + if plain, oerr := r.openFromStorage(k, v); oerr == nil { out[k] = plain } else { // 解不开就不给值:宁可缺失(可诊断)也不把密文当明文交出去。 @@ -580,7 +616,7 @@ func (r *ConfigRegistry) Dump() map[string]interface{} { // ★ 返回**明文**:Dump 的调用方都是内核内部可信路径 //(ToConfig 重建 LLMSource、WebUI 设置页、插件读自身/他人配置)。 // 本需求防的是「静态文件泄漏」,不是「内部读取」。 - if plain, oerr := openFromStorage(k, v); oerr == nil { + if plain, oerr := r.openFromStorage(k, v); oerr == nil { result[k] = plain } else { log.Printf("[config] Dump decrypt %s failed: %v", k, oerr) @@ -1076,7 +1112,7 @@ func (r *ConfigRegistry) GetString(key, defaultVal string) string { if err != nil { return defaultVal } - if plain, oerr := openFromStorage(key, v); oerr == nil { + if plain, oerr := r.openFromStorage(key, v); oerr == nil { return plain } return defaultVal @@ -1365,7 +1401,7 @@ func (r *ConfigRegistry) SetPluginConfig(name, key string, value interface{}) er table := r.pluginTableName(name) stored := fmt.Sprint(value) if r.isSecretKey("plugin." + name + "." + key) { - stored, _ = sealForStorage("plugin."+name+"."+key, stored) + stored, _ = r.sealForStorage("plugin."+name+"."+key, stored) } _, err := r.db.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, table), key, stored) return err @@ -1397,7 +1433,7 @@ func (p *PluginSettings) Get(key string) (interface{}, error) { return nil, err } // 敏感项透明解密 - if plain, oerr := openFromStorage("plugin."+p.name+"."+key, val); oerr == nil { + if plain, oerr := p.registry.openFromStorage("plugin."+p.name+"."+key, val); oerr == nil { return plain, nil } else { return nil, oerr @@ -1410,7 +1446,7 @@ func (p *PluginSettings) Set(key string, value interface{}) error { stored := fmt.Sprint(value) // ★ 插件敏感项同样加密落盘(插件无感知:它 Set 明文,Get 也得到明文)。 if p.isSecretKeyLocal(key) { - stored, _ = sealForStorage("plugin."+p.name+"."+key, stored) + stored, _ = p.registry.sealForStorage("plugin."+p.name+"."+key, stored) } _, err := p.registry.db.Exec(fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, p.table), key, stored) return err @@ -1428,7 +1464,7 @@ func (p *PluginSettings) SetSecret(key string, value interface{}) error { sealed := stored if stored != "" { var ok bool - sealed, ok = sealForStorage("plugin."+p.name+"."+key, stored) + sealed, ok = p.registry.sealForStorage("plugin."+p.name+"."+key, stored) if !ok { return fmt.Errorf("config: 密钥未装配,拒绝以明文存储敏感项 %q", key) } @@ -1453,7 +1489,7 @@ func (p *PluginSettings) GetSecret(key string) (string, error) { if err != nil { return "", err } - return openFromStorage("plugin."+p.name+"."+key, val) + return p.registry.openFromStorage("plugin."+p.name+"."+key, val) } func (p *PluginSettings) Remove(key string) error { p.registry.mu.Lock() @@ -1496,7 +1532,7 @@ func (p *PluginSettings) RegisterDef(def ConfigDef) { stored := def.Default if p.registry.isSecretKey(qualified) { - stored, _ = sealForStorage(qualified, stored) + stored, _ = p.registry.sealForStorage(qualified, stored) } p.registry.db.Exec(fmt.Sprintf(`INSERT OR IGNORE INTO %s (key, value) VALUES (?, ?)`, p.table), localKey, stored) } diff --git a/internal/config/secret.go b/internal/config/secret.go index c8e65f47..303bf355 100644 --- a/internal/config/secret.go +++ b/internal/config/secret.go @@ -113,9 +113,9 @@ func (p *PluginSettings) isSecretKeyLocal(key string) bool { // // 返回值语义:加密发生则 mem=false(调用方据此决定是否需回读校验); // 非敏感键或未装配 codec 时原样返回。 -func sealForStorage(key, value string) (string, bool) { +func (r *ConfigRegistry) sealForStorage(key, value string) (string, bool) { if !security.IsEncrypted(value) { - c := security.ConfigCodec() + c := r.codecOf() if c == nil || !c.Enabled() { return value, false } @@ -133,11 +133,11 @@ func sealForStorage(key, value string) (string, bool) { // 非密文原样返回(**存量明文兼容**):老数据不需要一次性迁移。 // 密文但解不开 ⇒ **响亮失败**(返回错误而非密文),因为把密文 // 当明文交出去会让下游拿到垃圾(比如把 `enc:v1:...` 当 API key 发出去)。 -func openFromStorage(key, stored string) (string, error) { +func (r *ConfigRegistry) openFromStorage(key, stored string) (string, error) { if !security.IsEncrypted(stored) { return stored, nil } - c := security.ConfigCodec() + c := r.codecOf() if c == nil || !c.Enabled() { return "", security.ErrNoMasterKey } @@ -164,7 +164,7 @@ func openFromStorage(key, stored string) (string, error) { // // 返回 (加密条数, 错误)。幂等:已加密的项不会被二次加密。 func (r *ConfigRegistry) EncryptExistingSecrets() (int, error) { - c := security.ConfigCodec() + c := r.codecOf() if c == nil || !c.Enabled() { return 0, security.ErrNoMasterKey } @@ -243,7 +243,7 @@ func (r *ConfigRegistry) EncryptExistingSecrets() (int, error) { // // 幂等:已是当前钥的项不动。解不开的项**响亮计数**并跳过(不静默删)。 func (r *ConfigRegistry) RekeySecrets() (int, int, error) { - c := security.ConfigCodec() + c := r.codecOf() if c == nil || !c.Enabled() { return 0, 0, security.ErrNoMasterKey } @@ -304,7 +304,7 @@ func (r *ConfigRegistry) RekeySecrets() (int, int, error) { // // 返回 (可解, 不可解)。不可解 > 0 就是密钥不匹配/缺代的硬信号。 func (r *ConfigRegistry) AuditSecrets() (int, int, error) { - c := security.ConfigCodec() + c := r.codecOf() if c == nil || !c.Enabled() { return 0, 0, security.ErrNoMasterKey } @@ -393,16 +393,23 @@ func (r *ConfigRegistry) SecretKeys() []string { // 「磁盘上确实是密文」。生产代码不得使用。 func (r *ConfigRegistry) DB() *sql.DB { return r.db } -// ResetTestCodec 为判据装配一把固定主密钥。 +// NewTestCodec 造一把固定主密钥的配置 codec(供判据注入 Registry)。 +// +// ★ 2026-11-04:从 ResetTestCodec 改名并改语义。 +// +// 原实现调 security.InitConfigCodec 装**包级单例**(已删除)。现在返回 +// 实例,由调用方 `reg.SetCodec(c)` 注入 —— 与生产注入路径一致。 // // 单独放在非 _test 文件里,是因为 proc 包(另一个包)的端到端判据 // 也需要它 —— 跨包无法直接调 *_test.go 里的辅助函数。 -func ResetTestCodec(t interface { +func NewTestCodec(t interface { Helper() Fatalf(string, ...interface{}) -}) { - if _, err := security.InitConfigCodec( - []byte("0123456789abcdef0123456789abcdef")); err != nil { +}) *security.Codec { + t.Helper() + c, err := security.NewConfigCodec([]byte("0123456789abcdef0123456789abcdef"), nil) + if err != nil { t.Fatalf("装配测试 codec 失败: %v", err) } + return c } diff --git a/internal/config/secret_test.go b/internal/config/secret_test.go index 648108b5..1bbba2a1 100644 --- a/internal/config/secret_test.go +++ b/internal/config/secret_test.go @@ -12,18 +12,23 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/security" ) -func testCodec(t *testing.T) { +// testCodec 造一把固定主密钥的 codec(不再装包级单例)。 +func testCodec(t *testing.T) *security.Codec { t.Helper() - if _, err := security.InitConfigCodec([]byte("0123456789abcdef0123456789abcdef")); err != nil { + 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() - testCodec(t) - return NewConfigRegistry(filepath.Join(dir, "config.db")) + r := NewConfigRegistry(filepath.Join(dir, "config.db")) + // 按生产路径注入(config 模块从 ks.Security.Codec() 取)。 + r.SetCodec(testCodec(t)) + return r } // 直接在 sqlite 层面读原始值(绕过 ConfigRegistry 的解密)。 @@ -198,10 +203,12 @@ func TestWrongKeyFailsLoudly(t *testing.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 { + // 换一把主密钥(同一 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("换密钥后必须报错,不能把密文当明文返回") } @@ -216,8 +223,7 @@ func TestWrongKeyFailsLoudly(t *testing.T) { // ★ 判据:未装配 codec 时退化为明文读写(本地开发/测试不阻塞)。 func TestNoCodecDegradesToPlain(t *testing.T) { dir := t.TempDir() - // 清空全局 codec - security.ClearConfigCodec() + // 不注入 codec = 未装配(原先是 ClearConfigCodec 清单例) r := NewConfigRegistry(filepath.Join(dir, "c.db")) if err := r.Set("core.llm.api_key", "plain-dev"); err != nil { t.Fatal(err) @@ -375,9 +381,8 @@ func TestExplicitSetSecretIgnoresKeyName(t *testing.T) { // 这是最危险的静默降级:插件以为存了密文,实际磁盘上是明文。 func TestSetSecretRefusesWithoutKey(t *testing.T) { dir := t.TempDir() - security.ClearConfigCodec() - defer testCodec(t) // 复原,避免影响其它判据 - + // 不注入 codec = 未装配(原先是 ClearConfigCodec 清单例)。 + // 每个 Registry 自带 codec,不再需要"复原全局状态"。 r := NewConfigRegistry(filepath.Join(dir, "c.db")) ps := r.PluginConfig("demo4") ps.RegisterDef(ConfigDef{Key: "enabled", Default: "1"}) diff --git a/internal/kernelmod/security/mod.go b/internal/kernelmod/security/mod.go index 26fe2de7..6e35ba5d 100644 --- a/internal/kernelmod/security/mod.go +++ b/internal/kernelmod/security/mod.go @@ -191,9 +191,14 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error } // 配置落盘用的子密钥(域隔离:与别名密钥不共用)。 - if _, err := sec.InitConfigCodecWithOldKeys(key, oldKeys); err != nil { + // + // ★ 2026-11-04(T3.2):不再装全局单例,构造后**注入 cfgReg**。 + // 注入必须早于下面的 EncryptExistingSecrets —— 它要靠 codec 加密存量明文。 + cfgCodec, err := sec.NewConfigCodec(key, oldKeys) + if err != nil { return nil, fmt.Errorf("config-encryption subkey derivation failed: %w", err) } + cfgReg.SetCodec(cfgCodec) // 把库里仍是明文的敏感项就地加密(幂等;升级路径必须跑)。 if n, merr := cfgReg.EncryptExistingSecrets(); merr != nil { log.Printf("[security] existing sensitive config encryption incomplete (some entries still plaintext): %v", merr) @@ -208,6 +213,7 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error } rt := sec.NewRuntime(store) + rt.SetCodec(cfgCodec) setPreparedRuntime(rt) // ★ 全局策略基线(部署方兵底,用户 2026-10-29 裁定选项 B)。 diff --git a/internal/plugin/proc/e2e_secret_test.go b/internal/plugin/proc/e2e_secret_test.go index 7b58d414..2772495c 100644 --- a/internal/plugin/proc/e2e_secret_test.go +++ b/internal/plugin/proc/e2e_secret_test.go @@ -17,6 +17,7 @@ package proc // 内核侧修好了接口」这类错位(本次实际踩到过一次)。 import ( + "github.com/JianFeeeee/HologramAgent/internal/security" "os" "path/filepath" "strings" @@ -74,8 +75,8 @@ func TestE2E_RealTemplateSecretRoundTrip(t *testing.T) { // 用**真实** ConfigRegistry(真加密)+ 真主密钥 dir := t.TempDir() - setTestCodec(t) reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db")) + reg.SetCodec(setTestCodec(t)) defer reg.Close() host, err := NewHost() @@ -131,8 +132,10 @@ func TestE2E_RealTemplateSecretRoundTrip(t *testing.T) { _ = pubsdk.ConfigDef{} } -// setTestCodec 装配一把测试主密钥。 -func setTestCodec(t *testing.T) { +// setTestCodec 造一把测试主密钥的 codec(由调用方注入 Registry)。 +// +// ★ 2026-11-04:包级单例已删,不能"装一次全局就到处生效"。 +func setTestCodec(t *testing.T) *security.Codec { t.Helper() - internalConfig.ResetTestCodec(t) + return internalConfig.NewTestCodec(t) } diff --git a/internal/plugin/proc/e2e_secrets_test.go b/internal/plugin/proc/e2e_secrets_test.go index 7dc41400..e36c823e 100644 --- a/internal/plugin/proc/e2e_secrets_test.go +++ b/internal/plugin/proc/e2e_secrets_test.go @@ -80,7 +80,6 @@ func TestE2E_RealTemplateSecretsRoundTrip(t *testing.T) { bin := buildPluginWithRealTemplate(t, secretsE2EPlugin) dir := t.TempDir() - setTestCodec(t) // 变量库(2026-11-04:不再有进程级单例,改为显式持有并注入) store, err := internalSecurity.MustStore(internalSecurity.Options{ Path: filepath.Join(dir, "variables.json"), @@ -99,7 +98,7 @@ func TestE2E_RealTemplateSecretsRoundTrip(t *testing.T) { defer host.Close() core := newFakeCore() - core.secretsAPI = internalSDK.NewSecretsAPI(store) + core.secretsAPI = internalSDK.NewSecretsAPI(store, nil) p := New("e2e", bin, dir, nil, host, nil) if err := p.Start(core); err != nil { t.Fatalf("Start: %v", err) @@ -140,7 +139,6 @@ func TestE2E_PluginAndKernelShareOneVault(t *testing.T) { bin := buildPluginWithRealTemplate(t, secretsE2EPlugin) dir := t.TempDir() - setTestCodec(t) st, err := internalSecurity.MustStore(internalSecurity.Options{ Path: filepath.Join(dir, "variables.json"), KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, @@ -153,7 +151,7 @@ func TestE2E_PluginAndKernelShareOneVault(t *testing.T) { host, _ := NewHost() defer host.Close() core := newFakeCore() - core.secretsAPI = internalSDK.NewSecretsAPI(st) + core.secretsAPI = internalSDK.NewSecretsAPI(st, nil) p := New("e2e", bin, dir, nil, host, nil) if err := p.Start(core); err != nil { t.Fatal(err) @@ -193,7 +191,7 @@ func TestE2E_SecretsUnavailableWhenDisabled(t *testing.T) { defer host.Close() core := newFakeCore() // 未启用安全模块 ⇒ 传 nil(Available() 必须为 false,且不得 panic) - core.secretsAPI = internalSDK.NewSecretsAPI(nil) + core.secretsAPI = internalSDK.NewSecretsAPI(nil, nil) p := New("e2e", bin, dir, nil, host, nil) if err := p.Start(core); err != nil { t.Fatal(err) diff --git a/internal/plugin/proc/e2e_security_declare_test.go b/internal/plugin/proc/e2e_security_declare_test.go index 6f50b7a3..ca6c5ac9 100644 --- a/internal/plugin/proc/e2e_security_declare_test.go +++ b/internal/plugin/proc/e2e_security_declare_test.go @@ -73,7 +73,6 @@ func TestE2E_RealTemplateSecurityDeclare(t *testing.T) { bin := buildPluginWithRealTemplate(t, securityE2EPlugin) dir := t.TempDir() - setTestCodec(t) // ★ 2026-11-04:包级单例已删。Runtime 显式构造并**注入**给插件 SDK // (生产路径同款:kstate.State.Security → plugin.Registry.SetSecurityRuntime)。 diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 302f8bf4..b83a1e5b 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -346,6 +346,14 @@ func (r restartOnly) RequestKernelShutdown(reason string) error { // // (那是反向依赖,且 cmd 包不可被 import)。用接口 + setter 打破方向。 // +// codecOf 从 Runtime 取配置加密器(Runtime 可 nil)。 +func codecOf(rt *security.Runtime) *security.Codec { + if rt == nil { + return nil + } + return rt.Codec() +} + // SetSecurityRuntime 注入安全运行时(plugin-host 模块装配时调用)。 func (r *Registry) SetSecurityRuntime(rt *security.Runtime) { r.mu.Lock() @@ -793,7 +801,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { Settings: sett, // ★ 变量库:桥接到**注入的** Store(不另建存储)。 // 内核未启用安全模块时为 nil ⇒ Available()==false(不是 nil API)。 - Secrets: sdk.NewSecretsAPI(deps.secretStore), + Secrets: sdk.NewSecretsAPI(deps.secretStore, codecOf(deps.securityRT)), SecurityRuntime: deps.securityRT, // ★ 策略声明面:插件**声明**档位(qq.owner / qq.guest…), // 判定仍在内核 security.Runtime。Owner 由内核用真实插件名填, diff --git a/internal/plugins/webui/secret_meta_test.go b/internal/plugins/webui/secret_meta_test.go index 04938438..c9aae164 100644 --- a/internal/plugins/webui/secret_meta_test.go +++ b/internal/plugins/webui/secret_meta_test.go @@ -22,8 +22,8 @@ import ( func TestSecretFlagReachesSettingsMeta(t *testing.T) { dir := t.TempDir() - internalConfig.ResetTestCodec(t) reg := internalConfig.NewConfigRegistry(dir + "/config.db") + reg.SetCodec(internalConfig.NewTestCodec(t)) defer reg.Close() // 插件用**两种写法**各声明一个敏感项 @@ -79,8 +79,8 @@ func keysOfMeta(m map[string]map[string]interface{}) []string { // 单键 reveal 才能看到明文;磁盘上仍是密文。 func TestSecretPlainInUIButCipherOnDisk(t *testing.T) { dir := t.TempDir() - internalConfig.ResetTestCodec(t) reg := internalConfig.NewConfigRegistry(dir + "/config.db") + reg.SetCodec(internalConfig.NewTestCodec(t)) defer reg.Close() sett := internalSDK.NewSettings("webui", reg) diff --git a/internal/sdk/secrets_impl.go b/internal/sdk/secrets_impl.go index 709d3e4d..5c54acae 100644 --- a/internal/sdk/secrets_impl.go +++ b/internal/sdk/secrets_impl.go @@ -32,11 +32,19 @@ import ( ) // secretsImpl 实现 pubsdk.SecretsAPI。 -type secretsImpl struct{ store *internalSecurity.Store } +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) pubsdk.SecretsAPI { - return &secretsImpl{store: store} +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 } @@ -92,7 +100,7 @@ func (s *secretsImpl) Fill(text string) (string, int, []string) { } func (s *secretsImpl) Encrypt(plain []byte) (string, error) { - c := internalSecurity.ConfigCodec() + c := s.codec if c == nil || !c.Enabled() { // ★ 拒绝明文落盘:插件以为存的是密文而实际是明文,是最危险的静默降级。 return "", fmt.Errorf("内核未装配加密能力,拒绝以明文存储(core.security.enabled=false?)") @@ -105,7 +113,7 @@ func (s *secretsImpl) Decrypt(stored string) ([]byte, error) { if !internalSecurity.IsEncrypted(stored) { return []byte(stored), nil } - c := internalSecurity.ConfigCodec() + c := s.codec if c == nil || !c.Enabled() { return nil, fmt.Errorf("有密文但内核未装配加密能力(密钥换了?)") } diff --git a/internal/security/codec.go b/internal/security/codec.go index 21faa54d..788d80c7 100644 --- a/internal/security/codec.go +++ b/internal/security/codec.go @@ -31,7 +31,6 @@ import ( "fmt" "io" "strings" - "sync" ) // EncPrefixV1 是密文的自描述前缀。 @@ -180,6 +179,75 @@ func (c *Codec) Open(stored string) (string, error) { return "", fmt.Errorf("%w (域 %s)", ErrBadCiphertext, c.Domain()) } +// NewLegacyRingCodec 构造一个用**现成密钥环**(不做域派生)的编解码器。 +// +// ★ 专供 Store 使用(2026-11-04 T3.2)。与 NewCodec 的区别只有一点: +// +// NewCodec :用一个主密钥 HMAC 派生出**该域**的子密钥 +// NewLegacyRingCodec:直接吃一组密钥(Store 的 keys 环,含历史代) +// +// 为什么 Store 不能走 NewCodec 的域派生:它的密文是**用主密钥本身**加密的 +// (历史数据),换成派生密钥会让所有存量条目解不开 —— 那是数据丢失, +// 不是迁移。 +// +// keys[0] 是当前加密钥,其余只用于 Open(轮换过渡期)。 +func NewLegacyRingCodec(domain string, keys [][]byte) *Codec { + c := &Codec{domain: domain} + if len(keys) > 0 && len(keys[0]) == 32 { + c.key = keys[0] + for _, k := range keys[1:] { + if len(k) == 32 { + c.oldKeys = append(c.oldKeys, k) + } + } + } + return c +} + +// OpenLegacy 解开**无前缀**的旧格式密文(裸 base64 + nonce 前置)。 +// +// ★ 为什么必须单独一个方法而不是让 Open 兼容: +// +// `Open` 对无前缀输入**原样返回**(那是"存量明文兼容"的正确行为—— +// 老配置里存的就是明文)。但 Store 的旧密文恰好也是无前缀的裸 base64, +// 两者在字面上无法区分。 +// +// 所以: +// +// 配置(config)→ 用 Open:无前缀 = 明文,原样返回 +// 变量库(Store)→ 读到无前缀时先试 OpenLegacy:能解开 = 旧密文 +// ⇒ 立刻用 Seal 回写为新格式(惰性迁移) +// +// 把歧义留在**调用方**(它知道自己存的是什么),而不是让 Open 猜。 +func (c *Codec) OpenLegacy(stored string) (string, error) { + if !c.Enabled() { + return "", fmt.Errorf("%w: 有密文但编解码器未启用(域 %s)", ErrNoMasterKey, c.Domain()) + } + raw, err := decodeB64(stored) + if err != nil { + return "", fmt.Errorf("%w: 旧格式 base64 解码失败", ErrBadCiphertext) + } + keys := append([][]byte{c.key}, c.oldKeys...) + for _, k := range keys { + block, err := aes.NewCipher(k) + if err != nil { + continue + } + gcm, err := cipher.NewGCM(block) + if err != nil { + continue + } + if len(raw) < gcm.NonceSize() { + continue + } + plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) + if err == nil { + return string(plain), nil + } + } + return "", fmt.Errorf("%w: 旧格式密文解不开(域 %s)", ErrBadCiphertext, c.Domain()) +} + // IsEncrypted 报告一个已存储的值是否是本模块的密文。 // // ★ 单调为真:密文解密后可能恰好以 `enc:v1:` 开头(极小概率), @@ -188,38 +256,32 @@ func IsEncrypted(stored string) bool { return strings.HasPrefix(stored, EncPrefixV1) } -// ---------------- 全局配置编解码器 ---------------- +// ---------------- 配置落盘编解码器 ---------------- -var ( - globalCodecMu sync.RWMutex - globalCodec *Codec -) +// ★ 2026-11-04(T3.2):包级单例 `globalCodec` 与访问器 `ConfigCodec()` +// 已删除(用户裁定「所有共享数据统一挂载一个 kernel 提供的挂载点上暴露」)。 +// +// 配置加密器现在归 `security.Runtime`: +// +// security 模块装配 → rt.SetCodec(c) → ks.Security +// config 模块装配 → cfgReg.SetCodec(ks.Security.Codec()) +// +// 与变量库(ks.Security.Store())同源同生命周期,没有任何第二条通道。 -// InitConfigCodec 装配配置落盘用的编解码器(内核启动时调用一次)。 -func InitConfigCodec(masterKey []byte) (*Codec, error) { - return InitConfigCodecWithOldKeys(masterKey, nil) +// NewConfigCodec 构造配置落盘用的编解码器(**不**注册任何全局状态)。 +// +// 当前钥负责 Seal;旧钥仅参与 Open(E-1 平滑轮换)。 +// 调用方拿到实例后负责注入(见 Runtime.SetCodec)。 +func NewConfigCodec(masterKey []byte, oldKeys [][]byte) (*Codec, error) { + return NewCodecWithOldKeys(masterKey, configCodecDomain, oldKeys) } -// InitConfigCodecWithOldKeys 装配支持 E-1 平滑轮换的配置 codec。 -// 当前钥负责 Seal;旧钥仅参与 Open。 - -func InitConfigCodecWithOldKeys(masterKey []byte, oldKeys [][]byte) (*Codec, error) { - c, err := NewCodecWithOldKeys(masterKey, "config-at-rest", oldKeys) - if err != nil { - return nil, err - } - globalCodecMu.Lock() - globalCodec = c - globalCodecMu.Unlock() - return c, nil -} - -// ConfigCodec 取配置编解码器;未装配时返回 nil(调用方原样读写)。 -func ConfigCodec() *Codec { - globalCodecMu.RLock() - defer globalCodecMu.RUnlock() - return globalCodec -} +// configCodecDomain 是配置落盘加密的域。 +// +// ★ 必须与变量库的域(storeCodecDomain)不同 —— 见那边的说明。 +// 域在这里集中定义,避免"两处各写一个字符串"(拼错了不会报错, +// 只会让两个域意外重合,而那是安全倒退)。 +const configCodecDomain = "config-at-rest" // NeedsRekey 报告该密文是否**不是**当前主密钥加的(即轮换后尚未重加密)。 // @@ -273,13 +335,11 @@ func (c *Codec) openWithSingleKey(key []byte, stored string) (string, error) { return string(plain), nil } -// ClearConfigCodec 卸下配置编解码器(测试与"显式关闭加密"用)。 +// ★ 2026-11-04:`ClearConfigCodec` 已删除。 // -// 单独一个函数而不是让 InitConfigCodec(nil) 承担: -// 后者语义模糊(nil 是"没密钥"还是"要清空"?),且会掩盖调用方的错误 -// —— 曾因此让一个判据假绿(以为清空了,其实上一把密钥还在)。 -func ClearConfigCodec() { - globalCodecMu.Lock() - globalCodec = nil - globalCodecMu.Unlock() -} +// 它清的是包级单例。现在"关闭加密"通过不注入(或注入 nil)表达: +// +// rt.SetCodec(nil) → config.codecOf() == nil +// cfgReg.SetCodec(nil) +// +// 比"清空一个全局变量"更明确:状态属于对象,不属于进程。 diff --git a/internal/security/codec_unify_test.go b/internal/security/codec_unify_test.go new file mode 100644 index 00000000..7a090e16 --- /dev/null +++ b/internal/security/codec_unify_test.go @@ -0,0 +1,216 @@ +package security + +// 判据:**加密能力只有一处实现**(2026-11-04 T3.2)。 +// +// ## 用户裁定 +// +// > 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 写入的密文必须带 enc:v1: 前缀(旧实现是裸 base64) +// ② 变量库与配置**不得同域**(同域 = 一处泄漏可推导另一处) +// ③ 旧格式(裸 base64)密文必须仍可读,且读到后就地升级 +// ④ 密钥轮换后旧密文仍可解(不能因换实现而丢数据) + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestStoreWritesPrefixedCiphertext 钉住 ①:新写入必须带前缀。 +// +// 变红条件:把 Store.Put 改回自建 seal(裸 base64)⇒ 红。 +func TestStoreWritesPrefixedCiphertext(t *testing.T) { + dir := t.TempDir() + s, err := MustStore(Options{ + Path: filepath.Join(dir, "v.json"), + KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("MustStore: %v", err) + } + if _, err := s.Submit("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", "test"); err != nil { + t.Fatalf("Submit: %v", err) + } + + // 从内存里读原始密文(绕过解密)。 + s.mu.RLock() + var sealed string + for _, e := range s.data { + sealed = e.Sealed + } + s.mu.RUnlock() + + if sealed == "" { + t.Fatal("没读出密文") + } + if !IsEncrypted(sealed) { + t.Fatalf("★ Store 写入的密文没有 %q 前缀 —— 说明它还在用自建 seal。\n"+ + "加密必须委托 Codec(用户裁定:「store 也应该调用 security 的加密能力」)。\n"+ + "实际前缀(前 24 字符): %q", EncPrefixV1, sealed[:min(24, len(sealed))]) + } +} + +// TestStoreAndConfigUseDifferentDomains 钉住 ②:域隔离(**安全判据**)。 +// +// 变红条件:把 storeCodecDomain 改成与 configCodecDomain 相同 ⇒ 红。 +// +// 为什么这条必须有:同域意味着"用同一把密钥派生同一把子密钥加密两处不同 +// 用途的数据"。一旦变量库的明文泄漏,它就成为配置密文的字典攻击素材。 +// 这是安全倒退,不是风格问题。 +func TestStoreAndConfigUseDifferentDomains(t *testing.T) { + if storeCodecDomain == configCodecDomain { + t.Fatalf("★ 变量库与配置用了同一个加密域 %q\n\n"+ + "两者必须不同域:同域时一处明文泄漏可推导另一处密文。\n"+ + "storeCodecDomain=%q configCodecDomain=%q", + storeCodecDomain, storeCodecDomain, configCodecDomain) + } + // 更强的判据:实际用同一把主密钥构造两个 codec,密文必须互不可解。 + const key = "0123456789abcdef0123456789abcdef" + storeCodec := NewLegacyRingCodec(storeCodecDomain, [][]byte{[]byte(key)}) + cfgCodec, err := NewConfigCodec([]byte(key), nil) + if err != nil { + t.Fatalf("NewConfigCodec: %v", err) + } + + sealedByStore, err := storeCodec.Seal("same-plaintext") + if err != nil { + t.Fatalf("store Seal: %v", err) + } + if plain, oerr := cfgCodec.Open(sealedByStore); oerr == nil { + t.Fatalf("★ 配置 codec 解开了变量库的密文(明文 %q)—— 域隔离失效", plain) + } +} + +// TestLegacyCiphertextStillReadableAndMigrates 钉住 ③:旧格式可读 + 惰性迁移。 +// +// 变红条件: +// - openWithStoreRing 不兼容无前缀格式 ⇒ 旧数据全丢(红) +// - 读到旧格式后不回写 ⇒ 文件里仍是旧格式(红) +func TestLegacyCiphertextStillReadableAndMigrates(t *testing.T) { + dir := t.TempDir() + const key = "0123456789abcdef0123456789abcdef" + path := filepath.Join(dir, "v.json") + + // ① 用**旧格式**(裸 base64,无前缀)造一条密文 —— 模拟 T3.2 之前的数据。 + legacyCodec := NewLegacyRingCodec(storeCodecDomain, [][]byte{[]byte(key)}) + legacySealed, err := legacyCodec.Seal("ghp_LEGACY_TOKEN_0123456789") + if err != nil { + t.Fatalf("Seal: %v", err) + } + // Seal 带前缀;去掉它才是真正的旧格式(裸 base64)。 + oldFormat := strings.TrimPrefix(legacySealed, EncPrefixV1) + if IsEncrypted(oldFormat) { + t.Fatal("构造旧格式失败(仍有前缀)") + } + + // ② 写一份带旧格式条目的库文件。 + s, err := MustStore(Options{ + Path: path, + KeyFn: func() ([]byte, error) { return []byte(key), nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("MustStore: %v", err) + } + s.mu.Lock() + s.data["sec_legacy01"] = &Entry{Alias: "sec_legacy01", Sealed: oldFormat, Source: "intercepted"} + if err := s.saveLocked(); err != nil { + t.Fatalf("saveLocked: %v", err) + } + s.mu.Unlock() + + // ③ 重新加载后必须能**解开**旧格式(否则旧数据全丢)。 + s2, err := MustStore(Options{ + Path: path, + KeyFn: func() ([]byte, error) { return []byte(key), nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("重新加载: %v", err) + } + plain, n, _ := s2.Fill("token=@{sec_legacy01}") + if n != 1 { + t.Fatalf("★ 旧格式密文解不开(回填 0 处)—— 存量数据全丢。实际: %q", plain) + } + if !strings.Contains(plain, "ghp_LEGACY_TOKEN") { + t.Fatalf("回填结果不对: %q", plain) + } + + // ④ 惰性迁移:读到旧格式后,落盘应升级为新格式。 + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读落盘文件: %v", err) + } + if strings.Contains(string(raw), oldFormat) { + t.Fatalf("★ 旧格式密文仍留在磁盘上 —— 惰性迁移没有回写。\n" + + "两种格式永久共存会让'落盘都是新格式'这类判据永远无法成立。") + } + if !strings.Contains(string(raw), EncPrefixV1) { + t.Fatalf("迁移后应出现 %q 前缀,实际文件内容(前 200 字符): %s", + EncPrefixV1, string(raw[:min(200, len(raw))])) + } +} + +// TestRotationKeepsOldCiphertextReadable 钉住 ④:轮换后旧密文仍可解。 +// +// 变红条件:RotateKey 只保留新钥、丢掉旧钥 ⇒ 旧条目全变"解不开"。 +func TestRotationKeepsOldCiphertextReadable(t *testing.T) { + dir := t.TempDir() + oldKey := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + newKey := []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + + path := filepath.Join(dir, "v.json") + s, err := MustStore(Options{ + Path: path, + KeyFn: func() ([]byte, error) { return oldKey, nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("MustStore: %v", err) + } + alias, err := s.Submit("ghp_ROTATE_ME_0123456789", "test") + if err != nil { + t.Fatalf("Submit: %v", err) + } + + // 轮换到新钥(旧钥进历史环)。 + if err := s.RotateKey(newKey); err != nil { + t.Fatalf("RotateKey: %v", err) + } + + // 轮换后必须仍能回填(否则所有历史 @{sec_xxx} 失效)。 + out, n, _ := s.Fill("token=@{" + alias + "}") + if n != 1 || !strings.Contains(out, "ghp_ROTATE_ME") { + t.Fatalf("★ 轮换后旧条目解不开(回填 %d 处): %q\n"+ + "这会静默作废所有历史引用 —— 用户看到的是一次凭据替换失败。", n, out) + } +} + +// min 是 Go 版本早于 1.21 时的兜底(本仓支持较老工具链)。 +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/security/runtime.go b/internal/security/runtime.go index 936449c1..ef5cfb67 100644 --- a/internal/security/runtime.go +++ b/internal/security/runtime.go @@ -76,6 +76,16 @@ type Runtime struct { // 是**插件声明出来的注册表**,每个 agent 各持一份就会变成 // “插件往 A 声明了档位,B 看不见”—— 那是最难查的一类缺陷。 // 所以切分是「共享注册表 + per-agent 执行视图」。 + // codec 是配置落盘加密执行者(独立域,与变量库不共用密钥派生)。 + // + // ★ 2026-11-04(T3.2):此前是包级单例 `globalCodec`(ConfigCodec())。 + // 那个单例绕开了内核统一挂载点,且让"哪个 codec 生效"取决于 + // "最后谁调了 InitConfigCodec"。 + // + // 现在它归 Runtime:config 模块从 ks.Security.Codec() 取, + // 与变量库(ks.Security.Store())同源同生命周期。 + codec *Codec + scopesByAgent map[string]*AgentScope scopeMu sync.Mutex } @@ -167,6 +177,26 @@ func (r *Runtime) FillForExecution(ins Inspection, text string) (string, int) { return r.gate.FillForExecution(ins, text) } +// Codec 返回配置落盘加密器(未装配时为 nil)。 +// +// ★ 与 Store() 的区别:Store 管**凭据别名**(@{sec_xxx}), +// Codec 管**配置项密文**(enc:v1:...)。两者是独立的密钥域, +// 同一把主密钥派生出的不同子密钥 —— 域隔离是安全要求。 +func (r *Runtime) Codec() *Codec { + if r == nil { + return nil + } + return r.codec +} + +// SetCodec 注入配置加密器(安全模块装配时调用)。 +func (r *Runtime) SetCodec(c *Codec) { + if r == nil { + return + } + r.codec = c +} + // Store 返回底层变量库(安全工具族 secret_* 需要元信息)。 func (r *Runtime) Store() *Store { if r == nil || r.gate == nil { diff --git a/internal/security/singleton_test.go b/internal/security/singleton_test.go index 5cced37f..ab232cff 100644 --- a/internal/security/singleton_test.go +++ b/internal/security/singleton_test.go @@ -51,15 +51,16 @@ import ( // // 每一条都对应一次真实事故或一次可复现的错配: // -// global → 变量库:测试装单例、代码用 agent 字段,测的与跑的不是同一份 -// runtime → 防火墙:多实例时被覆盖 +// global → 变量库:测试装单例、代码用 agent 字段,测的与跑的不是同一份 +// runtime → 防火墙:多实例时被覆盖 +// globalCodec → 配置加密:域隔离失效的隐患(T3.2 已删) // -// ★ globalCodec 不在本表 —— 它属于 T3.2(Store 复用 Codec + Codec 归 Runtime)。 -// 那条改动还牵涉 config 的 6 个调用点与惰性密文迁移,单独一步做。 -// 本判据只钉已经完成的部分,避免"半成品状态让整套判据长期红" -// (长期红的判据会被人习惯性忽略,那时它就等于不存在)。 +// ★ 三条全部落地后才纳入本表。 +// +// - 半成品状态就纳入 ⇒ 判据长期红 ⇒ 被人习惯性忽略 ⇒ 等于不存在 +// - 全部落定后仍不纳入 ⇒ 后来者能原样加回来 func forbiddenPackageLevelVars() []string { - return []string{"global", "runtime"} + return []string{"global", "runtime", "globalCodec"} } // TestNoPackageLevelSingletons 钉住本包不得声明这三个包级变量。 diff --git a/internal/security/store.go b/internal/security/store.go index fc157983..ba4a26c8 100644 --- a/internal/security/store.go +++ b/internal/security/store.go @@ -104,6 +104,23 @@ type Store struct { // —— 因为新密文是按新钥加密的,两者必须同时可读才能平滑迁移。 keys []keyGen + // codec 是加解密执行者(**唯一**的 AES-GCM 实现)。 + // + // ★ 2026-11-04(T3.2):此前 Store 自带一份 seal/open 实现, + // 与 Codec 是**两套重复的 AES-256-GCM**(算法、nonce 布局、密钥环 + // 逻辑全都一样,只是 Codec 多了 enc:v1: 前缀与域隔离)。 + // + // 用户裁定: + // + // > store也应该调用security的加密能力! + // + // 现在 Store 只做"我用哪个域、我的密钥环是什么",加解密一律委托。 + // 这让"加密算法只有一处实现",也修掉一个真实隐患: + // 两套实现曾在 nonce 布局上各自演化(一处前置、一处也前置但无前缀), + // 任何一侧改动都不会被另一侧的判据发现。 + codec *Codec + codecMu sync.Mutex // 保护 codec 的惰性重建(keys 环在轮换时会变) + // enabled 支持显式关闭(本仓「显式才是特权」)。 enabled bool @@ -250,13 +267,9 @@ func (s *Store) Alias(plain string) (string, error) { // 别名若随新主密钥改变,记忆/上下文里的旧 @{sec_...} 会失效, // 且同一个凭据会出现两份变量。先在现有条目中按密钥环解密比对; // 找到就复用旧别名,找不到才用当前钥派生新别名。 - cands := s.keysForDecrypt() - if len(cands) == 0 { - cands = [][]byte{key} - } s.mu.RLock() for alias, e := range s.data { - if got, openErr := openWithRing(cands, e.Sealed); openErr == nil && got == plain { + if got, _, openErr := s.openWithStoreRing(e.Sealed); openErr == nil && got == plain { s.mu.RUnlock() return alias, nil } @@ -302,21 +315,16 @@ func (s *Store) keysForDecrypt() [][]byte { // // ★ 为何不是“记录每条密文属于哪一代”:那需要**改 Entry 结构**(加世代字段), // 而密文本身已经能被正确的那把钥唯一解开(AES-GCM 带认证)。 -// 逐个尝试的成本是常数级(代数量 <= 2 是常态),换来的是**零 schema 迁移**。 -func openWithRing(cands [][]byte, sealed string) (string, error) { - if len(cands) == 0 { - return "", ErrNoMasterKey - } - var lastErr error - for _, k := range cands { - plain, err := open(k, sealed) - if err == nil { - return plain, nil - } - lastErr = err - } - return "", lastErr -} +// ★ 2026-11-04(T3.2):`openWithRing` / `seal` / `open` 已删除。 +// +// 它们是 Store 自带的 AES-GCM 实现,与 Codec 完全重复(算法、nonce 布局、 +// 密钥环逻辑一致,只是 Codec 多了 enc:v1: 前缀与域隔离)。 +// +// 现在唯一的实现是 Codec: +// +// 写入 → s.sealWithRing(新格式,带前缀) +// 读取 → s.openWithStoreRing(兼容新旧两种格式) +// 轮换 → NewLegacyRingCodec(old) 解 + NewLegacyRingCodec(new) 加密 // RotateKey 平滑轮换变量库主密钥,并保持**已有别名不变**。 // @@ -341,13 +349,26 @@ func (s *Store) RotateKey(newKey []byte) error { if len(oldCandidates) == 0 { return ErrNoMasterKey } + // 旧环解、新钥加密。 + // + // ★ 这里刻意**不用** s.openWithStoreRing:它读 s.keys,而此刻 + // s.keys 还没换(下面才换)—— 但语义上要明确"用旧环解", + // 所以显式构造。 + oldCodec := NewLegacyRingCodec(storeCodecDomain, oldCandidates) + newCodec := NewLegacyRingCodec(storeCodecDomain, [][]byte{newKey}) copyData := make(map[string]*Entry, len(s.data)) for alias, e := range s.data { - plain, err := openWithRing(oldCandidates, e.Sealed) - if err != nil { - return fmt.Errorf("security: 轮换时解密变量 %s 失败,未切换密钥: %w", alias, err) + var plain string + var oerr error + if IsEncrypted(e.Sealed) { + plain, oerr = oldCodec.Open(e.Sealed) + } else { + plain, oerr = oldCodec.OpenLegacy(e.Sealed) } - sealed, err := seal(newKey, plain) + if oerr != nil { + return fmt.Errorf("security: 轮换时解密变量 %s 失败,未切换密钥: %w", alias, oerr) + } + sealed, err := newCodec.Seal(plain) if err != nil { return fmt.Errorf("security: 轮换时加密变量 %s 失败,未切换密钥: %w", alias, err) } @@ -369,6 +390,69 @@ func (s *Store) RotateKey(newKey []byte) error { // ---------------- 加密 ---------------- +// codecOf 返回当前密钥环对应的编解码器(惰性构建)。 +// +// ★ 为什么惰性而不是构造期建一次:`keys` 环会在 `RotateKey` 里变化 +// (新钥插到头部),那时旧 codec 的 key 就过期了 —— 表现为"轮换后 +// 新写入的条目用旧钥加密",而轮换的**全部意义**就是尽快停用旧钥。 +// 每次取用都按当前环重建,代价是一次切片拷贝。 +func (s *Store) codecOf() *Codec { + s.codecMu.Lock() + defer s.codecMu.Unlock() + keys := make([][]byte, 0, len(s.keys)) + for _, g := range s.keys { + keys = append(keys, g.Key) + } + s.codec = NewLegacyRingCodec(storeCodecDomain, keys) + return s.codec +} + +// storeCodecDomain 是变量库的加密域。 +// +// ★ 必须与 config 的域**不同**:域隔离是安全要求,不是风格。 +// 同域意味着"用同一把密钥加密两处不同用途的数据",一旦某处的明文 +// 泄漏就能成为另一处的字典攻击素材。 +const storeCodecDomain = "security/variables" + +// sealWithRing 用密钥环加密(新格式,带 enc:v1: 前缀)。 +func (s *Store) sealWithRing(plain string) (string, error) { + c := s.codecOf() + if !c.Enabled() { + return "", ErrNoMasterKey + } + return c.Seal(plain) +} + +// openWithStoreRing 解开一条条目密文,**兼容旧格式**。 +// +// 返回 (明文, 是否需要迁移回写)。 +// +// 两种格式: +// +// enc:v1:... → 新格式,Codec.Open +// 裸 base64 → 旧格式(T3.2 之前写的),Codec.OpenLegacy +// +// ★ 为什么必须区分:`Codec.Open` 对无前缀输入**原样返回明文** +// (那是给 config 的"存量明文兼容"用的)。而 Store 的旧密文恰好也 +// 无前缀 —— 直接交给 Open 会把密文当明文返回,症状是"凭据回填出一个 +// base64 串",看起来像数据损坏,实际是格式误判。 +func (s *Store) openWithStoreRing(sealed string) (plain string, needsMigrate bool, err error) { + c := s.codecOf() + if !c.Enabled() { + return "", false, ErrNoMasterKey + } + if IsEncrypted(sealed) { + p, oerr := c.Open(sealed) + return p, false, oerr + } + // 无前缀:只可能是旧格式密文(Store 从不存明文)。 + p, oerr := c.OpenLegacy(sealed) + if oerr != nil { + return "", false, oerr + } + return p, true, nil +} + func seal(key []byte, plain string) (string, error) { block, err := aes.NewCipher(key) if err != nil { @@ -463,11 +547,9 @@ func (s *Store) Replace(text, where string, detect []Pattern) (string, []Evt) { // // 返回 merged=true 表示这是**合并到已有别名**(同变量合并能力)。 func (s *Store) Put(alias, plain, source, note string) (merged bool, err error) { - key, err := s.key() - if err != nil { - return false, err - } - sealed, err := seal(key, plain) + // ★ 2026-11-04(T3.2):写入走 Codec(带 enc:v1: 前缀), + // 不再自建 seal。旧条目仍按旧格式可读(见 openWithStoreRing)。 + sealed, err := s.sealWithRing(plain) if err != nil { return false, err } @@ -515,6 +597,46 @@ func (s *Store) Forget(alias string) error { return s.saveLocked() } +// migrateEntry 把一条旧格式条目就地升级为新格式(惰性迁移)。 +// +// ## 为什么需要惰性迁移 +// +// T3.2 之前 Store 写入的是**裸 base64**(无前缀)。切到 Codec 之后新写入 +// 带 `enc:v1:` 前缀,但磁盘上已有的条目仍是旧格式。 +// +// 三条路可选: +// +// ① 不做迁移 —— 旧格式永远可读(openWithStoreRing 兼容), +// 代价是"两种格式永久共存",判据永不能要求"落盘都是新格式" +// ② 启动时全量迁移 —— 有停机风险(21G 数据目录的教训:迁移窗口越长越危险) +// ③ 惰性迁移 —— 读到旧的就地升级,零停机、零额外扫描 +// +// 选 ③。这是本仓已在用的手法(Codec.Rekey 同样是"用到了才升级")。 +// +// ## 为什么失败不报错 +// +// 迁移失败只影响"下次读时还要再解一次旧格式",不影响本次读取(明文已经 +// 在前一步拿到了)。为一处升级失败让整个回填失败,是把可恢复的降级变成 +// 不可恢复的中断。 +func (s *Store) migrateEntry(alias, sealed string, plain string) { + newSealed, err := s.sealWithRing(plain) + if err != nil { + return + } + if newSealed == sealed { + return + } + s.mu.Lock() + if e, ok := s.data[alias]; ok && e.Sealed == sealed { + e.Sealed = newSealed + s.mu.Unlock() + // 落盘失败也不报错:内存已升级,下次 Save 会带上。 + _ = s.saveLocked() + return + } + s.mu.Unlock() +} + // ---------------- 核心:回填(别名 → 明文,插件入向用) ---------------- // Fill 把文本里的 `@{alias}` 换回明文(路由到插件前用)。 @@ -528,14 +650,7 @@ func (s *Store) Fill(text string) (string, int, []string) { if strings.TrimSpace(text) == "" { return text, 0, nil } - cands := s.keysForDecrypt() - if len(cands) == 0 { - // 环为空时退回 keyFn(未用 New 构造的路径,如测试里 SetGlobal) - if k, err := s.key(); err == nil { - cands = [][]byte{k} - } - } - if len(cands) == 0 { + if len(s.keys) == 0 { return text, 0, nil } var used []string @@ -552,10 +667,14 @@ func (s *Store) Fill(text string) (string, int, []string) { if !ok { return m // 未知别名:原样保留 } - plain, err := openWithRing(cands, e.Sealed) + plain, needsMigrate, err := s.openWithStoreRing(e.Sealed) if err != nil { return m } + if needsMigrate { + // 惰性迁移:读到旧格式就地升级(失败静默,下次再试)。 + s.migrateEntry(alias, e.Sealed, plain) + } n++ used = append(used, alias) return plain