You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

156 lines
6.5 KiB

package comm
import (
"crypto/aes"
"crypto/cipher"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"strings"
)
// AES-256-GCM 字段加密:console 后台与业务服务共用的单一实现。
//
// 「应用的服务配置」(app_service_config) 由 console 前端写、业务服务启动时读/回写,两端读写同一张加密表,
// 故加密算法必须完全一致——统一收敛到本文件(console 的 svcEncrypt/svcDecrypt、业务侧 seed 均委托这里),
// 避免各处复制导致密文格式漂移。密钥由 ${FIELD_ENCRYPT_KEY} 注入,console 与业务服务必须配同一值。
// EncMask 加密字段返回前端时的脱敏占位符;更新时前端回传该占位/空串则保留原密文。
const EncMask = "[encrypted]"
// normAESKey 把任意长度的密钥规整为 32 字节(AES-256):不足补零、超长截断。
func normAESKey(key string) []byte {
k := make([]byte, 32)
copy(k, []byte(key))
return k
}
// Encrypt 用 AES-256-GCM 加密明文,返回 base64(nonce‖ciphertext)。空明文返回空串。
func Encrypt(key, plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(normAESKey(key))
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(cryptorand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ct), nil
}
// Decrypt 解密 Encrypt 产出的密文,返回明文。
func Decrypt(key, ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
raw, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", fmt.Errorf("base64 解码失败: %w", err)
}
block, err := aes.NewCipher(normAESKey(key))
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
ns := gcm.NonceSize()
if len(raw) < ns {
return "", fmt.Errorf("密文过短")
}
plain, err := gcm.Open(nil, raw[:ns], raw[ns:], nil)
if err != nil {
return "", fmt.Errorf("GCM 解密失败: %w", err)
}
return string(plain), nil
}
// ── 密钥可观测性 ────────────────────────────────────────────────────────────
// 两端 key 不一致时,报错只能说"解不开",说不出"谁跟谁不一致",排查全靠人肉试。
// 下面两个函数让 key 在日志里可对比、可诊断,但**绝不打印密钥或明文本身**:
// FIELD_ENCRYPT_KEY 能解开库里所有密钥字段(AppSecret / 商户私钥 / API Key),
// 而日志会落盘到挂载出来的 log/ 并可能被采集走。
// fpDomain 指纹的域分隔前缀:掺进哈希,使指纹无法直接拿去撞公开的 sha256 彩虹表。
const fpDomain = "yunyan-field-key-fingerprint\x00"
// KeyFingerprint 返回可安全打日志的密钥指纹,供两端比对(指纹相同 ⟺ 实际 AES 密钥相同)。
//
// 故意对 normAESKey **规整后**的 32 字节取哈希,而非原始字符串:超过 32 字节的部分会被丢弃,
// 前 32 字节相同的两把 key 其实就是同一把,指纹必须体现这一点。
// 顺带标注三种最常见的配置事故——未注入、超长截断、尾部残留 CR(.env 存成了 CRLF)。
func KeyFingerprint(key string) string {
sum := sha256.Sum256(append([]byte(fpDomain), normAESKey(key)...))
fp := fmt.Sprintf("sha256:%x", sum[:6])
notes := make([]string, 0, 2)
switch {
case key == "":
notes = append(notes, "!!未配置 FIELD_ENCRYPT_KEY,正在使用全零密钥")
case len(key) > 32:
notes = append(notes, fmt.Sprintf("超 32 字节,第 33 字节起被丢弃(原长 %d)", len(key)))
case len(key) < 32:
notes = append(notes, fmt.Sprintf("不足 32 字节,已右侧补零(原长 %d)", len(key)))
}
if key != strings.TrimRight(key, " \t\r\n") {
notes = append(notes, "尾部含空白/CR(检查 .env 是否 CRLF 换行)")
}
if len(notes) > 0 {
fp += " [" + strings.Join(notes, "; ") + "]"
}
return fp
}
// legacyKeys 历史上可能加密过存量数据的密钥,按可能性排序。
// 空 key 排第一:FIELD_ENCRYPT_KEY 进 env 模板之前,业务服务一路用 os.Getenv 拿到的空串,
// 经 normAESKey 补零成全零密钥,静默地把配置 seed 进了库。
var legacyKeys = []struct{ desc, key string }{
{"空 key(FIELD_ENCRYPT_KEY 未注入时补零出的全零密钥)", ""},
{"console 内置兜底默认值(modules/console/options.go)", "console-default-encrypt-key-xxxxx!"},
{"console 模板 local.env 的示例值", "console-default-encrypt-key-change!"},
}
// DiagnoseDecryptFailure 解密失败时,拿几把「历史上可能加密过这条数据」的 key 逐一试解,
// 判定密文究竟是被谁加密的,把结论直接写进日志——省掉一轮人肉排查。
//
// 只判定成败,**绝不返回也绝不记录解出的明文**。返回空串表示密文为空、无需诊断。
func DiagnoseDecryptFailure(encKey, ciphertext string) string {
if ciphertext == "" {
return ""
}
// ① 先试当前 key 的"脏变体":尾部混入 CR/空白(CRLF 的 .env),或值被连引号一起读进来。
for _, v := range []struct{ desc, key string }{
{"当前 key 去掉尾部空白/CR 后", strings.TrimRight(encKey, " \t\r\n")},
{"当前 key 去掉首尾引号后", strings.Trim(encKey, `"'`)},
} {
if v.key == encKey {
continue
}
if _, err := Decrypt(v.key, ciphertext); err == nil {
return fmt.Sprintf("【诊断】密文可被「%s」解开 —— 本服务的 FIELD_ENCRYPT_KEY 值里混进了多余字符,清理 .env 后重启即可", v.desc)
}
}
// ② 再试历史默认 key:命中说明这行是「配上 FIELD_ENCRYPT_KEY 之前」写进库的存量数据,
// env 本身没配错,重新保存一次让它用当前 key 重新加密即可。
for _, c := range legacyKeys {
if c.key == encKey {
continue
}
if _, err := Decrypt(c.key, ciphertext); err == nil {
return fmt.Sprintf("【诊断】密文是用「%s」(指纹 %s)加密的存量数据,与本服务当前 key 不是同一把 —— "+
"到 console 后台把该字段重新填一遍保存,即可用当前 key 重新加密落库", c.desc, KeyFingerprint(c.key))
}
}
return "【诊断】已知的几把历史 key 都解不开,密文由一把未知的 key 加密 —— 请比对 console 侧启动日志里的 key 指纹"
}