feat: crypto for key

This commit is contained in:
icy
2025-11-07 01:25:18 +08:00
parent bbf34e70c2
commit e73e427e35
15 changed files with 1609 additions and 148 deletions
+3
View File
@@ -13,6 +13,9 @@ REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=redis123456
# 数据加密密钥
DATA_ENCRYPTION_KEY=my_secret_encryption_key
# Ports Configuration
# Backend API server port (internal: 8080, external: configurable)
NOFX_BACKEND_PORT=8080
+5
View File
@@ -35,6 +35,11 @@ config.db
certs/
beta_codes.txt
# 密钥文件
keys/
*.key
*.pem
# 决策日志
decision_logs/
coin_pool_cache/
+129 -3
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"nofx/auth"
"nofx/config"
"nofx/crypto"
"nofx/decision"
"nofx/manager"
"nofx/trader"
@@ -24,11 +25,12 @@ type Server struct {
router *gin.Engine
traderManager *manager.TraderManager
database config.DatabaseInterface
cryptoService *crypto.CryptoService
port int
}
// NewServer 创建API服务器
func NewServer(traderManager *manager.TraderManager, database config.DatabaseInterface, port int) *Server {
func NewServer(traderManager *manager.TraderManager, database config.DatabaseInterface, cryptoService *crypto.CryptoService, port int) *Server {
// 设置为Release模式(减少日志输出)
gin.SetMode(gin.ReleaseMode)
@@ -37,10 +39,17 @@ func NewServer(traderManager *manager.TraderManager, database config.DatabaseInt
// 启用CORS
router.Use(corsMiddleware())
if cryptoService == nil {
log.Printf("⚠️ 加密服务未初始化,敏感数据加解密功能不可用")
} else {
database.SetCryptoService(cryptoService)
}
s := &Server{
router: router,
traderManager: traderManager,
database: database,
cryptoService: cryptoService,
port: port,
}
@@ -123,6 +132,7 @@ func (s *Server) setupRoutes() {
// 交易所配置
protected.GET("/exchanges", s.handleGetExchangeConfigs)
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
protected.PUT("/exchanges/encrypted", s.handleUpdateExchangeConfigsEncrypted)
// 用户信号源配置
protected.GET("/user/signal-sources", s.handleGetUserSignalSource)
@@ -179,11 +189,19 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) {
betaModeStr, _ := s.database.GetSystemConfig("beta_mode")
betaMode := betaModeStr == "true"
// 获取RSA公钥
var rsaPublicKey string
if s.cryptoService != nil {
rsaPublicKey = s.cryptoService.GetPublicKeyPEM()
}
c.JSON(http.StatusOK, gin.H{
"beta_mode": betaMode,
"default_coins": defaultCoins,
"btc_eth_leverage": btcEthLeverage,
"altcoin_leverage": altcoinLeverage,
"rsa_public_key": rsaPublicKey,
"rsa_key_id": "rsa-key-2025-11-05",
})
}
@@ -1638,8 +1656,10 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) {
// handleLogin 处理用户登录请求
func (s *Server) handleLogin(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
Email string `json:"email"`
EmailEncrypted *crypto.EncryptedPayload `json:"email_encrypted"`
Password string `json:"password"`
PasswordEncrypted *crypto.EncryptedPayload `json:"password_encrypted"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -1647,6 +1667,51 @@ func (s *Server) handleLogin(c *gin.Context) {
return
}
if req.EmailEncrypted != nil {
if s.cryptoService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "加密服务不可用"})
return
}
decryptedEmail, err := s.cryptoService.DecryptSensitiveData(req.EmailEncrypted)
if err != nil {
log.Printf("❌ 登录邮箱解密失败: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱解密失败"})
return
}
req.Email = decryptedEmail
}
if req.PasswordEncrypted != nil {
if s.cryptoService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "加密服务不可用"})
return
}
decryptedPassword, err := s.cryptoService.DecryptSensitiveData(req.PasswordEncrypted)
if err != nil {
log.Printf("❌ 登录密码解密失败: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "密码解密失败"})
return
}
req.Password = decryptedPassword
}
req.Email = strings.TrimSpace(req.Email)
if req.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱不能为空"})
return
}
if !strings.Contains(req.Email, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式错误"})
return
}
if strings.TrimSpace(req.Password) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "密码不能为空"})
return
}
// 获取用户信息
user, err := s.database.GetUserByEmail(req.Email)
if err != nil {
@@ -2026,3 +2091,64 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
// handleUpdateExchangeConfigsEncrypted 更新交易所配置(加密传输)
func (s *Server) handleUpdateExchangeConfigsEncrypted(c *gin.Context) {
if s.cryptoService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "加密服务不可用"})
return
}
userID := c.GetString("user_id")
// 接收加密载荷
var payload crypto.EncryptedPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 解密数据
decryptedData, err := s.cryptoService.DecryptSensitiveData(&payload)
if err != nil {
log.Printf("❌ 解密失败: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "解密失败"})
return
}
// 解析解密后的数据
var req UpdateExchangeConfigRequest
if err := json.Unmarshal([]byte(decryptedData), &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "数据格式错误"})
return
}
// 更新每个交易所的配置
for exchangeID, exchangeData := range req.Exchanges {
err := s.database.UpdateExchange(
userID,
exchangeID,
exchangeData.Enabled,
exchangeData.APIKey,
exchangeData.SecretKey,
exchangeData.Testnet,
exchangeData.HyperliquidWalletAddr,
exchangeData.AsterUser,
exchangeData.AsterSigner,
exchangeData.AsterPrivateKey,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)})
return
}
}
// 重新加载该用户的所有交易员,使新配置立即生效
err = s.traderManager.LoadUserTraders(s.database, userID)
if err != nil {
log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err)
}
log.Printf("✓ 交易所配置已通过加密方式更新")
c.JSON(http.StatusOK, gin.H{"message": "交易所配置已更新"})
}
+7 -3
View File
@@ -3,10 +3,13 @@ package config
import (
"fmt"
"time"
"nofx/crypto"
)
// DatabaseInterface 定义了数据库实现需要提供的方法集合
type DatabaseInterface interface {
SetCryptoService(cs *crypto.CryptoService)
CreateUser(user *User) error
GetUserByEmail(email string) (*User, error)
GetUserByID(userID string) (*User, error)
@@ -43,8 +46,8 @@ type DatabaseInterface interface {
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"` // 不返回到前端
OTPSecret string `json:"-"` // 不返回到前端
PasswordHash string `json:"-"`
OTPSecret string `json:"-"`
OTPVerified bool `json:"otp_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -78,12 +81,13 @@ type ExchangeConfig struct {
AsterUser string `json:"asterUser"`
AsterSigner string `json:"asterSigner"`
AsterPrivateKey string `json:"asterPrivateKey"`
DEXWalletPrivateKey string `json:"dexWalletPrivateKey"` // 统一的DEX私钥字段
Deleted bool `json:"deleted"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TraderRecord 交易员配置(数据库实体)
// TraderRecord 交易员配置
type TraderRecord struct {
ID string `json:"id"`
UserID string `json:"user_id"`
+221 -10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"nofx/crypto"
"nofx/market"
"os"
"slices"
@@ -17,6 +18,7 @@ import (
// PostgreSQLDatabase PostgreSQL数据库配置
type PostgreSQLDatabase struct {
db *sql.DB
cryptoService *crypto.CryptoService
}
// NewPostgreSQLDatabase 创建PostgreSQL数据库连接
@@ -60,6 +62,42 @@ func NewPostgreSQLDatabase() (*PostgreSQLDatabase, error) {
return database, nil
}
func (d *PostgreSQLDatabase) SetCryptoService(cs *crypto.CryptoService) {
d.cryptoService = cs
}
func (d *PostgreSQLDatabase) encryptValue(value string, aadParts ...string) (string, error) {
if value == "" {
return "", nil
}
if d.cryptoService == nil {
return "", fmt.Errorf("crypto service not initialized")
}
if !d.cryptoService.HasDataKey() {
return "", fmt.Errorf("data encryption key not configured")
}
if d.cryptoService.IsEncryptedStorageValue(value) {
return value, nil
}
return d.cryptoService.EncryptForStorage(value, aadParts...)
}
func (d *PostgreSQLDatabase) decryptValue(value string, aadParts ...string) (string, error) {
if value == "" {
return "", nil
}
if d.cryptoService == nil {
return "", fmt.Errorf("crypto service not initialized")
}
if !d.cryptoService.HasDataKey() {
return "", fmt.Errorf("data encryption key not configured")
}
if !d.cryptoService.IsEncryptedStorageValue(value) {
return "", fmt.Errorf("value is not encrypted")
}
return d.cryptoService.DecryptFromStorage(value, aadParts...)
}
// getEnv 获取环境变量,如果不存在返回默认值
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
@@ -162,6 +200,15 @@ func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error
if err != nil {
return nil, err
}
if model.APIKey != "" {
decrypted, err := d.decryptValue(model.APIKey, model.UserID, model.ID, "api_key")
if err != nil {
return nil, err
}
model.APIKey = decrypted
}
models = append(models, &model)
}
@@ -229,11 +276,18 @@ func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiK
`, userID, id).Scan(&existingID)
if err == nil {
apiKeyEnc, err := d.encryptValue(apiKey, userID, existingID, "api_key")
if err != nil {
return err
}
// 找到了现有配置(精确匹配 ID),更新它
_, err = d.db.Exec(`
UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = $5 AND user_id = $6
`, enabled, apiKey, customAPIURL, customModelName, existingID, userID)
`, enabled, apiKeyEnc, customAPIURL, customModelName, existingID, userID)
return err
}
if err != sql.ErrNoRows {
return err
}
@@ -244,12 +298,19 @@ func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiK
`, userID, provider).Scan(&existingID)
if err == nil {
apiKeyEnc, err := d.encryptValue(apiKey, userID, existingID, "api_key")
if err != nil {
return err
}
// 找到了现有配置(通过 provider 匹配,兼容旧版),更新它
log.Printf("⚠️ 使用旧版 provider 匹配更新模型: %s -> %s", provider, existingID)
_, err = d.db.Exec(`
UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = $5 AND user_id = $6
`, enabled, apiKey, customAPIURL, customModelName, existingID, userID)
`, enabled, apiKeyEnc, customAPIURL, customModelName, existingID, userID)
return err
}
if err != sql.ErrNoRows {
return err
}
@@ -292,11 +353,16 @@ func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiK
newModelID = fmt.Sprintf("%s_%s", userID, provider)
}
apiKeyEnc, err := d.encryptValue(apiKey, userID, newModelID, "api_key")
if err != nil {
return err
}
log.Printf("✓ 创建新的 AI 模型配置: ID=%s, Provider=%s, Name=%s", newModelID, provider, name)
_, err = d.db.Exec(`
INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url, custom_model_name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, newModelID, userID, name, provider, enabled, apiKey, customAPIURL, customModelName)
`, newModelID, userID, name, provider, enabled, apiKeyEnc, customAPIURL, customModelName)
return err
}
@@ -309,6 +375,7 @@ func (d *PostgreSQLDatabase) GetExchanges(userID string) ([]*ExchangeConfig, err
COALESCE(aster_user, '') AS aster_user,
COALESCE(aster_signer, '') AS aster_signer,
COALESCE(aster_private_key, '') AS aster_private_key,
COALESCE(dex_wallet_private_key, '') AS dex_wallet_private_key,
COALESCE(deleted, FALSE) AS deleted,
created_at, updated_at
FROM exchanges
@@ -329,12 +396,50 @@ func (d *PostgreSQLDatabase) GetExchanges(userID string) ([]*ExchangeConfig, err
&exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
&exchange.HyperliquidWalletAddr, &exchange.AsterUser,
&exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.DEXWalletPrivateKey,
&exchange.Deleted,
&exchange.CreatedAt, &exchange.UpdatedAt,
)
if err != nil {
return nil, err
}
if decrypted, err := d.decryptValue(exchange.APIKey, exchange.UserID, exchange.ID, "api_key"); err == nil {
exchange.APIKey = decrypted
} else {
return nil, err
}
if decrypted, err := d.decryptValue(exchange.SecretKey, exchange.UserID, exchange.ID, "secret_key"); err == nil {
exchange.SecretKey = decrypted
} else {
return nil, err
}
if decrypted, err := d.decryptValue(exchange.HyperliquidWalletAddr, exchange.UserID, exchange.ID, "hyperliquid_wallet_addr"); err == nil {
exchange.HyperliquidWalletAddr = decrypted
} else {
return nil, err
}
if decrypted, err := d.decryptValue(exchange.AsterUser, exchange.UserID, exchange.ID, "aster_user"); err == nil {
exchange.AsterUser = decrypted
} else {
return nil, err
}
if decrypted, err := d.decryptValue(exchange.AsterSigner, exchange.UserID, exchange.ID, "aster_signer"); err == nil {
exchange.AsterSigner = decrypted
} else {
return nil, err
}
if decrypted, err := d.decryptValue(exchange.AsterPrivateKey, exchange.UserID, exchange.ID, "aster_private_key"); err == nil {
exchange.AsterPrivateKey = decrypted
} else {
return nil, err
}
if decrypted, err := d.decryptValue(exchange.DEXWalletPrivateKey, exchange.UserID, exchange.ID, "dex_wallet_private_key"); err == nil {
exchange.DEXWalletPrivateKey = decrypted
} else {
return nil, err
}
exchanges = append(exchanges, &exchange)
}
@@ -345,7 +450,7 @@ func (d *PostgreSQLDatabase) GetExchanges(userID string) ([]*ExchangeConfig, err
func (d *PostgreSQLDatabase) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
log.Printf("🔧 UpdateExchange: userID=%s, id=%s, enabled=%v", userID, id, enabled)
// 如果请求禁用该交易所,标记为已删除
// 如果请求禁用该交易所,执行软删除
if !enabled {
_, err := d.db.Exec(`
UPDATE exchanges
@@ -369,13 +474,38 @@ func (d *PostgreSQLDatabase) UpdateExchange(userID, id string, enabled bool, api
return nil
}
apiKeyEnc, err := d.encryptValue(apiKey, userID, id, "api_key")
if err != nil {
return fmt.Errorf("encrypt api_key failed: %w", err)
}
secretKeyEnc, err := d.encryptValue(secretKey, userID, id, "secret_key")
if err != nil {
return fmt.Errorf("encrypt secret_key failed: %w", err)
}
hyperAddrEnc, err := d.encryptValue(hyperliquidWalletAddr, userID, id, "hyperliquid_wallet_addr")
if err != nil {
return fmt.Errorf("encrypt hyperliquid_wallet_addr failed: %w", err)
}
asterUserEnc, err := d.encryptValue(asterUser, userID, id, "aster_user")
if err != nil {
return fmt.Errorf("encrypt aster_user failed: %w", err)
}
asterSignerEnc, err := d.encryptValue(asterSigner, userID, id, "aster_signer")
if err != nil {
return fmt.Errorf("encrypt aster_signer failed: %w", err)
}
asterPrivateKeyEnc, err := d.encryptValue(asterPrivateKey, userID, id, "aster_private_key")
if err != nil {
return fmt.Errorf("encrypt aster_private_key failed: %w", err)
}
// 首先尝试更新现有的用户配置
result, err := d.db.Exec(`
UPDATE exchanges SET enabled = $1, api_key = $2, secret_key = $3, testnet = $4,
hyperliquid_wallet_addr = $5, aster_user = $6, aster_signer = $7, aster_private_key = $8,
deleted = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = $9 AND user_id = $10
`, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, id, userID)
`, enabled, apiKeyEnc, secretKeyEnc, testnet, hyperAddrEnc, asterUserEnc, asterSignerEnc, asterPrivateKeyEnc, id, userID)
if err != nil {
log.Printf("❌ UpdateExchange: 更新失败: %v", err)
return err
@@ -418,7 +548,7 @@ func (d *PostgreSQLDatabase) UpdateExchange(userID, id string, enabled bool, api
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
deleted, created_at, updated_at)
VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10, $11, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, id, userID, name, typ, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey)
`, id, userID, name, typ, apiKeyEnc, secretKeyEnc, testnet, hyperAddrEnc, asterUserEnc, asterSignerEnc, asterPrivateKeyEnc)
if err != nil {
log.Printf("❌ UpdateExchange: 创建记录失败: %v", err)
@@ -434,21 +564,51 @@ func (d *PostgreSQLDatabase) UpdateExchange(userID, id string, enabled bool, api
// CreateAIModel 创建AI模型配置
func (d *PostgreSQLDatabase) CreateAIModel(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
_, err := d.db.Exec(`
apiKeyEnc, err := d.encryptValue(apiKey, userID, id, "api_key")
if err != nil {
return err
}
_, err = d.db.Exec(`
INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING
`, id, userID, name, provider, enabled, apiKey, customAPIURL)
`, id, userID, name, provider, enabled, apiKeyEnc, customAPIURL)
return err
}
// CreateExchange 创建交易所配置
func (d *PostgreSQLDatabase) CreateExchange(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
_, err := d.db.Exec(`
apiKeyEnc, err := d.encryptValue(apiKey, userID, id, "api_key")
if err != nil {
return fmt.Errorf("encrypt api_key failed: %w", err)
}
secretKeyEnc, err := d.encryptValue(secretKey, userID, id, "secret_key")
if err != nil {
return fmt.Errorf("encrypt secret_key failed: %w", err)
}
hyperAddrEnc, err := d.encryptValue(hyperliquidWalletAddr, userID, id, "hyperliquid_wallet_addr")
if err != nil {
return fmt.Errorf("encrypt hyperliquid_wallet_addr failed: %w", err)
}
asterUserEnc, err := d.encryptValue(asterUser, userID, id, "aster_user")
if err != nil {
return fmt.Errorf("encrypt aster_user failed: %w", err)
}
asterSignerEnc, err := d.encryptValue(asterSigner, userID, id, "aster_signer")
if err != nil {
return fmt.Errorf("encrypt aster_signer failed: %w", err)
}
asterPrivateKeyEnc, err := d.encryptValue(asterPrivateKey, userID, id, "aster_private_key")
if err != nil {
return fmt.Errorf("encrypt aster_private_key failed: %w", err)
}
_, err = d.db.Exec(`
INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id, user_id) DO NOTHING
`, id, userID, name, typ, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey)
`, id, userID, name, typ, enabled, apiKeyEnc, secretKeyEnc, testnet, hyperAddrEnc, asterUserEnc, asterSignerEnc, asterPrivateKeyEnc)
return err
}
@@ -575,6 +735,57 @@ func (d *PostgreSQLDatabase) GetTraderConfig(userID, traderID string) (*TraderRe
return nil, nil, nil, err
}
if aiModel.APIKey != "" {
decrypted, err := d.decryptValue(aiModel.APIKey, aiModel.UserID, aiModel.ID, "api_key")
if err != nil {
return nil, nil, nil, err
}
aiModel.APIKey = decrypted
}
if exchange.APIKey != "" {
decrypted, err := d.decryptValue(exchange.APIKey, exchange.UserID, exchange.ID, "api_key")
if err != nil {
return nil, nil, nil, err
}
exchange.APIKey = decrypted
}
if exchange.SecretKey != "" {
decrypted, err := d.decryptValue(exchange.SecretKey, exchange.UserID, exchange.ID, "secret_key")
if err != nil {
return nil, nil, nil, err
}
exchange.SecretKey = decrypted
}
if exchange.HyperliquidWalletAddr != "" {
decrypted, err := d.decryptValue(exchange.HyperliquidWalletAddr, exchange.UserID, exchange.ID, "hyperliquid_wallet_addr")
if err != nil {
return nil, nil, nil, err
}
exchange.HyperliquidWalletAddr = decrypted
}
if exchange.AsterUser != "" {
decrypted, err := d.decryptValue(exchange.AsterUser, exchange.UserID, exchange.ID, "aster_user")
if err != nil {
return nil, nil, nil, err
}
exchange.AsterUser = decrypted
}
if exchange.AsterSigner != "" {
decrypted, err := d.decryptValue(exchange.AsterSigner, exchange.UserID, exchange.ID, "aster_signer")
if err != nil {
return nil, nil, nil, err
}
exchange.AsterSigner = decrypted
}
if exchange.AsterPrivateKey != "" {
decrypted, err := d.decryptValue(exchange.AsterPrivateKey, exchange.UserID, exchange.ID, "aster_private_key")
if err != nil {
return nil, nil, nil, err
}
exchange.AsterPrivateKey = decrypted
}
return &trader, &aiModel, &exchange, nil
}
+394
View File
@@ -0,0 +1,394 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"time"
)
const (
storagePrefix = "ENC:v1:"
storageDelimiter = ":"
dataKeyEnvName = "DATA_ENCRYPTION_KEY"
)
type EncryptedPayload struct {
WrappedKey string `json:"wrappedKey"`
IV string `json:"iv"`
Ciphertext string `json:"ciphertext"`
AAD string `json:"aad,omitempty"`
KID string `json:"kid,omitempty"`
TS int64 `json:"ts,omitempty"`
}
type AADData struct {
UserID string `json:"userId"`
SessionID string `json:"sessionId"`
TS int64 `json:"ts"`
Purpose string `json:"purpose"`
}
type CryptoService struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
dataKey []byte
}
func NewCryptoService(privateKeyPath string) (*CryptoService, error) {
// 读取私钥文件
privateKeyPEM, err := ioutil.ReadFile(privateKeyPath)
if err != nil {
// 如果私钥文件不存在,生成新的密钥对
if err := GenerateRSAKeyPair(privateKeyPath); err != nil {
return nil, fmt.Errorf("failed to generate RSA key pair: %w", err)
}
privateKeyPEM, err = ioutil.ReadFile(privateKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read generated private key: %w", err)
}
}
// 解析私钥
privateKey, err := ParseRSAPrivateKeyFromPEM(privateKeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
dataKey, err := loadDataKeyFromEnv()
if err != nil {
return nil, fmt.Errorf("failed to load data encryption key: %w", err)
}
return &CryptoService{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
dataKey: dataKey,
}, nil
}
func GenerateRSAKeyPair(privateKeyPath string) error {
// 确保目录存在
dir := filepath.Dir(privateKeyPath)
if dir != "." {
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
// 生成 RSA 密钥对
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
// 编码私钥
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
// 保存私钥
if err := ioutil.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil {
return err
}
// 编码公钥
publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return err
}
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyDER,
})
// 保存公钥
publicKeyPath := privateKeyPath + ".pub"
if err := ioutil.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil {
return err
}
return nil
}
func ParseRSAPrivateKeyFromPEM(pemBytes []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("no PEM block found")
}
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("not an RSA key")
}
return rsaKey, nil
default:
return nil, errors.New("unsupported key type: " + block.Type)
}
}
func loadDataKeyFromEnv() ([]byte, error) {
keyStr := strings.TrimSpace(os.Getenv(dataKeyEnvName))
if keyStr == "" {
return nil, fmt.Errorf("%s not set", dataKeyEnvName)
}
if key, ok := decodePossibleKey(keyStr); ok {
return key, nil
}
sum := sha256.Sum256([]byte(keyStr))
key := make([]byte, len(sum))
copy(key, sum[:])
return key, nil
}
func decodePossibleKey(value string) ([]byte, bool) {
decoders := []func(string) ([]byte, error){
base64.StdEncoding.DecodeString,
base64.RawStdEncoding.DecodeString,
func(s string) ([]byte, error) { return hex.DecodeString(s) },
}
for _, decoder := range decoders {
if decoded, err := decoder(value); err == nil {
if key, ok := normalizeAESKey(decoded); ok {
return key, true
}
}
}
return nil, false
}
func normalizeAESKey(raw []byte) ([]byte, bool) {
switch len(raw) {
case 16, 24, 32:
return raw, true
case 0:
return nil, false
default:
sum := sha256.Sum256(raw)
key := make([]byte, len(sum))
copy(key, sum[:])
return key, true
}
}
func (cs *CryptoService) HasDataKey() bool {
return len(cs.dataKey) > 0
}
func (cs *CryptoService) GetPublicKeyPEM() string {
publicKeyDER, err := x509.MarshalPKIXPublicKey(cs.publicKey)
if err != nil {
return ""
}
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyDER,
})
return string(publicKeyPEM)
}
func (cs *CryptoService) EncryptForStorage(plaintext string, aadParts ...string) (string, error) {
if plaintext == "" {
return "", nil
}
if !cs.HasDataKey() {
return "", errors.New("data encryption key not configured")
}
if isEncryptedStorageValue(plaintext) {
return plaintext, nil
}
block, err := aes.NewCipher(cs.dataKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
aad := composeAAD(aadParts)
ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), aad)
return storagePrefix +
base64.StdEncoding.EncodeToString(nonce) + storageDelimiter +
base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (cs *CryptoService) DecryptFromStorage(value string, aadParts ...string) (string, error) {
if value == "" {
return "", nil
}
if !cs.HasDataKey() {
return "", errors.New("data encryption key not configured")
}
if !isEncryptedStorageValue(value) {
return "", errors.New("value is not encrypted")
}
payload := strings.TrimPrefix(value, storagePrefix)
parts := strings.SplitN(payload, storageDelimiter, 2)
if len(parts) != 2 {
return "", errors.New("invalid encrypted payload format")
}
nonce, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("decode nonce failed: %w", err)
}
ciphertext, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("decode ciphertext failed: %w", err)
}
block, err := aes.NewCipher(cs.dataKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(nonce) != gcm.NonceSize() {
return "", fmt.Errorf("invalid nonce size: expected %d, got %d", gcm.NonceSize(), len(nonce))
}
aad := composeAAD(aadParts)
plaintext, err := gcm.Open(nil, nonce, ciphertext, aad)
if err != nil {
return "", fmt.Errorf("decryption failed: %w", err)
}
return string(plaintext), nil
}
func (cs *CryptoService) IsEncryptedStorageValue(value string) bool {
return isEncryptedStorageValue(value)
}
func composeAAD(parts []string) []byte {
if len(parts) == 0 {
return nil
}
return []byte(strings.Join(parts, "|"))
}
func isEncryptedStorageValue(value string) bool {
return strings.HasPrefix(value, storagePrefix)
}
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
// 1. 验证时间戳(防止重放攻击)
if payload.TS != 0 {
elapsed := time.Since(time.Unix(payload.TS, 0))
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
return nil, errors.New("timestamp invalid or expired")
}
}
// 2. 解码 base64url
wrappedKey, err := base64.RawURLEncoding.DecodeString(payload.WrappedKey)
if err != nil {
return nil, fmt.Errorf("failed to decode wrapped key: %w", err)
}
iv, err := base64.RawURLEncoding.DecodeString(payload.IV)
if err != nil {
return nil, fmt.Errorf("failed to decode IV: %w", err)
}
ciphertext, err := base64.RawURLEncoding.DecodeString(payload.Ciphertext)
if err != nil {
return nil, fmt.Errorf("failed to decode ciphertext: %w", err)
}
var aad []byte
if payload.AAD != "" {
aad, err = base64.RawURLEncoding.DecodeString(payload.AAD)
if err != nil {
return nil, fmt.Errorf("failed to decode AAD: %w", err)
}
// 验证 AAD
var aadData AADData
if err := json.Unmarshal(aad, &aadData); err == nil {
// 可以在这里添加额外的验证逻辑
// 例如:验证 sessionID、userID 等
}
}
// 3. 使用 RSA-OAEP 解密 AES 密钥
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, cs.privateKey, wrappedKey, nil)
if err != nil {
return nil, fmt.Errorf("failed to unwrap AES key: %w", err)
}
// 4. 使用 AES-GCM 解密数据
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
if len(iv) != gcm.NonceSize() {
return nil, fmt.Errorf("invalid IV size: expected %d, got %d", gcm.NonceSize(), len(iv))
}
// 解密并验证认证标签
plaintext, err := gcm.Open(nil, iv, ciphertext, aad)
if err != nil {
return nil, fmt.Errorf("authentication/decryption failed: %w", err)
}
return plaintext, nil
}
func (cs *CryptoService) DecryptSensitiveData(payload *EncryptedPayload) (string, error) {
plaintext, err := cs.DecryptPayload(payload)
if err != nil {
return "", err
}
return string(plaintext), nil
}
+1
View File
@@ -57,6 +57,7 @@ services:
environment:
- TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone
- AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000
- DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY} # 数据加密密钥
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DB=${POSTGRES_DB:-nofx}
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"nofx/api"
"nofx/auth"
"nofx/config"
"nofx/crypto"
"nofx/manager"
"nofx/market"
"nofx/pool"
@@ -171,6 +172,13 @@ func main() {
}
defer database.Close()
// 初始化加密服务(用于敏感数据加密存储与传输)
cryptoService, err := crypto.NewCryptoService("keys/rsa_private.key")
if err != nil {
log.Fatalf("❌ 初始化加密服务失败: %v", err)
}
database.SetCryptoService(cryptoService)
// 同步config.json到数据库
if err := syncConfigToDatabase(database, configFile); err != nil {
log.Printf("⚠️ 同步config.json到数据库失败: %v", err)
@@ -289,7 +297,7 @@ func main() {
}
// 创建并启动API服务器
apiServer := api.NewServer(traderManager, database, apiPort)
apiServer := api.NewServer(traderManager, database, cryptoService, apiPort)
go func() {
if err := apiServer.Start(); err != nil {
log.Printf("❌ API服务器错误: %v", err)
+132 -10
View File
@@ -13,6 +13,7 @@ import { useAuth } from '../contexts/AuthContext'
import { getExchangeIcon } from './ExchangeIcons'
import { getModelIcon } from './ModelIcons'
import { TraderConfigModal } from './TraderConfigModal'
import { TwoStageKeyModal } from './TwoStageKeyModal'
import {
Bot,
Brain,
@@ -46,6 +47,12 @@ function getShortName(fullName: string): string {
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
function maskSecret(value: string): string {
if (!value) return ''
const length = Math.min(value.length, 16)
return '•'.repeat(length)
}
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
}
@@ -445,7 +452,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
},
}
await api.updateExchangeConfigs(request)
await api.updateExchangeConfigsEncrypted(request)
const refreshed = await api.getExchangeConfigs()
setAllExchanges(refreshed)
@@ -494,7 +501,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
},
}
await api.updateExchangeConfigs(request)
await api.updateExchangeConfigsEncrypted(request)
const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges)
@@ -1666,6 +1673,9 @@ function ExchangeConfigModal({
const [asterUser, setAsterUser] = useState('')
const [asterSigner, setAsterSigner] = useState('')
const [asterPrivateKey, setAsterPrivateKey] = useState('')
const [secureInputTarget, setSecureInputTarget] = useState<
null | 'hyperliquid' | 'aster'
>(null)
// 获取当前选择的交易所信息
// 编辑模式:从 configuredExchanges 查找(包含用户配置的 apiKey、secretKey 等)
@@ -1674,6 +1684,13 @@ function ExchangeConfigModal({
? configuredExchanges?.find(e => e.id === selectedExchangeId)
: supportedExchanges?.find(e => e.id === selectedExchangeId);
const secureInputContextLabel =
secureInputTarget === 'aster'
? t('asterExchangeName', language)
: secureInputTarget === 'hyperliquid'
? t('hyperliquidExchangeName', language)
: undefined
// 如果是编辑现有交易所,初始化表单数据
useEffect(() => {
if (editingExchangeId && selectedExchange) {
@@ -1692,6 +1709,28 @@ function ExchangeConfigModal({
}
}, [editingExchangeId, selectedExchange])
const handleSecureInputComplete = ({
value,
obfuscationLog,
}: {
value: string
obfuscationLog: string[]
}) => {
const trimmed = value.trim()
if (secureInputTarget === 'hyperliquid') {
setApiKey(trimmed)
}
if (secureInputTarget === 'aster') {
setAsterPrivateKey(trimmed)
}
console.log('Secure input obfuscation log:', obfuscationLog)
setSecureInputTarget(null)
}
const handleSecureInputCancel = () => {
setSecureInputTarget(null)
}
// 加载服务器IP(当选择binance时)
useEffect(() => {
if (selectedExchangeId === 'binance' && !serverIP) {
@@ -1755,6 +1794,7 @@ function ExchangeConfigModal({
}
return (
<>
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative"
@@ -2094,10 +2134,12 @@ function ExchangeConfigModal({
>
{t('privateKey', language)}
</label>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
type="text"
value={maskSecret(apiKey)}
readOnly
placeholder={t('enterPrivateKey', language)}
className="w-full px-3 py-2 rounded"
style={{
@@ -2105,8 +2147,42 @@ function ExchangeConfigModal({
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<button
type="button"
onClick={() => setSecureInputTarget('hyperliquid')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#F0B90B',
color: '#000',
whiteSpace: 'nowrap',
}}
>
{apiKey
? t('secureInputReenter', language)
: t('secureInputButton', language)}
</button>
{apiKey && (
<button
type="button"
onClick={() => setApiKey('')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#1B1F2B',
color: '#848E9C',
whiteSpace: 'nowrap',
}}
>
{t('secureInputClear', language)}
</button>
)}
</div>
{apiKey && (
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('secureInputHint', language)}
</div>
)}
</div>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('hyperliquidPrivateKeyDesc', language)}
</div>
@@ -2209,10 +2285,12 @@ function ExchangeConfigModal({
/>
</Tooltip>
</label>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="password"
value={asterPrivateKey}
onChange={(e) => setAsterPrivateKey(e.target.value)}
type="text"
value={maskSecret(asterPrivateKey)}
readOnly
placeholder={t('enterPrivateKey', language)}
className="w-full px-3 py-2 rounded"
style={{
@@ -2220,8 +2298,42 @@ function ExchangeConfigModal({
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<button
type="button"
onClick={() => setSecureInputTarget('aster')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#F0B90B',
color: '#000',
whiteSpace: 'nowrap',
}}
>
{asterPrivateKey
? t('secureInputReenter', language)
: t('secureInputButton', language)}
</button>
{asterPrivateKey && (
<button
type="button"
onClick={() => setAsterPrivateKey('')}
className="px-3 py-2 rounded text-xs font-semibold transition-all hover:scale-105"
style={{
background: '#1B1F2B',
color: '#848E9C',
whiteSpace: 'nowrap',
}}
>
{t('secureInputClear', language)}
</button>
)}
</div>
{asterPrivateKey && (
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('secureInputHint', language)}
</div>
)}
</div>
</div>
</>
)}
@@ -2350,5 +2462,15 @@ function ExchangeConfigModal({
</div>
)}
</div>
<TwoStageKeyModal
isOpen={secureInputTarget !== null}
language={language}
contextLabel={secureInputContextLabel}
expectedLength={64}
onCancel={handleSecureInputCancel}
onComplete={handleSecureInputComplete}
/>
</>
)
}
+320
View File
@@ -0,0 +1,320 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { t, type Language } from '../i18n/translations'
const DEFAULT_LENGTH = 64
function generateObfuscation(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function validatePrivateKeyFormat(value: string, expectedLength: number): boolean {
const normalized = value.startsWith('0x') ? value.slice(2) : value
if (normalized.length !== expectedLength) {
return false
}
return /^[0-9a-fA-F]+$/.test(normalized)
}
export interface TwoStageKeyModalResult {
value: string
obfuscationLog: string[]
}
interface TwoStageKeyModalProps {
isOpen: boolean
language: Language
onCancel: () => void
onComplete: (result: TwoStageKeyModalResult) => void
expectedLength?: number
contextLabel?: string
}
export function TwoStageKeyModal({
isOpen,
language,
onCancel,
onComplete,
expectedLength = DEFAULT_LENGTH,
contextLabel,
}: TwoStageKeyModalProps) {
const [stage, setStage] = useState<1 | 2>(1)
const [part1, setPart1] = useState('')
const [part2, setPart2] = useState('')
const [error, setError] = useState<string | null>(null)
const [clipboardStatus, setClipboardStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
const [obfuscationLog, setObfuscationLog] = useState<string[]>([])
const [processing, setProcessing] = useState(false)
const [manualObfuscationValue, setManualObfuscationValue] = useState<string | null>(null)
const stage1InputRef = useRef<HTMLInputElement | null>(null)
const stage2InputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (!isOpen) return
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, onCancel])
useEffect(() => {
if (!isOpen) {
setStage(1)
setPart1('')
setPart2('')
setError(null)
setClipboardStatus('idle')
setObfuscationLog([])
setProcessing(false)
setManualObfuscationValue(null)
return
}
const focusTimer = setTimeout(() => {
if (stage === 1) {
stage1InputRef.current?.focus()
} else {
stage2InputRef.current?.focus()
}
}, 10)
return () => clearTimeout(focusTimer)
}, [isOpen, stage])
const heading = useMemo(() => {
if (!contextLabel) {
return t('twoStageModalTitle', language)
}
return `${t('twoStageModalTitle', language)} · ${contextLabel}`
}, [contextLabel, language])
if (!isOpen) {
return null
}
const handleOverlayClick = () => {
if (!processing) {
onCancel()
}
}
const handleStage1Next = async () => {
if (!part1.trim()) {
setError(t('twoStageStage1Error', language))
return
}
setProcessing(true)
const obfuscation = generateObfuscation()
let copied = false
try {
await navigator.clipboard.writeText(obfuscation)
copied = true
setClipboardStatus('copied')
setManualObfuscationValue(null)
} catch (err) {
console.warn('Clipboard write failed', err)
setClipboardStatus('failed')
setManualObfuscationValue(obfuscation)
}
setObfuscationLog((prev) => [...prev, `stage1:${new Date().toISOString()}`])
setProcessing(false)
setError(null)
setStage(2)
if (copied) {
setManualObfuscationValue(null)
}
}
const handleSubmit = () => {
const cleanedPart1 = part1.trim()
const cleanedPart2 = part2.trim()
const combined = (cleanedPart1 + cleanedPart2).replace(/\s+/g, '')
if (!validatePrivateKeyFormat(combined, expectedLength)) {
setError(t('twoStageInvalidFormat', language, { length: expectedLength }))
return
}
setObfuscationLog((prev) => [...prev, `stage2:${new Date().toISOString()}`])
const result: TwoStageKeyModalResult = {
value: combined,
obfuscationLog: [...obfuscationLog, `stage2:${new Date().toISOString()}`],
}
onComplete(result)
}
const modalContent = (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4"
onClick={handleOverlayClick}
>
<div
className="w-full max-w-md rounded-xl border border-[#2B3139] bg-[#0B0E11] p-6 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-4">
<h2 className="text-lg font-semibold" style={{ color: '#EAECEF' }}>
{heading}
</h2>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('twoStageModalDescription', language, { length: expectedLength })}
</p>
</div>
{stage === 1 ? (
<div className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('twoStageStage1Title', language)}
</label>
<input
ref={stage1InputRef}
type="password"
value={part1}
onChange={(event) => setPart1(event.target.value)}
placeholder={t('twoStageStage1Placeholder', language)}
className="w-full rounded border border-[#2B3139] bg-[#0F111C] px-3 py-2 text-sm text-[#EAECEF] outline-none focus:ring-2 focus:ring-[#F0B90B]/40"
disabled={processing}
/>
<p className="mt-2 text-xs" style={{ color: '#848E9C' }}>
{t('twoStageStage1Hint', language)}
</p>
</div>
{clipboardStatus === 'failed' && (
<div
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
style={{ color: '#F6465D' }}
>
<div>{t('twoStageClipboardManual', language)}</div>
{manualObfuscationValue && (
<code className="mt-2 block select-all rounded bg-black/40 px-2 py-1 text-[11px] text-[#F0B90B]">
{manualObfuscationValue}
</code>
)}
</div>
)}
{error && (
<div
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
style={{ color: '#F6465D' }}
>
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{ background: '#1B1F2B', color: '#848E9C' }}
disabled={processing}
>
{t('twoStageCancel', language)}
</button>
<button
type="button"
onClick={handleStage1Next}
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{
background: processing ? '#3d2e0d' : '#F0B90B',
color: processing ? '#a18a43' : '#000',
opacity: part1.trim().length === 0 ? 0.7 : 1,
}}
disabled={processing || part1.trim().length === 0}
>
{processing ? t('twoStageProcessing', language) : t('twoStageNext', language)}
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('twoStageStage2Title', language)}
</label>
<input
ref={stage2InputRef}
type="password"
value={part2}
onChange={(event) => setPart2(event.target.value)}
placeholder={t('twoStageStage2Placeholder', language)}
className="w-full rounded border border-[#2B3139] bg-[#0F111C] px-3 py-2 text-sm text-[#EAECEF] outline-none focus:ring-2 focus:ring-[#F0B90B]/40"
/>
<p className="mt-2 text-xs" style={{ color: '#848E9C' }}>
{t('twoStageStage2Hint', language)}
</p>
</div>
{clipboardStatus === 'copied' && (
<div
className="rounded border border-[#F0B90B]/40 bg-[#F0B90B]/10 px-3 py-2 text-xs"
style={{ color: '#F0B90B' }}
>
{t('twoStageClipboardSuccess', language)}
</div>
)}
{clipboardStatus === 'failed' && manualObfuscationValue && (
<div
className="rounded border border-[#2B3139] bg-[#141821] px-3 py-2 text-xs"
style={{ color: '#EAECEF' }}
>
{t('twoStageClipboardReminder', language)}
</div>
)}
{error && (
<div
className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs"
style={{ color: '#F6465D' }}
>
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setStage(1)
setPart2('')
setError(null)
setClipboardStatus('idle')
}}
className="rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{ background: '#1B1F2B', color: '#848E9C' }}
>
{t('twoStageBack', language)}
</button>
<button
type="button"
onClick={handleSubmit}
className="flex-1 rounded px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.01]"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('twoStageSubmit', language)}
</button>
</div>
</div>
)}
</div>
</div>
)
return createPortal(modalContent, document.body)
}
+26 -2
View File
@@ -1,4 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getSystemConfig } from '../lib/config';
import { CryptoService } from '../lib/crypto';
interface User {
id: string
@@ -61,12 +63,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = async (email: string, password: string) => {
try {
const systemConfig = await getSystemConfig()
if (!systemConfig.rsa_public_key) {
throw new Error('系统未配置登录所需的RSA公钥')
}
await CryptoService.initialize(systemConfig.rsa_public_key)
const sessionId = sessionStorage.getItem('session_id') || ''
const requestBody = {
email_encrypted: await CryptoService.encryptSensitiveData(
email,
email,
sessionId
),
password_encrypted: await CryptoService.encryptSensitiveData(
password,
email,
sessionId
),
}
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
body: JSON.stringify(requestBody),
})
const data = await response.json()
@@ -84,6 +107,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { success: false, message: data.error }
}
} catch (error) {
console.error('Login request failed:', error)
return { success: false, message: '登录失败,请重试' }
}
+64
View File
@@ -204,6 +204,42 @@ export const translations = {
'API wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)',
asterUsdtWarning:
'Important: Aster only tracks USDT balance. Please ensure you use USDT as margin currency to avoid P&L calculation errors caused by price fluctuations of other assets (BNB, ETH, etc.)',
hyperliquidExchangeName: 'Hyperliquid',
asterExchangeName: 'Aster DEX',
secureInputButton: 'Secure Input',
secureInputReenter: 'Re-enter Securely',
secureInputClear: 'Clear',
secureInputHint:
'Captured via secure two-step input. Use “Re-enter Securely” to update this value.',
twoStageModalTitle: 'Secure Key Input',
twoStageModalDescription:
'Use a two-step flow to enter your {length}-character private key safely.',
twoStageStage1Title: 'Step 1 · Enter the first half',
twoStageStage1Placeholder: 'First 32 characters (include 0x if present)',
twoStageStage1Hint:
'Continuing copies an obfuscation string to your clipboard as a diversion.',
twoStageStage1Error: 'Please enter the first part before continuing.',
twoStageNext: 'Next',
twoStageProcessing: 'Processing…',
twoStageCancel: 'Cancel',
twoStageStage2Title: 'Step 2 · Enter the rest',
twoStageStage2Placeholder: 'Remaining characters of your private key',
twoStageStage2Hint:
'Paste the obfuscation string somewhere neutral, then finish entering your key.',
twoStageClipboardSuccess:
'Obfuscation string copied. Paste it into any text field once before completing.',
twoStageClipboardReminder:
'Remember to paste the obfuscation string before submitting to avoid clipboard leaks.',
twoStageClipboardManual:
'Automatic copy failed. Copy the obfuscation string below manually.',
twoStageClipboardFailed:
'Automatic clipboard copy failed. Please copy the obfuscation string manually.',
twoStageClipboardInstruction:
'Obfuscation string copied. Paste it once before finishing the input.',
twoStageBack: 'Back',
twoStageSubmit: 'Confirm',
twoStageInvalidFormat:
'Invalid private key format. Expected {length} hexadecimal characters (optional 0x prefix).',
testnetDescription:
'Enable to connect to exchange test environment for simulated trading',
securityWarning: 'Security Warning',
@@ -700,6 +736,34 @@ export const translations = {
'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
asterUsdtWarning:
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
hyperliquidExchangeName: 'Hyperliquid',
asterExchangeName: 'Aster DEX',
secureInputButton: '安全输入',
secureInputReenter: '重新安全输入',
secureInputClear: '清除',
secureInputHint: '已通过安全双阶段输入设置。若需修改,请点击“重新安全输入”。',
twoStageModalTitle: '安全私钥输入',
twoStageModalDescription: '使用双阶段流程安全输入长度为 {length} 的私钥。',
twoStageStage1Title: '步骤一 · 输入前半段',
twoStageStage1Placeholder: '前 32 位字符(若有 0x 前缀请保留)',
twoStageStage1Hint: '继续后会将扰动字符串复制到剪贴板,用于迷惑剪贴板监控。',
twoStageStage1Error: '请先输入第一段私钥。',
twoStageNext: '下一步',
twoStageProcessing: '处理中…',
twoStageCancel: '取消',
twoStageStage2Title: '步骤二 · 输入剩余部分',
twoStageStage2Placeholder: '剩余的私钥字符',
twoStageStage2Hint: '将扰动字符串粘贴到任意位置后,再完成私钥输入。',
twoStageClipboardSuccess:
'扰动字符串已复制。请在完成前在任意文本处粘贴一次以迷惑剪贴板记录。',
twoStageClipboardReminder:
'记得在提交前粘贴一次扰动字符串,降低剪贴板泄漏风险。',
twoStageClipboardManual: '自动复制失败,请手动复制下面的扰动字符串。',
twoStageClipboardFailed: '自动写入剪贴板失败,请手动复制扰动字符串。',
twoStageClipboardInstruction: '扰动字符串已复制,请在完成输入前粘贴一次。',
twoStageBack: '返回',
twoStageSubmit: '确认',
twoStageInvalidFormat: '私钥格式不正确,应为 {length} 位十六进制字符(可选 0x 前缀)。',
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
securityWarning: '安全提示',
saveConfiguration: '保存配置',
+36 -1
View File
@@ -11,7 +11,8 @@ import type {
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CompetitionData,
} from '../types'
} from '../types';
import { CryptoService } from './crypto';
const API_BASE = '/api'
@@ -165,6 +166,40 @@ export const api = {
if (!res.ok) throw new Error('更新交易所配置失败')
},
// 使用加密传输更新交易所配置
async updateExchangeConfigsEncrypted(request: UpdateExchangeConfigRequest): Promise<void> {
// 从系统配置获取公钥
const configRes = await fetch(`${API_BASE}/config`);
if (!configRes.ok) throw new Error('获取系统配置失败');
const config = await configRes.json();
if (!config.rsa_public_key) {
throw new Error('系统未配置RSA公钥,无法使用加密传输');
}
// 初始化加密服务
await CryptoService.initialize(config.rsa_public_key);
// 获取用户信息(从localStorage或其他地方)
const userId = localStorage.getItem('user_id') || '';
const sessionId = sessionStorage.getItem('session_id') || '';
// 加密敏感数据
const encryptedPayload = await CryptoService.encryptSensitiveData(
JSON.stringify(request),
userId,
sessionId
);
// 发送加密数据
const res = await fetch(`${API_BASE}/exchanges/encrypted`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(encryptedPayload),
});
if (!res.ok) throw new Error('更新交易所配置失败');
},
// 获取系统状态(支持trader_id
async getStatus(traderId?: string): Promise<SystemStatus> {
const url = traderId
+2
View File
@@ -3,6 +3,8 @@ export interface SystemConfig {
default_coins?: string[]
btc_eth_leverage?: number
altcoin_leverage?: number
rsa_public_key?: string
rsa_key_id?: string
}
let configPromise: Promise<SystemConfig> | null = null
+142
View File
@@ -0,0 +1,142 @@
export interface EncryptedPayload {
wrappedKey: string; // RSA-OAEP(K)
iv: string; // 12 bytes
ciphertext: string; // AES-GCM 输出(含 tag)
aad?: string; // 可选:额外认证数据
kid?: string; // 可选:服务端公钥标识
ts?: number; // 可选:unix 秒,用于重放保护
}
export class CryptoService {
private static publicKey: CryptoKey | null = null;
private static publicKeyPEM: string | null = null;
static async initialize(publicKeyPEM: string) {
if (this.publicKey && this.publicKeyPEM === publicKeyPEM) {
return;
}
this.publicKeyPEM = publicKeyPEM;
this.publicKey = await this.importPublicKey(publicKeyPEM);
}
private static async importPublicKey(pem: string): Promise<CryptoKey> {
const pemHeader = '-----BEGIN PUBLIC KEY-----';
const pemFooter = '-----END PUBLIC KEY-----';
const headerIndex = pem.indexOf(pemHeader);
const footerIndex = pem.indexOf(pemFooter);
if (headerIndex === -1 || footerIndex === -1 || headerIndex >= footerIndex) {
throw new Error('Invalid PEM formatted public key');
}
const pemContents = pem
.substring(headerIndex + pemHeader.length, footerIndex)
.replace(/\s+/g, ''); // 移除所有空白字符(包括换行符、空格等)
const binaryDerString = atob(pemContents);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
return crypto.subtle.importKey(
'spki',
binaryDer,
{
name: 'RSA-OAEP',
hash: 'SHA-256',
},
false,
['encrypt']
);
}
static async encryptSensitiveData(
plaintext: string,
userId?: string,
sessionId?: string
): Promise<EncryptedPayload> {
if (!this.publicKey) {
throw new Error('Crypto service not initialized. Call initialize() first.');
}
// 1. 生成 256-bit AES 密钥
const aesKey = await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256,
},
true,
['encrypt']
);
// 2. 生成 12 字节随机 IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// 3. 准备 AAD (额外认证数据)
const ts = Math.floor(Date.now() / 1000);
const aadObject = {
userId: userId || '',
sessionId: sessionId || '',
ts: ts,
purpose: 'sensitive_data_encryption'
};
const aadString = JSON.stringify(aadObject);
const aadBytes = new TextEncoder().encode(aadString);
// 4. 使用 AES-GCM 加密数据
const plaintextBytes = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: aadBytes,
tagLength: 128, // 16 bytes tag
},
aesKey,
plaintextBytes
);
// 5. 导出 AES 密钥
const aesKeyRaw = await crypto.subtle.exportKey('raw', aesKey);
// 6. 使用 RSA-OAEP 加密 AES 密钥
const wrappedKey = await crypto.subtle.encrypt(
{
name: 'RSA-OAEP',
},
this.publicKey,
aesKeyRaw
);
// 7. 转换为 base64url 格式
return {
wrappedKey: this.arrayBufferToBase64Url(wrappedKey),
iv: this.arrayBufferToBase64Url(iv),
ciphertext: this.arrayBufferToBase64Url(ciphertext),
aad: this.arrayBufferToBase64Url(aadBytes),
kid: 'rsa-key-2025-11-05',
ts: ts,
};
}
private static arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
static async encryptWalletPrivateKey(privateKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
return this.encryptSensitiveData(privateKey, userId, sessionId);
}
static async encryptExchangeSecret(secretKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
return this.encryptSensitiveData(secretKey, userId, sessionId);
}
}