service.xpcool.com/internal/library/crypto/crypto.go
夏犀麟 06484774ad feat(auth): 登录密码 RSA+AES 混合加密传输与加密字段支持
- 新增 internal/library/crypto:RSA-OAEP(SHA-256)+AES-256-GCM 混合解密,
  私钥按 配置PEM > data/crypto/rsa_private.pem > 自动生成 三级来源,公钥 SPKI 输出
- 公开接口 POST /system/auth/public-key;LoginReq 支持 encryptedKey/encryptedData
- AdminCreate/AdminResetPwd 密码字段支持加密传输(allowPlain 仅开发联调)
- 配置 encrypt.privateKey/allowPlain;data/ 加入 gitignore 防止私钥入库
2026-08-27 23:43:41 +08:00

235 lines
8.1 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 提供登录密码「RSA + AES-GCM」混合加密服务。
//
// 设计目标对称加密AES-GCM负责加密实际载荷非对称加密RSA-OAEP
// 负责加密 AES 会话密钥,两者结合兼顾性能与密钥分发安全:
// - 前端:随机生成 AES-256 密钥 → AES-GCM 加密 {username,password,ts} → RSA 公钥加密 AES 密钥
// - 后端:持有 RSA 私钥 → 解出 AES 密钥 → 解出明文载荷(含时间戳防重放)
//
// 密钥来源优先级:配置 encrypt.privateKeyPEM→ 本地文件 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
}
// 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()}
// 配置项可能是未注入的 ${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 }
// PublicKey 返回 PEM 格式 RSA 公钥SubjectPublicKeyInfo / SPKI
// 供前端 Web Crypto importKey('spki') 做混合加密。
func (s *Service) PublicKey() string {
// MarshalPKIXPublicKey 生成 SPKIPKIX/SubjectPublicKeyInfoDER前端按 "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
}
// decryptPayload 混合解密公共实现RSA-OAEP 解 AES 密钥 → AES-GCM 解明文。
// 密文结构encryptedData = base64(nonce(12B) || ciphertext)。
func (s *Service) decryptPayload(encryptedKeyB64, encryptedDataB64 string) ([]byte, error) {
encKey, err := base64.StdEncoding.DecodeString(encryptedKeyB64)
if err != nil {
return nil, gerror.New("加密密钥格式错误")
}
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, s.privKey, encKey, nil)
if err != nil {
return nil, gerror.New("解密 AES 会话密钥失败")
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, gerror.Wrap(err, "初始化 AES 失败")
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, gerror.Wrap(err, "初始化 AES-GCM 失败")
}
data, err := base64.StdEncoding.DecodeString(encryptedDataB64)
if err != nil {
return nil, gerror.New("密文格式错误")
}
if len(data) <= aesNonceSize {
return nil, gerror.New("密文长度非法")
}
// 密文结构nonce(12B) || ciphertextGCM 自带完整性校验。
plain, err := gcm.Open(nil, data[:aesNonceSize], data[aesNonceSize:], nil)
if err != nil {
return nil, gerror.New("解密载荷失败")
}
return 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)}))
}