Files
HomeAgent/internal/security/codec_unify_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

217 lines
7.5 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 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
}