Files
openteam/server/internal/pkg/crypto/crypto.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

151 lines
4.5 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 crypto 密码哈希(argon2id)与对称加密(AES-GCM)。
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// PasswordHasher argon2id 参数(来自配置)。
type PasswordHasher struct {
Time uint32
Memory uint32
Threads uint8
KeyLen uint32
SaltLen int
}
func NewPasswordHasher(time, memory uint32, threads uint8, keyLen uint32, saltLen int) *PasswordHasher {
return &PasswordHasher{Time: time, Memory: memory, Threads: threads, KeyLen: keyLen, SaltLen: saltLen}
}
// HashPassword 编码为 $argon2id$v=19$m=...,t=...,p=...$salt$hash
func (h *PasswordHasher) HashPassword(password string) (string, error) {
salt := make([]byte, h.SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, h.Time, h.Memory, h.Threads, h.KeyLen)
enc := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
h.Memory, h.Time, h.Threads, enc.EncodeToString(salt), enc.EncodeToString(key)), nil
}
// VerifyPassword 校验密码,常数时间比较。
func (h *PasswordHasher) VerifyPassword(encoded, password string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("invalid hash format")
}
var memory, time uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false, err
}
enc := base64.RawStdEncoding
salt, err := enc.DecodeString(parts[4])
if err != nil {
return false, err
}
want, err := enc.DecodeString(parts[5])
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// ---------------------------------------------------------------------------
// AES-GCM 渠道密钥加密
// Encryptor 用主密钥加解密渠道上游 key。
type Encryptor struct {
key []byte
mode string // 派生路径说明(排障用)
}
// NewEncryptor 主密钥必须为 16/24/32 字节;不足时用 SHA-256 派生固定 32 字节。
// 长度陷阱:16/24/32 字节直接用原始字节,其他长度走 SHA-256 派生——把主密钥改成
// 另一个长度(或从 32 字节改成 33 字符)就切换到另一条路径,等价于换了一把密钥,
// 历史密文会全部解不开。
func NewEncryptor(master string) *Encryptor {
key := []byte(master)
mode := ""
switch len(key) {
case 16:
mode = "raw-16(AES-128,直接用主密钥原始字节)"
case 24:
mode = "raw-24(AES-192,直接用主密钥原始字节)"
case 32:
mode = "raw-32(AES-256,直接用主密钥原始字节)"
default:
key = sha256Sum(master)
mode = fmt.Sprintf("derived-32(主密钥 %d 字节,经 SHA-256 派生)", len(master))
}
return &Encryptor{key: key, mode: mode}
}
// KeyMode 说明这把主密钥走的是哪条派生路径(排障用,不泄露密钥)。
func (e *Encryptor) KeyMode() string { return e.mode }
// KeyFingerprint 派生后 AES 密钥的指纹(SHA-256 前 4 字节 hex)。
// 只用于确认「两个实例是不是同一把主密钥」:指纹相同必然能互相解密,
// 不同必然解不开,且不泄露密钥本身。
func (e *Encryptor) KeyFingerprint() string {
sum := sha256.Sum256(e.key)
return hex.EncodeToString(sum[:4])
}
// Encrypt 输出 base64(nonce || ciphertext)
func (e *Encryptor) Encrypt(plain string) (string, error) {
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := gcm.Seal(nil, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(append(nonce, ct...)), nil
}
// Decrypt 解析 Encrypt 的输出。
func (e *Encryptor) Decrypt(enc string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
block, err := aes.NewCipher(e.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plain), nil
}