Files
openteam/server/internal/cli/rotate_master_key.go
T
Sakurasan 5d6e5bdb04 feat: 主密钥诊断与迁移命令;空/占位主密钥启动警告
渠道上游 key 用 OT_MASTER_KEY 经 AES-GCM 加密落库,主密钥一旦变化,全部渠道在运行时
报 "failed to decrypt channel key"。最常见的成因是历史实例先用了空主密钥(退化为
sha256("")),后来才补上 OT_MASTER_KEY——旧密文从此解不开,看起来像渠道全挂了。

- 新增 `check-channels`:打印主密钥派生路径与指纹(SHA-256 前 4 字节,不泄露密钥)
  + 逐渠道解密结果,区分「全部失败=主密钥不对」与「个别失败=该渠道密文异常」;
  存在失败时退出码为 1,便于脚本告警
- 新增 `rotate-master-key`:旧密钥解密、新密钥重新加密,全部渠道单事务更新,
  任一渠道解不开就整体回滚(--force 可跳过),避免库里新旧密钥混杂;
  写入前对新密文做回读校验;--dry-run 只验证不改库。空主密钥作为默认 --old,
  正是「先空后补」的历史路径;配套迁移无需重新录入任何渠道 key
- crypto 新增 KeyFingerprint / KeyMode:指纹用于比对两个实例是否同一把密钥,
  KeyMode 显式暴露「16/24/32 字节用原始字节、其他长度走 SHA-256 派生」这条
  容易踩的长度陷阱(改长度等于换密钥)
- 启动警告:开发环境不拦空/占位主密钥,但明确提示它等于公开密钥,并给出
  rotate-master-key 指引;生产环境仍由 config.validate 直接拒绝启动
- README 补主密钥与渠道密钥运维小节
- 测试:换密钥必解密失败、长度切换 32↔33 字节必失败、指纹一致性

验证:go vet、go test ./... 全绿;端到端复现「空主密钥建渠道 → 配新主密钥全部解密失败
→ rotate-master-key 迁移 → check-channels 全部正常」,明文长度与前后缀保持一致。
2026-11-08 23:12:17 +08:00

178 lines
6.0 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 cli 命令行入口。
package cli
import (
"fmt"
"log"
"os"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var (
rotateOldKey string
rotateNewKey string
rotateDryRun bool
rotateForce bool
)
var rotateMasterKeyCmd = &cobra.Command{
Use: "rotate-master-key",
Short: "用旧主密钥解密渠道密钥、再用新主密钥重新加密(主密钥迁移)",
Long: `主密钥迁移:把渠道上游 key 从旧主密钥改为用新主密钥加密。
典型场景:实例最初没有配置 OT_MASTER_KEY(用的是 sha256("") 派生的空密钥加密),
后来补上了 OT_MASTER_KEY,于是所有渠道在运行时报 "failed to decrypt channel key"。
运行本命令后重启服务即可恢复,不需要重新录入各渠道的上游 key。
用法:
# 旧密钥为空(历史默认 sha256("")),新密钥取当前配置的 OT_MASTER_KEY
go run ./cmd/server rotate-master-key
# 显式指定旧密钥
go run ./cmd/server rotate-master-key --old "旧主密钥"
# 只验证不改库
go run ./cmd/server rotate-master-key --dry-run
新主密钥默认用当前环境/配置里的 OT_MASTER_KEY(必须非空)。全部渠道在同一个事务里
更新:任一渠道用旧密钥解不开就整体回滚(除非 --force 跳过这些渠道),避免库里
出现新旧密钥混杂、下次还得再猜一遍的状态。
注意:命令成功只改了库,跑完必须用新主密钥重启服务!`,
Run: func(cmd *cobra.Command, args []string) {
runRotateMasterKey()
},
}
func init() {
rootCmd.AddCommand(rotateMasterKeyCmd)
rotateMasterKeyCmd.Flags().StringVar(&rotateOldKey, "old", "", "旧主密钥(留空表示历史默认的空密钥,即 sha256(\"\"))")
rotateMasterKeyCmd.Flags().StringVar(&rotateNewKey, "new", "", "新主密钥(留空则用配置里的 OT_MASTER_KEY)")
rotateMasterKeyCmd.Flags().BoolVar(&rotateDryRun, "dry-run", false, "只验证能否用旧密钥解密,不改库")
rotateMasterKeyCmd.Flags().BoolVar(&rotateForce, "force", false, "跳过旧密钥解不开的渠道(默认整体回滚)")
}
func runRotateMasterKey() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
newMaster := rotateNewKey
if newMaster == "" {
newMaster = cfg.Master
}
if newMaster == "" {
log.Fatal("新主密钥为空:请用 --new 指定,或先配置 OT_MASTER_KEY")
}
oldEnc := crypto.NewEncryptor(rotateOldKey)
newEnc := crypto.NewEncryptor(newMaster)
if oldEnc.KeyFingerprint() == newEnc.KeyFingerprint() {
fmt.Println("旧密钥与新密钥指纹相同(同一把密钥),无需迁移。")
return
}
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
fmt.Printf("数据库: %s (%s)\n", cfg.DB.DSN, cfg.DB.Driver)
fmt.Printf("旧密钥: %s,指纹 %s\n", describeOldKey(rotateOldKey), oldEnc.KeyFingerprint())
fmt.Printf("新密钥: %s,指纹 %s\n", describeMaster(newMaster), newEnc.KeyFingerprint())
if rotateDryRun {
fmt.Println("模式: dry-run(只验证,不改库)")
}
fmt.Println()
var chs []store.Channel
if err := db.Order("id").Find(&chs).Error; err != nil {
log.Fatalf("读取渠道失败: %v", err)
}
if len(chs) == 0 {
fmt.Println("没有渠道,无需迁移。")
return
}
type pending struct {
id uint64
name string
newEncV string
}
var todo []pending
var failed []string
for _, ch := range chs {
plain, derr := oldEnc.Decrypt(ch.APIKeyEnc)
if derr != nil {
failed = append(failed, fmt.Sprintf("#%d %s: %v", ch.ID, ch.Name, derr))
continue
}
re, eerr := newEnc.Encrypt(plain)
if eerr != nil {
failed = append(failed, fmt.Sprintf("#%d %s: 重新加密失败: %v", ch.ID, ch.Name, eerr))
continue
}
// 回读校验:写进库之前确认新密文能被新密钥解开,避免把渠道写成谁也解不开的状态
if back, berr := newEnc.Decrypt(re); berr != nil || back != plain {
failed = append(failed, fmt.Sprintf("#%d %s: 新密文回读校验失败", ch.ID, ch.Name))
continue
}
todo = append(todo, pending{id: ch.ID, name: ch.Name, newEncV: re})
}
for _, f := range failed {
fmt.Printf(" [失败] %s\n", f)
}
if len(todo) > 0 {
fmt.Printf(" [就绪] %d 个渠道可用旧密钥解密并已用新密钥重新加密\n", len(todo))
}
if len(failed) > 0 && !rotateForce {
fmt.Printf("\n%d 个渠道无法用旧密钥解密,已整体回滚(未改动任何数据)。\n", len(failed))
fmt.Println("如果这些渠道本就该用另一把密钥,可用 --force 跳过它们只迁移其余渠道;")
fmt.Println("如果全部失败,说明 --old 猜错了:用 check-channels 的指纹逐个核对候选旧密钥。")
os.Exit(1)
}
if len(todo) == 0 {
fmt.Println("\n没有可迁移的渠道。")
return
}
if rotateDryRun {
fmt.Printf("\ndry-run:以上 %d 个渠道迁移可行,未改动数据库。\n", len(todo))
return
}
if err := db.Transaction(func(tx *gorm.DB) error {
for _, p := range todo {
if err := tx.Model(&store.Channel{}).Where("id = ?", p.id).
Update("api_key_enc", p.newEncV).Error; err != nil {
return fmt.Errorf("渠道 #%d %s: %w", p.id, p.name, err)
}
}
return nil
}); err != nil {
log.Fatalf("写入失败,已回滚: %v", err)
}
fmt.Printf("\n迁移完成:%d 个渠道已改用新主密钥加密。\n", len(todo))
fmt.Println("现在用新主密钥重启服务,然后运行 check-channels 确认全部「正常」。")
if len(failed) > 0 {
fmt.Printf("仍有 %d 个渠道未迁移(用 --force 跳过的),需要在管理后台重填上游 key。\n", len(failed))
}
}
// describeOldKey 说明旧密钥形态;空值单独提示,因为那正是历史默认行为。
func describeOldKey(master string) string {
if master == "" {
return "空(历史默认:sha256(\"\") 派生)"
}
return describeMaster(master)
}