- crypto.Service 增 fullBody 开关 + DecryptRequest/EncryptResponse(会话密钥双向加密) - 新增 middleware/APICrypto:请求整体解密 + 响应加密(public-key 豁免),未加密请求拒绝 - ResolveLogin/resolvePassword 增 fullBody 分支;admin 组中间件链调整 - injectEnv 支持 ENCRYPT_FULL_BODY/ENCRYPT_ALLOW_PLAIN(直接跑二进制需 env 注入)
277 lines
9.9 KiB
Go
277 lines
9.9 KiB
Go
// Package crypto 提供登录密码「RSA + AES-GCM」混合加密服务。
|
||
//
|
||
// 设计目标:对称加密(AES-GCM)负责加密实际载荷,非对称加密(RSA-OAEP)
|
||
// 负责加密 AES 会话密钥,两者结合兼顾性能与密钥分发安全:
|
||
// - 前端:随机生成 AES-256 密钥 → AES-GCM 加密 {username,password,ts} → RSA 公钥加密 AES 密钥
|
||
// - 后端:持有 RSA 私钥 → 解出 AES 密钥 → 解出明文载荷(含时间戳防重放)
|
||
//
|
||
// 密钥来源优先级:配置 encrypt.privateKey(PEM)→ 本地文件 data/crypto/rsa_private.pem → 自动生成并持久化。
|
||
package crypto
|
||
|
||
import (
|
||
"context"
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"crypto/rsa"
|
||
"crypto/sha256"
|
||
"crypto/x509"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"encoding/pem"
|
||
"os"
|
||
"path/filepath"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gfile"
|
||
"github.com/gogf/gf/v2/text/gstr"
|
||
)
|
||
|
||
const (
|
||
// rsaKeyBits RSA 密钥位数(2048 可加密 256 位 AES 密钥)。
|
||
rsaKeyBits = 2048
|
||
// aesNonceSize AES-GCM 推荐随机数长度(字节)。
|
||
aesNonceSize = 12
|
||
// payloadTTL 明文载荷时间戳容忍窗口:超出即视为重放/过期。
|
||
payloadTTL = 5 * time.Minute
|
||
// keyFilePath 自动生成密钥的持久化路径(相对工作目录)。
|
||
keyFilePath = "data/crypto/rsa_private.pem"
|
||
)
|
||
|
||
// LoginPayload 是前端混合加密后的明文载荷结构。
|
||
type LoginPayload struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
// TS 毫秒时间戳,用于防重放校验。
|
||
TS int64 `json:"ts"`
|
||
}
|
||
|
||
// FieldPayload 是通用加密字段载荷(创建管理员/重置密码等场景复用)。
|
||
type FieldPayload struct {
|
||
Value string `json:"value"`
|
||
// TS 毫秒时间戳,用于防重放校验。
|
||
TS int64 `json:"ts"`
|
||
}
|
||
|
||
// Service 提供登录密码混合加密能力。
|
||
type Service struct {
|
||
privKey *rsa.PrivateKey
|
||
// allowPlain 允许明文密码登录,仅限开发环境联调(生产必须关闭)。
|
||
allowPlain bool
|
||
// fullBody 全量请求/响应加密开关(生产开启):所有 JSON body 整体加密,
|
||
// 响应用请求会话密钥(AES)加密回传;开发环境仅加密密码字段。
|
||
fullBody bool
|
||
}
|
||
|
||
// defaultSvc 进程级默认实例,供各业务模块复用(cmd 启动时经 New 注册)。
|
||
var defaultSvc *Service
|
||
|
||
// SetDefault 注册进程级默认加密服务实例。
|
||
func SetDefault(s *Service) { defaultSvc = s }
|
||
|
||
// Get 返回进程级默认加密服务实例,未初始化时 panic(启动必经 New 注册)。
|
||
func Get() *Service {
|
||
if defaultSvc == nil {
|
||
panic("crypto service not registered")
|
||
}
|
||
return defaultSvc
|
||
}
|
||
|
||
// New 加载或生成 RSA 密钥对并构建加密服务。
|
||
// 私钥来源:encrypt.privateKey 配置 > data/crypto/rsa_private.pem 文件 > 自动生成并落盘。
|
||
func New(ctx context.Context) (*Service, error) {
|
||
s := &Service{
|
||
allowPlain: g.Cfg().MustGet(ctx, "encrypt.allowPlain", false).Bool(),
|
||
fullBody: g.Cfg().MustGet(ctx, "encrypt.fullBody", false).Bool(),
|
||
}
|
||
// 配置项可能是未注入的 ${ENV} 占位符,按未配置处理,走文件/自动生成。
|
||
privPEM := g.Cfg().MustGet(ctx, "encrypt.privateKey", "").String()
|
||
if gstr.Contains(privPEM, "${") {
|
||
privPEM = ""
|
||
}
|
||
if privPEM != "" {
|
||
key, err := parsePrivateKeyPEM(privPEM)
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "解析 encrypt.privateKey 失败")
|
||
}
|
||
s.privKey = key
|
||
SetDefault(s)
|
||
return s, nil
|
||
}
|
||
// 尝试读取本地持久化密钥文件。
|
||
if gfile.Exists(keyFilePath) {
|
||
if b, err := os.ReadFile(keyFilePath); err == nil {
|
||
if key, perr := parsePrivateKeyPEM(string(b)); perr == nil {
|
||
s.privKey = key
|
||
SetDefault(s)
|
||
return s, nil
|
||
}
|
||
}
|
||
}
|
||
// 自动生成并尝试持久化(目录不可写时降级为内存持有,重启后公钥变化,前端每次登录重新拉取不受影响)。
|
||
key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits)
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "生成 RSA 密钥对失败")
|
||
}
|
||
s.privKey = key
|
||
if err = os.MkdirAll(filepath.Dir(keyFilePath), 0o700); err == nil {
|
||
_ = os.WriteFile(keyFilePath, []byte(encodePrivateKeyPEM(key)), 0o600)
|
||
}
|
||
SetDefault(s)
|
||
return s, nil
|
||
}
|
||
|
||
// AllowPlain 返回是否允许明文密码登录(仅开发联调)。
|
||
func (s *Service) AllowPlain() bool { return s.allowPlain }
|
||
|
||
// FullBody 返回是否启用全量请求/响应加密(生产开启)。
|
||
func (s *Service) FullBody() bool { return s.fullBody }
|
||
|
||
// PublicKey 返回 PEM 格式 RSA 公钥(SubjectPublicKeyInfo / SPKI),
|
||
// 供前端 Web Crypto importKey('spki') 做混合加密。
|
||
func (s *Service) PublicKey() string {
|
||
// MarshalPKIXPublicKey 生成 SPKI(PKIX/SubjectPublicKeyInfo)DER,前端按 "PUBLIC KEY" PEM 解析。
|
||
der, err := x509.MarshalPKIXPublicKey(&s.privKey.PublicKey)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))
|
||
}
|
||
|
||
// DecryptLogin 混合解密前端登录载荷:
|
||
// 1) RSA-OAEP(SHA-256) 私钥解出 AES 密钥;2) AES-256-GCM 解出明文 JSON;
|
||
// 3) 校验时间戳窗口(防重放)与必填字段。
|
||
func (s *Service) DecryptLogin(_ context.Context, encryptedKeyB64, encryptedDataB64 string) (*LoginPayload, error) {
|
||
_, plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var p LoginPayload
|
||
if err = json.Unmarshal(plain, &p); err != nil {
|
||
return nil, gerror.Wrap(err, "解析载荷失败")
|
||
}
|
||
now := time.Now()
|
||
if p.TS == 0 || p.Username == "" || p.Password == "" {
|
||
return nil, gerror.New("载荷字段缺失")
|
||
}
|
||
if now.Sub(time.UnixMilli(p.TS)) > payloadTTL || time.UnixMilli(p.TS).Sub(now) > payloadTTL {
|
||
return nil, gerror.New("载荷已过期,请重试")
|
||
}
|
||
return &p, nil
|
||
}
|
||
|
||
// DecryptField 混合解密通用加密字段(创建管理员/重置密码等场景):
|
||
// 载荷 JSON 结构为 {"value":"...","ts":...},解密失败或字段缺失返回错误。
|
||
func (s *Service) DecryptField(_ context.Context, encryptedKeyB64, encryptedDataB64 string) (string, error) {
|
||
_, plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var p FieldPayload
|
||
if err = json.Unmarshal(plain, &p); err != nil {
|
||
return "", gerror.Wrap(err, "解析载荷失败")
|
||
}
|
||
now := time.Now()
|
||
if p.TS == 0 || p.Value == "" {
|
||
return "", gerror.New("载荷字段缺失")
|
||
}
|
||
if now.Sub(time.UnixMilli(p.TS)) > payloadTTL || time.UnixMilli(p.TS).Sub(now) > payloadTTL {
|
||
return "", gerror.New("载荷已过期,请重试")
|
||
}
|
||
return p.Value, nil
|
||
}
|
||
|
||
// DecryptRequest 全量加密请求解密:返回 AES 会话密钥(base64)与明文 body。
|
||
// 会话密钥由中间件暂存,供响应加密回传(前端持同一密钥解密响应)。
|
||
func (s *Service) DecryptRequest(encryptedKeyB64, encryptedDataB64 string) (aesKeyB64 string, plain []byte, err error) {
|
||
aesKey, plain, err := s.decryptPayload(encryptedKeyB64, encryptedDataB64)
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
return base64.StdEncoding.EncodeToString(aesKey), plain, nil
|
||
}
|
||
|
||
// EncryptResponse 用请求会话密钥(AES-256-GCM)加密响应 JSON:
|
||
// 返回 base64(nonce(12B) || ciphertext || tag),前端用同一会话密钥解密。
|
||
func (s *Service) EncryptResponse(aesKeyB64 string, plain []byte) (string, error) {
|
||
aesKey, err := base64.StdEncoding.DecodeString(aesKeyB64)
|
||
if err != nil {
|
||
return "", gerror.New("会话密钥格式错误")
|
||
}
|
||
block, err := aes.NewCipher(aesKey)
|
||
if err != nil {
|
||
return "", gerror.Wrap(err, "初始化 AES 失败")
|
||
}
|
||
gcm, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return "", gerror.Wrap(err, "初始化 AES-GCM 失败")
|
||
}
|
||
nonce := make([]byte, aesNonceSize)
|
||
if _, err = rand.Read(nonce); err != nil {
|
||
return "", gerror.Wrap(err, "生成随机数失败")
|
||
}
|
||
sealed := gcm.Seal(nil, nonce, plain, nil)
|
||
return base64.StdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||
}
|
||
|
||
// decryptPayload 混合解密公共实现:RSA-OAEP 解 AES 密钥 → AES-GCM 解明文。
|
||
// 密文结构:encryptedData = base64(nonce(12B) || ciphertext || tag)。
|
||
func (s *Service) decryptPayload(encryptedKeyB64, encryptedDataB64 string) ([]byte, []byte, error) {
|
||
encKey, err := base64.StdEncoding.DecodeString(encryptedKeyB64)
|
||
if err != nil {
|
||
return nil, nil, gerror.New("加密密钥格式错误")
|
||
}
|
||
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, s.privKey, encKey, nil)
|
||
if err != nil {
|
||
return nil, nil, gerror.New("解密 AES 会话密钥失败")
|
||
}
|
||
block, err := aes.NewCipher(aesKey)
|
||
if err != nil {
|
||
return nil, nil, gerror.Wrap(err, "初始化 AES 失败")
|
||
}
|
||
gcm, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return nil, nil, gerror.Wrap(err, "初始化 AES-GCM 失败")
|
||
}
|
||
data, err := base64.StdEncoding.DecodeString(encryptedDataB64)
|
||
if err != nil {
|
||
return nil, nil, gerror.New("密文格式错误")
|
||
}
|
||
if len(data) <= aesNonceSize {
|
||
return nil, nil, gerror.New("密文长度非法")
|
||
}
|
||
// 密文结构:nonce(12B) || ciphertext,GCM 自带完整性校验。
|
||
plain, err := gcm.Open(nil, data[:aesNonceSize], data[aesNonceSize:], nil)
|
||
if err != nil {
|
||
return nil, nil, gerror.New("解密载荷失败")
|
||
}
|
||
return aesKey, plain, nil
|
||
}
|
||
|
||
// parsePrivateKeyPEM 解析 PEM 格式 RSA 私钥(兼容 PKCS#1 与 PKCS#8)。
|
||
func parsePrivateKeyPEM(pemStr string) (*rsa.PrivateKey, error) {
|
||
block, _ := pem.Decode([]byte(pemStr))
|
||
if block == nil {
|
||
return nil, gerror.New("无效的 PEM 数据")
|
||
}
|
||
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||
return k, nil
|
||
}
|
||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||
if err != nil {
|
||
return nil, gerror.Wrap(err, "解析 RSA 私钥失败")
|
||
}
|
||
rsaKey, ok := k.(*rsa.PrivateKey)
|
||
if !ok {
|
||
return nil, gerror.New("密钥类型不是 RSA")
|
||
}
|
||
return rsaKey, nil
|
||
}
|
||
|
||
// encodePrivateKeyPEM 将 RSA 私钥编码为 PKCS#1 PEM。
|
||
func encodePrivateKeyPEM(key *rsa.PrivateKey) string {
|
||
return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||
}
|