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
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 指纹"
|
|
}
|
|
|