diff --git a/.env.example b/.env.example index 21e93e8..8aee7f7 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,16 @@ JWT_SECRET=your-super-secret-key-change-this-in-production # 当前生产公钥(桌面已嵌入): 108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d # OFFLINE_LICENSE_SEED= +# 机密字段信封加密主密钥(64 位 hex = 32 字节)。加密 DB 中的支付网关私钥/ +# webhook 密钥/节点互联密钥;缺失则启动失败。双节点必须配置相同值! +# 生成示例: openssl rand -hex 32 +# OPSLIC_MASTER_KEY= + +# 信任代理 CIDR(逗号分隔)。直连公网的节点留空(不信任任何 X-Forwarded-For); +# 经 nginx 反代的节点填反代机器的内网网段,否则限流可被伪造 XFF 绕过。 +# 例: TRUSTED_PROXIES=192.168.14.0/24 +# TRUSTED_PROXIES= + # 客户端授权防护 # 过期宽限天数(仅商业过期;吊销/暂停无宽限,立即生效) GRACE_PERIOD_DAYS=15 diff --git a/backend/internal/controller/order/order_controller.go b/backend/internal/controller/order/order_controller.go index 4fdf073..ebd892f 100644 --- a/backend/internal/controller/order/order_controller.go +++ b/backend/internal/controller/order/order_controller.go @@ -121,11 +121,13 @@ func (ctrl *OrderController) PaymentWebhook(c *gin.Context) { )) release := strings.EqualFold(os.Getenv("GIN_MODE"), "release") if secret == "" { - if release { + // fail-closed:未配置密钥一律拒绝;仅当显式声明 ALLOW_UNVERIFIED_WEBHOOK=1 + // (本地调试)才放行,防止调试配置带进生产 // Logan.Li + if release || os.Getenv("ALLOW_UNVERIFIED_WEBHOOK") != "1" { c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未配置 webhook 密钥,已拒绝"}) return } - log.Println("[安全警告] INTERNAL_WEBHOOK_SECRET / WebhookSecret 未配置,开发模式放行通用回调") + log.Println("[安全警告] ALLOW_UNVERIFIED_WEBHOOK=1:未验签放行通用回调(仅限本地调试)") } else if provided == "" || provided != secret { c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid webhook secret"}) return @@ -137,7 +139,7 @@ func (ctrl *OrderController) PaymentWebhook(c *gin.Context) { return } if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil { - c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + c.JSON(http.StatusOK, gin.H{"code": 1, "message": utils.InternalMsg(err)}) return } c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"}) @@ -330,8 +332,13 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { ) verifyKey := payment.LoadWaffoWebhookVerifyKey(ctrl.db) if verifyKey == "" { - log.Println("[安全警告] 未配置 WAFFO_WEBHOOK_PUBLIC_KEY/SECRET,Waffo 回调暂未强制验签;请尽快配置") - } else if err := payment.VerifyWaffoWebhookSignature(verifyKey, body, sigHeader); err != nil { + // fail-closed:未配置验签材料时拒绝处理支付回调,而不是告警放行—— + // 否则任何人都能伪造 order.completed 结算订单 // Logan.Li + log.Println("[安全] 未配置 WAFFO_WEBHOOK_PUBLIC_KEY/SECRET,Waffo 回调已拒绝处理(fail-closed)") + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": "webhook 验签未配置,拒绝处理"}) + return + } + if err := payment.VerifyWaffoWebhookSignature(verifyKey, body, sigHeader); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid signature"}) return } @@ -448,7 +455,7 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { } if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil { // Still 200 so Waffo does not retry forever on business errors; log via message. // Logan.Li - c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + c.JSON(http.StatusOK, gin.H{"code": 1, "message": utils.InternalMsg(err)}) return } c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"}) @@ -490,7 +497,7 @@ func (ctrl *OrderController) ManualGrant(c *gin.Context) { username, _ := c.Get("username") data, err := ctrl.svc.ManualGrant(req, userID.(uint), username.(string), utils.GetClientIP(c)) if err != nil { - c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + c.JSON(http.StatusOK, gin.H{"code": 1, "message": utils.InternalMsg(err)}) return } c.JSON(http.StatusOK, gin.H{"code": 0, "message": "License 发放成功", "data": data}) @@ -541,7 +548,7 @@ func (ctrl *OrderController) BatchDelete(c *gin.Context) { } result, err := ctrl.svc.BatchDelete(req.IDs) if err != nil { - c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + c.JSON(http.StatusOK, gin.H{"code": 1, "message": utils.InternalMsg(err)}) return } utils.LogAction(ctrl.db, c, "batch_delete", "order", 0, "", "", "", diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index a469f4a..5278c67 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -1,6 +1,13 @@ package model -import "time" +import ( + "fmt" + "time" + + "backend/internal/secretbox" + + "gorm.io/gorm" +) // Order 订单模型 // Logan.Li type Order struct { @@ -52,3 +59,33 @@ type PaymentGatewayConfig struct { func (PaymentGatewayConfig) TableName() string { return "payment_gateway_configs" } + +// BeforeSave 机密字段信封加密后落库(幂等:已是密文不再重复加密) // Logan.Li +func (g *PaymentGatewayConfig) BeforeSave(tx *gorm.DB) error { + enc, err := secretbox.Encrypt(g.SecretKey) + if err != nil { + return fmt.Errorf("加密 secret_key: %w", err) + } + g.SecretKey = enc + enc, err = secretbox.Encrypt(g.WebhookSecret) + if err != nil { + return fmt.Errorf("加密 webhook_secret: %w", err) + } + g.WebhookSecret = enc + return nil +} + +// AfterFind 读取时解密;历史明文(无前缀)原样返回,迁移完成前不阻断 // Logan.Li +func (g *PaymentGatewayConfig) AfterFind(tx *gorm.DB) error { + dec, err := secretbox.Decrypt(g.SecretKey) + if err != nil { + return fmt.Errorf("解密 secret_key: %w", err) + } + g.SecretKey = dec + dec, err = secretbox.Decrypt(g.WebhookSecret) + if err != nil { + return fmt.Errorf("解密 webhook_secret: %w", err) + } + g.WebhookSecret = dec + return nil +} diff --git a/backend/internal/model/sync.go b/backend/internal/model/sync.go index 480f96d..5fbeb5e 100644 --- a/backend/internal/model/sync.go +++ b/backend/internal/model/sync.go @@ -1,6 +1,13 @@ package model -import "time" +import ( + "fmt" + "time" + + "backend/internal/secretbox" + + "gorm.io/gorm" +) // Peer 节点状态:pending 已配置未握手,active 握手成功可同步,disabled 手动停用 // Logan.Li const ( @@ -14,7 +21,7 @@ type PeerNode struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"size:100;not null"` BaseURL string `json:"base_url" gorm:"size:255;not null"` // 对端 https 地址(含 /api) - SharedKey string `json:"-" gorm:"size:128;not null"` // 互联密钥,双向一致,HMAC 签名用 + SharedKey string `json:"-" gorm:"size:512;not null"` // 互联密钥,双向一致,HMAC 签名用;信封加密存储(密文比明文长) Status string `json:"status" gorm:"size:20;default:'pending'"` RemoteNode string `json:"remote_node" gorm:"size:64"` // 对端节点 ID,握手后回填 LastSyncAt *time.Time `json:"last_sync_at,omitempty"` // 本地对端同步游标(增量起点) @@ -27,3 +34,23 @@ type PeerNode struct { func (PeerNode) TableName() string { return "peer_nodes" } + +// BeforeSave 互联密钥信封加密后落库(幂等) // Logan.Li +func (p *PeerNode) BeforeSave(tx *gorm.DB) error { + enc, err := secretbox.Encrypt(p.SharedKey) + if err != nil { + return fmt.Errorf("加密 shared_key: %w", err) + } + p.SharedKey = enc + return nil +} + +// AfterFind 读取时解密;历史明文原样返回 // Logan.Li +func (p *PeerNode) AfterFind(tx *gorm.DB) error { + dec, err := secretbox.Decrypt(p.SharedKey) + if err != nil { + return fmt.Errorf("解密 shared_key: %w", err) + } + p.SharedKey = dec + return nil +} diff --git a/backend/internal/secretbox/migrate.go b/backend/internal/secretbox/migrate.go new file mode 100644 index 0000000..d8fd18d --- /dev/null +++ b/backend/internal/secretbox/migrate.go @@ -0,0 +1,64 @@ +package secretbox + +import ( + "fmt" + + "gorm.io/gorm" +) + +// MigratePlaintext 启动时把历史明文机密一次性加密回写(幂等,可重跑)。 +// 只处理非空且无 enc:v1: 前缀的值;双节点各自执行,密文经节点同步后 +// 在相同 OPSLIC_MASTER_KEY 下均可解密。 // Logan.Li +func MigratePlaintext(db *gorm.DB) error { + if !Ready() { + return fmt.Errorf("secretbox: 主密钥未装载,拒绝执行机密迁移") + } + + // payment_gateway_configs: 主键为 gateway(字符串) // Logan.Li + var gwRows []struct { + Gateway string + SecretKey string + WebhookSecret string + } + if err := db.Raw(`SELECT gateway, secret_key, webhook_secret FROM payment_gateway_configs + WHERE (secret_key <> '' AND secret_key NOT LIKE ?) + OR (webhook_secret <> '' AND webhook_secret NOT LIKE ?)`, Prefix+"%", Prefix+"%"). + Scan(&gwRows).Error; err != nil { + return fmt.Errorf("扫描 payment_gateway_configs: %w", err) + } + for _, r := range gwRows { + encSecret, err := Encrypt(r.SecretKey) + if err != nil { + return fmt.Errorf("加密 %s.secret_key: %w", r.Gateway, err) + } + encWebhook, err := Encrypt(r.WebhookSecret) + if err != nil { + return fmt.Errorf("加密 %s.webhook_secret: %w", r.Gateway, err) + } + if err := db.Exec(`UPDATE payment_gateway_configs SET secret_key = ?, webhook_secret = ? WHERE gateway = ?`, + encSecret, encWebhook, r.Gateway).Error; err != nil { + return fmt.Errorf("回写 %s: %w", r.Gateway, err) + } + } + + // peer_nodes: 主键为 id(整型) // Logan.Li + var peerRows []struct { + ID uint + SharedKey string + } + if err := db.Raw(`SELECT id, shared_key FROM peer_nodes + WHERE shared_key <> '' AND shared_key NOT LIKE ?`, Prefix+"%"). + Scan(&peerRows).Error; err != nil { + return fmt.Errorf("扫描 peer_nodes: %w", err) + } + for _, r := range peerRows { + enc, err := Encrypt(r.SharedKey) + if err != nil { + return fmt.Errorf("加密 peer_nodes.%d: %w", r.ID, err) + } + if err := db.Exec(`UPDATE peer_nodes SET shared_key = ? WHERE id = ?`, enc, r.ID).Error; err != nil { + return fmt.Errorf("回写 peer_nodes.%d: %w", r.ID, err) + } + } + return nil +} diff --git a/backend/internal/secretbox/secretbox.go b/backend/internal/secretbox/secretbox.go new file mode 100644 index 0000000..ef6d803 --- /dev/null +++ b/backend/internal/secretbox/secretbox.go @@ -0,0 +1,104 @@ +// Package secretbox 为落库机密字段提供信封加密(AES-256-GCM)。 +// 主密钥来自环境变量 OPSLIC_MASTER_KEY(64 位 hex = 32 字节),启动时装载, +// 缺失或非法即 fail-fast。密文带 enc:v1: 前缀;无前缀的值视为历史明文, +// Decrypt 原样返回——支持存量数据一次性迁移渐进完成。 // Logan.Li +package secretbox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "sync" +) + +// Prefix 密文前缀;同时用于迁移时识别未加密的历史明文。 // Logan.Li +const Prefix = "enc:v1:" + +var ( + mu sync.RWMutex + aed cipher.AEAD +) + +// LoadMasterKey 从 OPSLIC_MASTER_KEY 装载主密钥;缺失/非法返回错误(调用方应 fail-fast)。 // Logan.Li +func LoadMasterKey() error { + raw := strings.TrimSpace(os.Getenv("OPSLIC_MASTER_KEY")) + if raw == "" { + return errors.New("未配置 OPSLIC_MASTER_KEY(64 位 hex,32 字节):机密字段加密不可用,拒绝启动") + } + key, err := hex.DecodeString(raw) + if err != nil || len(key) != 32 { + return errors.New("OPSLIC_MASTER_KEY 必须是 64 位 hex(32 字节)") + } + block, err := aes.NewCipher(key) + if err != nil { + return fmt.Errorf("初始化 AES 失败: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return fmt.Errorf("初始化 GCM 失败: %w", err) + } + mu.Lock() + aed = gcm + mu.Unlock() + return nil +} + +// Ready 报告主密钥是否已装载。 // Logan.Li +func Ready() bool { + mu.RLock() + defer mu.RUnlock() + return aed != nil +} + +// Encrypt 加密明文:空串与已是密文的值原样返回(幂等,可安全重复调用)。 // Logan.Li +func Encrypt(plain string) (string, error) { + if plain == "" || strings.HasPrefix(plain, Prefix) { + return plain, nil + } + mu.RLock() + g := aed + mu.RUnlock() + if g == nil { + return "", errors.New("secretbox: 主密钥未装载") + } + nonce := make([]byte, g.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("生成 nonce 失败: %w", err) + } + sealed := g.Seal(nonce, nonce, []byte(plain), nil) + return Prefix + base64.StdEncoding.EncodeToString(sealed), nil +} + +// Decrypt 解密:无前缀(空值或历史明文)原样返回;密文被篡改时返回错误。 // Logan.Li +func Decrypt(s string) (string, error) { + if !strings.HasPrefix(s, Prefix) { + return s, nil + } + mu.RLock() + g := aed + mu.RUnlock() + if g == nil { + return "", errors.New("secretbox: 主密钥未装载,无法解密机密字段") + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(s, Prefix)) + if err != nil { + return "", fmt.Errorf("机密字段 base64 解码失败: %w", err) + } + if len(raw) < g.NonceSize() { + return "", errors.New("机密字段密文长度非法") + } + plain, err := g.Open(nil, raw[:g.NonceSize()], raw[g.NonceSize():], nil) + if err != nil { + return "", fmt.Errorf("机密字段解密失败(主密钥不一致或密文被篡改): %w", err) + } + return string(plain), nil +} + +// MustDecrypt 供钩子使用:解密失败直接返回错误,让读写显式失败而非静默拿错值。 // Logan.Li +func MustDecrypt(s string) (string, error) { return Decrypt(s) } diff --git a/backend/internal/secretbox/secretbox_test.go b/backend/internal/secretbox/secretbox_test.go new file mode 100644 index 0000000..135a22c --- /dev/null +++ b/backend/internal/secretbox/secretbox_test.go @@ -0,0 +1,74 @@ +package secretbox + +import ( + "os" + "strings" + "testing" +) + +func withTestKey(t *testing.T) { + t.Helper() + t.Setenv("OPSLIC_MASTER_KEY", strings.Repeat("ab", 32)) + if err := LoadMasterKey(); err != nil { + t.Fatalf("LoadMasterKey: %v", err) + } +} + +func TestRoundtrip(t *testing.T) { + withTestKey(t) + for _, plain := range []string{"waffo-secret-123", "-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----", "x", ""} { + enc, err := Encrypt(plain) + if err != nil { + t.Fatalf("Encrypt(%q): %v", plain, err) + } + if plain != "" && !strings.HasPrefix(enc, Prefix) { + t.Fatalf("密文缺少前缀: %q", enc) + } + got, err := Decrypt(enc) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if got != plain { + t.Fatalf("roundtrip 不一致: got %q want %q", got, plain) + } + } +} + +func TestEncryptIdempotent(t *testing.T) { + withTestKey(t) + once, _ := Encrypt("same-value") + twice, _ := Encrypt(once) + if once != twice { + t.Fatal("对已加密值再次 Encrypt 必须原样返回(幂等)") + } +} + +func TestDecryptPlaintextPassthrough(t *testing.T) { + withTestKey(t) + got, err := Decrypt("legacy-plaintext") + if err != nil || got != "legacy-plaintext" { + t.Fatalf("历史明文应原样返回: got %q err %v", got, err) + } +} + +func TestTamperDetection(t *testing.T) { + withTestKey(t) + enc, _ := Encrypt("secret") + tampered := enc[:len(enc)-2] + "A=" + if _, err := Decrypt(tampered); err == nil { + t.Fatal("篡改后的密文必须解密失败") + } +} + +func TestLoadMasterKeyFailFast(t *testing.T) { + t.Setenv("OPSLIC_MASTER_KEY", "") + if err := LoadMasterKey(); err == nil { + t.Fatal("缺失主密钥必须报错") + } + t.Setenv("OPSLIC_MASTER_KEY", "not-hex") + if err := LoadMasterKey(); err == nil { + t.Fatal("非法主密钥必须报错") + } +} + +func TestMain(m *testing.M) { os.Exit(m.Run()) } diff --git a/backend/main.go b/backend/main.go index 5c91c2f..7884640 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "backend/config" "backend/database" licenseservice "backend/internal/service/license" + "backend/internal/secretbox" syncservice "backend/internal/service/sync" "backend/router" "context" @@ -19,12 +20,22 @@ func main() { // 加载配置 // Logan.Li cfg := config.Load() + // 机密字段主密钥:缺失/非法直接拒绝启动(fail-fast),避免带病运行 // Logan.Li + if err := secretbox.LoadMasterKey(); err != nil { + log.Fatal(err) + } + // 初始化数据库 // Logan.Li db, err := database.Init(cfg) if err != nil { log.Fatal("数据库初始化失败:", err) } + // 历史明文机密一次性加密回写(幂等;双节点需配置相同 OPSLIC_MASTER_KEY) // Logan.Li + if err := secretbox.MigratePlaintext(db); err != nil { + log.Fatal("机密字段迁移失败:", err) + } + // 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes) diff --git a/backend/router/router.go b/backend/router/router.go index afe7ee0..9529718 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -13,6 +13,7 @@ import ( "backend/internal/controller/setting" synccontroller "backend/internal/controller/sync" "backend/middleware" + "os" "strings" "github.com/gin-contrib/gzip" @@ -61,6 +62,16 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { r.RedirectTrailingSlash = false r.RedirectFixedPath = false + // 信任代理:默认不信任任何代理头(X-Forwarded-For 可伪造,会绕过按 IP 限流); + // 经 nginx 反代的节点用 TRUSTED_PROXIES 指定其内网 CIDR,多个用逗号分隔 // Logan.Li + if proxies := strings.TrimSpace(os.Getenv("TRUSTED_PROXIES")); proxies != "" { + if err := r.SetTrustedProxies(strings.Split(proxies, ",")); err != nil { + panic("TRUSTED_PROXIES 配置非法: " + err.Error()) + } + } else if err := r.SetTrustedProxies(nil); err != nil { + panic("SetTrustedProxies 失败: " + err.Error()) + } + // Gzip 压缩中间件 // Logan.Li r.Use(gzip.Gzip(gzip.DefaultCompression)) diff --git a/docker-compose.yml b/docker-compose.yml index 072b7e7..c26bd5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,10 @@ services: GIN_MODE: ${GIN_MODE:-release} DB_DRIVER: sqlite DB_NAME: /data/db.sqlite3 + # 机密字段信封加密主密钥(64 位 hex=32 字节):双节点必须一致;缺失则启动失败 // Logan.Li + OPSLIC_MASTER_KEY: ${OPSLIC_MASTER_KEY:-} + # 经 nginx 反代的节点填其内网 CIDR(逗号分隔);直连节点留空=不信任任何代理头 // Logan.Li + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} volumes: - licence_data:/data healthcheck: