Files
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

116 lines
3.9 KiB
Go
Raw Permalink 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"
)
var checkChannelsCmd = &cobra.Command{
Use: "check-channels",
Short: "诊断渠道上游密钥能否用当前主密钥解密",
Long: `诊断渠道上游密钥能否用当前主密钥(OT_MASTER_KEY)解密。
出现 "failed to decrypt channel key" 时先跑这个:它会把当前主密钥的派生路径和
指纹打出来,并逐个渠道试解密,从而区分两种情况:
1. 主密钥被换过/没加载到(常见:换了 OT_MASTER_KEY、从无 .env 变成有 .env、
密钥长度变化导致从「原始字节」切到「SHA-256 派生」)——需要换回原主密钥;
2. 主密钥对、但个别渠道密文被外部改写——该渠道重填上游 key 即可。
主密钥一旦丢失无法恢复(AES-GCM 没有后门),只能重新录入每个渠道的上游 key。
退出码:全部可解密为 0;存在无法解密的渠道为 1(便于脚本告警)。`,
Run: func(cmd *cobra.Command, args []string) {
runCheckChannels()
},
}
func init() {
rootCmd.AddCommand(checkChannelsCmd)
}
func runCheckChannels() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
enc := crypto.NewEncryptor(cfg.Master)
fmt.Printf("数据库: %s (%s)\n", cfg.DB.DSN, cfg.DB.Driver)
fmt.Printf("主密钥: %s\n", describeMaster(cfg.Master))
fmt.Printf("密钥指纹: %s(同指纹=同一把密钥;换过主密钥后这里会变)\n", enc.KeyFingerprint())
fmt.Printf("运行环境: OT_ENV=%s\n\n", cfg.Env)
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
}
okCount, failCount := 0, 0
for _, ch := range chs {
state := ""
if !ch.Enabled {
state = " [停用]"
}
plain, derr := enc.Decrypt(ch.APIKeyEnc)
switch {
case derr != nil:
failCount++
fmt.Printf(" [失败] #%-3d %-20s%s %v\n", ch.ID, ch.Name, state, derr)
case plain == "":
okCount++
fmt.Printf(" [空值] #%-3d %-20s%s 解密成功但内容为空\n", ch.ID, ch.Name, state)
default:
okCount++
fmt.Printf(" [正常] #%-3d %-20s%s 解密正常(key 长度 %d,前缀 %s)\n",
ch.ID, ch.Name, state, len(plain), maskKey(plain))
}
}
fmt.Printf("\n合计:%d 个渠道,%d 个可解密,%d 个失败\n", len(chs), okCount, failCount)
if failCount > 0 {
fmt.Print(`
诊断建议:
· 如果「全部」渠道都失败:几乎一定是主密钥不对。核对 OT_MASTER_KEY:
- 是否换了值,或者启动目录变了导致 .env 没被读到(.env 按当前工作目录读取);
- 主密钥长度是否变了——16/24/32 字节直接用原始字节,其他长度走 SHA-256 派生,
改长度等于换了一把密钥。
用原主密钥重启即可恢复;原值丢失则只能重新录入各渠道上游 key(AES-GCM 无法逆向)。
· 如果只有「个别」渠道失败:这些渠道的密文被改过或从别的库导入,
在管理后台重新填一次上游 key 即可。
`)
os.Exit(1)
}
}
func describeMaster(master string) string {
if master == "" {
return "未设置!用的是 sha256(\"\") 派生(公开可还原,生产必须配置 OT_MASTER_KEY)"
}
enc := crypto.NewEncryptor(master)
return fmt.Sprintf("已设置,%d 字节,%s", len(master), enc.KeyMode())
}
// maskKey 只露上游 key 的首尾各 4 个字符(排障时确认是不是填错了 key)。
func maskKey(s string) string {
if len(s) <= 8 {
return "****"
}
return s[:4] + "…" + s[len(s)-4:]
}