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