mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
## 用户裁定
> 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 测试全绿
## 数据风险
加密实现切换有丢数据风险,因此:
· 先写往返判据,再改实现
· 惰性迁移(读到才升级),不做启动全量重写
· 旧格式与旧密钥环**都保留**读取路径
139 lines
5.2 KiB
Go
139 lines
5.2 KiB
Go
// 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)
|
||
}
|