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

346 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 security
// 域隔离的加密编解码器(供内核其它模块复用主密钥)。
//
// ## 为什么要域隔离
//
// 同一把主密钥会服务多类用途:变量别名的 HMAC、配置落盘的 AES、
// (未来可能的)审计日志签名……若它们**共用同一把密钥**,就会产生
// 跨域攻击面:一处的输出可能在另一处被当作合法输入。
//
// 所以每类用途用 **HKDF 风格的派生子密钥**:
//
// subKey = HMAC-SHA256(masterKey, "homeagent/<domain>/v1")
//
// 这样一把主密钥可以安全地派生出无限多个互不相关的子密钥,
// 而部署方只需要管一把密钥。
//
// ## 与 Alias() 的关系
//
// `Alias()` 是**单向**的(明文 → 别名,不可逆),用 HMAC 做哈希。
// `Codec` 是**双向**的(明文 ⇄ 密文),用 AES-GCM 做加密。
// 二者用途不同、密钥不同 —— 这就是域隔离的意义。
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"strings"
)
// EncPrefixV1 是密文的自描述前缀。
//
// 为何用前缀而不是加列/加表:**无需 schema 迁移**,且明文旧数据
// 天然被识别(没有前缀就是明文),逐条惰性加密即可。
// 版本号 `v1` 让将来换算法时可以两代并存(读到 v1 用旧算法解)。
const EncPrefixV1 = "enc:v1:"
// ErrBadCiphertext 表示密文格式非法或密钥不对。
var ErrBadCiphertext = errors.New("security: 密文无法解密(密钥不对或数据损坏)")
// Codec 是用派生密钥做对称加解密的编解码器。
//
// key 为 nil 时 **Enabled()==false**,所有 Seal 退化为「原样返回」。
// 这是刻意设计:编解码器未装配时不能崩,但调用方可以据此判断
// 「密钥不可用」并选择响亮失败。
type Codec struct {
key []byte
domain string
// oldKeys 是轮换前的派生子密钥,仅用于 Open。
oldKeys [][]byte
}
// NewCodec 由主密钥派生出某个用途的子密钥。
func NewCodec(masterKey []byte, domain string) (*Codec, error) {
if len(masterKey) != 32 {
return nil, fmt.Errorf("security: 主密钥必须是 32 字节,实际 %d", len(masterKey))
}
if domain == "" {
return nil, errors.New("security: codec 域不能为空(域隔离是安全要求)")
}
m := hmac.New(sha256.New, masterKey)
m.Write([]byte("homeagent/" + domain + "/v1"))
return &Codec{key: m.Sum(nil), domain: domain}, nil
}
// Enabled 报告编解码器是否可用。
func (c *Codec) Enabled() bool { return c != nil && len(c.key) == 32 }
// NewCodecWithOldKeys 构造支持平滑轮换的 codec。
//
// current 是新主密钥;oldMasters 是旧主密钥,**只用于解开旧密文**。
// 轮换时若不带旧钥,已落盘的密文会全部解不开(响亮失败但服务不可用)
// —— 那就是 E-1 要解决的问题。
func NewCodecWithOldKeys(current []byte, domain string, oldMasters [][]byte) (*Codec, error) {
if len(current) != 32 {
return nil, fmt.Errorf("security: 主密钥必须是 32 字节,实际 %d", len(current))
}
if domain == "" {
return nil, errors.New("security: codec 域不能为空(域隔离是安全要求)")
}
derive := func(master []byte) []byte {
m := hmac.New(sha256.New, master)
m.Write([]byte("homeagent/" + domain + "/v1"))
return m.Sum(nil)
}
c := &Codec{key: derive(current), domain: domain}
for _, old := range oldMasters {
if len(old) == 32 {
c.oldKeys = append(c.oldKeys, derive(old))
}
}
return c, nil
}
// Domain 返回用途域(便于诊断,比如日志里说清楚是哪一域的密文解不开)。
func (c *Codec) Domain() string {
if c == nil {
return ""
}
return c.domain
}
// Seal 加密并带上版本前缀。
//
// 未启用时**原样返回**(明文),这样调用方可以在未装配密钥的
// 环境(测试、本地开发)继续工作 —— 但生产必须装配(见 IsEncrypted)。
func (c *Codec) Seal(plain string) (string, error) {
if !c.Enabled() {
return plain, nil
}
// 已加密的不要再加一层(幂等)
if IsEncrypted(plain) {
return plain, nil
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
return EncPrefixV1 + encodeB64(sealed), nil
}
// Open 解密。非密文(无前缀)原样返回 —— 这是**存量明文兼容**的关键:
// 旧数据不需要一次性迁移就能继续可读。
func (c *Codec) Open(stored string) (string, error) {
if !IsEncrypted(stored) {
return stored, nil // 明文(旧数据或未启用)
}
if !c.Enabled() {
return "", fmt.Errorf("%w: 有密文但编解码器未启用(域 %s)",
ErrNoMasterKey, c.Domain())
}
raw, err := decodeB64(strings.TrimPrefix(stored, EncPrefixV1))
if err != nil {
return "", fmt.Errorf("%w: base64 解码失败", ErrBadCiphertext)
}
block, err := aes.NewCipher(c.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", fmt.Errorf("%w: 密文太短", ErrBadCiphertext)
}
// ★ 先试当前钥,再依次试历史钥(E-1 轮换过渡期)。
// 不能只试当前钥:轮换后旧条目仍是旧钥密文,只试新钥会让它们
// 全部变成“解不开”,而那正是要避免的服务不可用。
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
}
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())
}
// 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:` 开头(极小概率),
// 但那会导致重复解密失败而非静默错误,可接受。
func IsEncrypted(stored string) bool {
return strings.HasPrefix(stored, EncPrefixV1)
}
// ---------------- 配置落盘编解码器 ----------------
// ★ 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())同源同生命周期,没有任何第二条通道。
// NewConfigCodec 构造配置落盘用的编解码器(**不**注册任何全局状态)。
//
// 当前钥负责 Seal;旧钥仅参与 Open(E-1 平滑轮换)。
// 调用方拿到实例后负责注入(见 Runtime.SetCodec)。
func NewConfigCodec(masterKey []byte, oldKeys [][]byte) (*Codec, error) {
return NewCodecWithOldKeys(masterKey, configCodecDomain, oldKeys)
}
// configCodecDomain 是配置落盘加密的域。
//
// ★ 必须与变量库的域(storeCodecDomain)不同 —— 见那边的说明。
// 域在这里集中定义,避免"两处各写一个字符串"(拼错了不会报错,
// 只会让两个域意外重合,而那是安全倒退)。
const configCodecDomain = "config-at-rest"
// NeedsRekey 报告该密文是否**不是**当前主密钥加的(即轮换后尚未重加密)。
//
// 用途:轮换后需要知道哪些 config 项要重写;只需先试当前钥能不能开。
// 非密文(明文/旧数据)返回 false(那是 EncryptExistingSecrets 的事)。
func (c *Codec) NeedsRekey(stored string) bool {
if !c.Enabled() || !IsEncrypted(stored) {
return false
}
_, err := c.openWithSingleKey(c.key, stored)
return err != nil
}
// Rekey 把一条密文(可能是旧钥加的)用当前钥重新加密。
// 非密文原样返回;旧钥也解不开则**响亮报错**(不静默丢数据)。
func (c *Codec) Rekey(stored string) (string, error) {
if !c.Enabled() || !IsEncrypted(stored) {
return stored, nil
}
if _, err := c.openWithSingleKey(c.key, stored); err == nil {
return stored, nil // 已是当前钥,幂等
}
plain, err := c.Open(stored)
if err != nil {
return "", err
}
return c.Seal(plain)
}
// openWithSingleKey 只用指定的一把钥试解(供 NeedsRekey 判定)。
func (c *Codec) openWithSingleKey(key []byte, stored string) (string, error) {
raw, err := decodeB64(strings.TrimPrefix(stored, EncPrefixV1))
if err != nil {
return "", ErrBadCiphertext
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", ErrBadCiphertext
}
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
return "", ErrBadCiphertext
}
return string(plain), nil
}
// ★ 2026-11-04:`ClearConfigCodec` 已删除。
//
// 它清的是包级单例。现在"关闭加密"通过不注入(或注入 nil)表达:
//
// rt.SetCodec(nil) → config.codecOf() == nil
// cfgReg.SetCodec(nil)
//
// 比"清空一个全局变量"更明确:状态属于对象,不属于进程。