Files
HomeAgent/cmd/homed-key-rotate/main.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

139 lines
5.2 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.

// homed-key-rotate:主密钥轮换(E-1)。
//
// ## 为何必须有这个命令
//
// `security` 包里的 `RotateKey` / `RotateKeyFile` / `RekeySecrets` 只是**零件**。
// 没有一条可执行的路径把它们串起来,E-1 就只是库里的能力,而不是
// 运维真能做的事 —— 而轮换的价值恰恰在于「出事时能执行」。
//
// ## 做三件事,顺序不可换
//
// ① 重加密变量库(security/variables.json)→ 新钥
// ② 写 security.keyring(旧钥降为只读历史)
// ③ 重加密 config.db 里的密文
//
// ★ 为什么必须「先重加密、后写 keyring」:
// 反过来的话,进程在中间崩溃会留下「keyring 说当前是新钥,但数据还是旧钥密文」
// 的状态 —— 而旧钥已不在环里可自动恢复,等于数据全丢。
//
// ## 用法
//
// homed-key-rotate -data /var/lib/homeagent -new-key <64位hex>
// homed-key-rotate -data /var/lib/homeagent -gen # 生成一把新钥并打印
//
// 轮换后**必须重启 homed**(密钥在启动时装配)。
package main
import (
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"os"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
func main() {
dataDir := flag.String("data", "", "数据目录(config.db 所在处)")
newKeyHex := flag.String("new-key", "", "新主密钥(64 位 hex);与 -gen 二选一")
gen := flag.Bool("gen", false, "生成一把新主密钥并打印(不执行轮换)")
flag.Parse()
if *gen {
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
fail("生成密钥失败: %v", err)
}
fmt.Println(hex.EncodeToString(k))
return
}
if *dataDir == "" || *newKeyHex == "" {
fail("必须给 -data 与 -new-key(或 -gen 生成一个)")
}
// ① 先取当前钥(走与 homed 完全相同的优先级,避免拿错钥)
oldKey, src, err := security.LoadOrCreateKey(*dataDir)
if err != nil {
fail("读取当前主密钥失败: %v", err)
}
newKey, err := hex.DecodeString(*newKeyHex)
if err != nil || len(newKey) != 32 {
fail("-new-key 必须是 64 位 hex(32 字节)")
}
if string(newKey) == string(oldKey) {
fail("新钥与当前钥相同,无需轮换")
}
history, err := security.LoadKeyring(*dataDir, oldKey)
if err != nil {
fail("读取密钥环失败: %v", err)
}
fmt.Printf("当前密钥来源: %s(历史密钥 %d 把)\n", src, len(history))
// ② 重加密变量库
st := security.New(security.Options{
Path: filepath.Join(*dataDir, "security", "variables.json"),
KeyFn: func() ([]byte, error) { return oldKey, nil },
OldKeys: history,
Enabled: true,
})
if err := st.Load(); err != nil {
fail("加载变量库失败(不静默重建): %v", err)
}
n := st.Count()
if err := st.RotateKey(newKey); err != nil {
fail("变量库轮换失败(数据未改动): %v", err)
}
fmt.Printf("① 变量库已重加密(%d 条)\n", n)
// ③ 写 keyring(旧钥转为只读历史)。放在数据重加密之后 —— 见文件头说明。
if err := security.RotateKeyFile(*dataDir, oldKey, newKey); err != nil {
fail("写密钥环失败(变量库已是新钥,此时不可回退): %v", err)
}
fmt.Printf("② 密钥环已更新(%s)\n", filepath.Join(*dataDir, security.KeyringFileName))
// 当前钥来源若是开发/磁盘路径,必须同步替换 security.key;否则下次启动
// LoadOrCreateKey 仍读旧钥,而 keyring current 已是新钥,会按设计响亮拒绝启动。
// systemd credential/env 不能由此命令擅自改:必须由运维更新注入源。
if src == filepath.Join(*dataDir, security.KeyFileName) {
path := filepath.Join(*dataDir, security.KeyFileName)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(hex.EncodeToString(newKey)), 0o600); err != nil {
fail("更新磁盘主密钥失败: %v", err)
}
if err := os.Chmod(tmp, 0o600); err != nil {
fail("确权新主密钥失败: %v", err)
}
if err := os.Rename(tmp, path); err != nil {
fail("替换磁盘主密钥失败: %v", err)
}
fmt.Printf("③ 磁盘主密钥已替换(%s)\n", path)
} else {
fmt.Printf("③ 当前钥来自 %s:请先更新该注入源为新钥,再重启 homed\n", src)
}
// ★ 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 {
fail("config.db 重加密失败: %v", err)
}
fmt.Printf("③ config.db 已重加密 %d 条(失败 %d 条)\n", rekeyed, failed)
if failed > 0 {
fmt.Fprintf(os.Stderr, "⚠ 有 %d 条密文用新旧钥都解不开 —— 那说明它们来自更早的密钥,\n"+
" 请检查 security.keyring 的 history 是否完整(不要在此状态下删除旧钥)\n", failed)
}
fmt.Println("\n完成。请重启 homed 以使新密钥生效:systemctl restart homeagent.service")
}
func fail(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "错误: "+format+"\n", args...)
os.Exit(1)
}