渠道上游 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 全部正常」,明文长度与前后缀保持一致。
116 lines
3.9 KiB
Go
116 lines
3.9 KiB
Go
// 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:]
|
||
}
|