Files
hfb_sys/backend/pkg/crypto/aes.go
T
yml 8fef037be3 AES 字段加密迁配置并兼容旧密文
移除字段加密硬编码主密钥,改为 FIELD_ENCRYPTION_KEY 注入。

保留 FIELD_ENCRYPTION_LEGACY_KEY 透明回退旧密文,新写入统一使用主密钥;生产环境校验主密钥和 legacy 密钥长度、占位符及相等关系,并统一生产环境判断口径。

补充配置与旧密文兼容回归测试。
2026-06-14 17:53:24 +08:00

133 lines
3.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
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
// Encryptor 字段加密器接口。Encrypt 永远用主密钥,Decrypt 先试主密钥、失败试 legacy 密钥。
type Encryptor interface {
Encrypt(plaintext string) (string, error)
Decrypt(ciphertext string) (string, error)
}
// aesKey 持有一个 AES-GCM 密钥及其 cipher。
type aesKey struct {
key []byte
}
func newAESKey(key string) (*aesKey, error) {
keyBytes := []byte(key)
if len(keyBytes) != 16 && len(keyBytes) != 24 && len(keyBytes) != 32 {
return nil, errors.New("invalid key length: must be 16, 24, or 32 bytes")
}
return &aesKey{key: keyBytes}, nil
}
// FieldEncryptor 持有主密钥 + 可选 legacy 密钥列表。
// Encrypt 永远用 primaryDecrypt 先试 primarygcm.Open 认证失败依次试 legacy
// 用于密钥轮换期间透明解出旧密文。
type FieldEncryptor struct {
primary *aesKey
legacy []*aesKey
}
// NewFieldEncryptor 创建字段加密器。primary 为主密钥,legacy 为可选的旧密钥(用于回退解密)。
func NewFieldEncryptor(primary string, legacy ...string) (*FieldEncryptor, error) {
primaryKey, err := newAESKey(primary)
if err != nil {
return nil, err
}
legacyKeys := make([]*aesKey, 0, len(legacy))
for _, l := range legacy {
if l == "" || l == primary {
continue
}
k, err := newAESKey(l)
if err != nil {
return nil, err
}
legacyKeys = append(legacyKeys, k)
}
return &FieldEncryptor{primary: primaryKey, legacy: legacyKeys}, nil
}
// Encrypt 使用 AES-GCM 加密明文,输出 base64(nonce || ciphertext || gcmTag)。
func (e *FieldEncryptor) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
gcm, err := e.gcm(e.primary)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt 解密密文。先试主密钥,gcm.Open 认证失败依次试 legacy 密钥。
func (e *FieldEncryptor) Decrypt(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
decoded, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
for _, k := range append([]*aesKey{e.primary}, e.legacy...) {
plaintext, err := decryptWithKey(k, decoded)
if err == nil {
return plaintext, nil
}
}
return "", errors.New("decrypt failed: no matching key")
}
func (e *FieldEncryptor) gcm(k *aesKey) (cipher.AEAD, error) {
block, err := aes.NewCipher(k.key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func decryptWithKey(k *aesKey, decoded []byte) (string, error) {
block, err := aes.NewCipher(k.key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(decoded) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, body := decoded[:nonceSize], decoded[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, body, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// MockEncryptor 测试用直通加密器,明文不加密。
type MockEncryptor struct{}
func (e *MockEncryptor) Encrypt(plaintext string) (string, error) {
return plaintext, nil
}
func (e *MockEncryptor) Decrypt(ciphertext string) (string, error) {
return ciphertext, nil
}