From 7f85274a960e8cdeb809822db72067b60b605645 Mon Sep 17 00:00:00 2001 From: WrBug Date: Wed, 3 Dec 2025 06:02:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=A7=81=E9=92=A5?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E5=AD=98=E5=82=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 CryptoUtils 加密工具类,支持 AES-256/CBC/PKCS5Padding 加密 - encryption.key 支持任意长度密钥(通过 SHA-256 哈希成 32 字节) - 修改 AccountService,在保存时加密私钥,在使用时解密 - 修改 CopyOrderTrackingService,在使用私钥前先解密 - 更新 Account 实体注释,说明私钥已加密存储 - 添加向后兼容支持:如果私钥未加密(旧数据),直接返回明文 --- .../com/wrbug/polymarketbot/entity/Account.kt | 2 +- .../polymarketbot/service/AccountService.kt | 42 +++++- .../service/CopyOrderTrackingService.kt | 38 ++++- .../wrbug/polymarketbot/util/CryptoUtils.kt | 130 ++++++++++++++++++ .../src/main/resources/application.properties | 4 + docker/start.sh | 2 +- 6 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt index 003f278..15ec399 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/Account.kt @@ -14,7 +14,7 @@ data class Account( val id: Long? = null, @Column(name = "private_key", nullable = false, length = 500) - val privateKey: String, // 私钥(明文存储) + val privateKey: String, // 私钥(AES 加密存储) @Column(name = "wallet_address", unique = true, nullable = false, length = 42) val walletAddress: String, // 钱包地址(从私钥推导) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt index 16989e7..8eb9c60 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AccountService.kt @@ -25,7 +25,8 @@ class AccountService( private val blockchainService: BlockchainService, private val apiKeyService: PolymarketApiKeyService, private val orderPushService: OrderPushService, - private val orderSigningService: OrderSigningService + private val orderSigningService: OrderSigningService, + private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils ) { private val logger = LoggerFactory.getLogger(AccountService::class.java) @@ -108,9 +109,12 @@ class AccountService( } } - // 7. 创建账户 + // 7. 加密私钥 + val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey) + + // 8. 创建账户 val account = Account( - privateKey = request.privateKey, + privateKey = encryptedPrivateKey, // 存储加密后的私钥 walletAddress = request.walletAddress, proxyAddress = proxyAddress, apiKey = apiKeyCreds.apiKey, @@ -569,6 +573,26 @@ class AccountService( val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$")) } + + /** + * 解密账户私钥 + * 支持向后兼容:如果私钥未加密(明文),直接返回 + */ + fun decryptPrivateKey(account: Account): String { + return try { + // 尝试解密(如果已加密) + if (cryptoUtils.isEncrypted(account.privateKey)) { + cryptoUtils.decrypt(account.privateKey) + } else { + // 向后兼容:如果私钥未加密(可能是旧数据),直接返回 + logger.warn("账户 ${account.id} 的私钥未加密,建议重新导入账户以加密私钥") + account.privateKey + } + } catch (e: Exception) { + logger.error("解密私钥失败: accountId=${account.id}", e) + throw RuntimeException("解密私钥失败: ${e.message}", e) + } + } /** * 查询所有账户的仓位列表 @@ -764,10 +788,13 @@ class AccountService( // 只有 GTD 订单才需要设置具体的过期时间 val expiration = "0" - // 7. 创建并签名订单 + // 7. 解密私钥 + val decryptedPrivateKey = decryptPrivateKey(account) + + // 8. 创建并签名订单 val signedOrder = try { orderSigningService.createAndSignOrder( - privateKey = account.privateKey, + privateKey = decryptedPrivateKey, makerAddress = account.proxyAddress, // 使用代理地址作为 maker tokenId = tokenId, side = "SELL", @@ -1047,9 +1074,12 @@ class AccountService( for ((marketId, marketPositions) in positionsByMarket) { val indexSets = marketPositions.map { it.second } + // 解密私钥 + val decryptedPrivateKey = decryptPrivateKey(account) + // 调用区块链服务赎回仓位 val redeemResult = blockchainService.redeemPositions( - privateKey = account.privateKey, + privateKey = decryptedPrivateKey, proxyAddress = account.proxyAddress, conditionId = marketId, indexSets = indexSets diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt index 1125624..bf20fe5 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/CopyOrderTrackingService.kt @@ -33,11 +33,32 @@ class CopyOrderTrackingService( private val leaderRepository: LeaderRepository, private val orderSigningService: OrderSigningService, private val blockchainService: BlockchainService, - private val retrofitFactory: RetrofitFactory + private val retrofitFactory: RetrofitFactory, + private val cryptoUtils: com.wrbug.polymarketbot.util.CryptoUtils ) { private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java) + /** + * 解密账户私钥 + * 支持向后兼容:如果私钥未加密(明文),直接返回 + */ + private fun decryptPrivateKey(account: Account): String { + return try { + // 尝试解密(如果已加密) + if (cryptoUtils.isEncrypted(account.privateKey)) { + cryptoUtils.decrypt(account.privateKey) + } else { + // 向后兼容:如果私钥未加密(可能是旧数据),直接返回 + logger.warn("账户 ${account.id} 的私钥未加密,建议重新导入账户以加密私钥") + account.privateKey + } + } catch (e: Exception) { + logger.error("解密私钥失败: accountId=${account.id}", e) + throw RuntimeException("解密私钥失败: ${e.message}", e) + } + } + /** * 处理交易事件(WebSocket 或轮询) * 根据交易方向调用相应的处理方法 @@ -214,10 +235,13 @@ class CopyOrderTrackingService( account.walletAddress ) + // 解密私钥 + val decryptedPrivateKey = decryptPrivateKey(account) + // 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名) val createOrderResult = createOrderWithRetry( clobApi = clobApi, - privateKey = account.privateKey, + privateKey = decryptedPrivateKey, makerAddress = account.proxyAddress, tokenId = tokenId, side = "BUY", @@ -442,9 +466,12 @@ class CopyOrderTrackingService( val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), template, isBuy = false) // 7. 创建并签名卖出订单 + // 解密私钥 + val decryptedPrivateKey = decryptPrivateKey(account) + val signedOrder = try { orderSigningService.createAndSignOrder( - privateKey = account.privateKey, + privateKey = decryptedPrivateKey, makerAddress = account.proxyAddress, tokenId = tokenId, side = "SELL", @@ -479,9 +506,12 @@ class CopyOrderTrackingService( ) // 10. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名) + // 解密私钥 + val decryptedPrivateKey = decryptPrivateKey(account) + val createOrderResult = createOrderWithRetry( clobApi = clobApi, - privateKey = account.privateKey, + privateKey = decryptedPrivateKey, makerAddress = account.proxyAddress, tokenId = tokenId, side = "SELL", diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt new file mode 100644 index 0000000..395787c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/CryptoUtils.kt @@ -0,0 +1,130 @@ +package com.wrbug.polymarketbot.util + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * 加密工具类 + * 用于加密/解密敏感数据(如私钥) + * 使用 AES-256 加密算法 + */ +@Component +class CryptoUtils { + + @Value("\${encryption.key:\${jwt.secret}}") + private lateinit var encryptionKey: String + + private val ALGORITHM = "AES" + // 使用 AES/CBC/PKCS5Padding 模式,明确支持 AES-256 + // 注意:如果 JVM 不支持 256 位密钥,可能需要安装 JCE 无限强度策略文件 + private val TRANSFORMATION = "AES/CBC/PKCS5Padding" + + /** + * 获取加密密钥(从配置的密钥派生 32 字节密钥) + * + * 支持任意长度的密钥: + * - 如果密钥是十六进制字符串(64 字符或更长),会解析为字节数组 + * - 如果是普通字符串,会使用 UTF-8 编码转换为字节数组 + * - 无论输入多长,都会通过 SHA-256 哈希成固定的 32 字节(256 位) + * + * 这样设计的好处: + * 1. 支持任意长度的密钥(短密钥、长密钥都可以) + * 2. 确保密钥长度固定为 32 字节,满足 AES-256 要求 + * 3. 即使密钥很短,通过哈希后也能提供足够的安全性 + */ + private fun getSecretKey(): SecretKeySpec { + val keyBytes = if (encryptionKey.length >= 64 && encryptionKey.matches(Regex("^[0-9a-fA-F]+$"))) { + // 十六进制字符串,解析为字节数组 + encryptionKey.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } else { + // 普通字符串,使用 UTF-8 编码 + encryptionKey.toByteArray(StandardCharsets.UTF_8) + } + + // 使用 SHA-256 哈希确保密钥长度为 32 字节(256 位) + // 无论输入密钥多长,都会哈希成固定的 32 字节 + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(keyBytes) + val hash = messageDigest.digest() + + return SecretKeySpec(hash, ALGORITHM) + } + + /** + * 加密数据 + * 使用 AES-256/CBC/PKCS5Padding 模式 + * + * @param plainText 明文 + * @return Base64 编码的密文(包含 IV) + */ + fun encrypt(plainText: String): String { + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + val secretKey = getSecretKey() + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + // 获取 IV(初始化向量) + val iv = cipher.iv + val encryptedBytes = cipher.doFinal(plainText.toByteArray(StandardCharsets.UTF_8)) + + // 将 IV 和加密数据组合:IV (16 字节) + 加密数据 + val combined = ByteArray(iv.size + encryptedBytes.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size) + + Base64.getEncoder().encodeToString(combined) + } catch (e: Exception) { + throw RuntimeException("加密失败: ${e.message}", e) + } + } + + /** + * 解密数据 + * 使用 AES-256/CBC/PKCS5Padding 模式 + * + * @param encryptedText Base64 编码的密文(包含 IV) + * @return 明文 + */ + fun decrypt(encryptedText: String): String { + return try { + val combined = Base64.getDecoder().decode(encryptedText) + + // 提取 IV(前 16 字节)和加密数据 + val iv = ByteArray(16) + System.arraycopy(combined, 0, iv, 0, 16) + val encryptedBytes = ByteArray(combined.size - 16) + System.arraycopy(combined, 16, encryptedBytes, 0, encryptedBytes.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val secretKey = getSecretKey() + cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv)) + val decryptedBytes = cipher.doFinal(encryptedBytes) + + String(decryptedBytes, StandardCharsets.UTF_8) + } catch (e: Exception) { + throw RuntimeException("解密失败: ${e.message}", e) + } + } + + /** + * 检查字符串是否为加密后的数据(Base64 格式) + * 注意:这不是完全可靠的检测方法,仅用于向后兼容 + */ + fun isEncrypted(text: String): Boolean { + return try { + // 尝试 Base64 解码,如果成功且长度合理,可能是加密数据 + val decoded = Base64.getDecoder().decode(text) + // 加密后的数据长度应该是 16 字节的倍数(AES 块大小) + decoded.size % 16 == 0 && decoded.size >= 16 + } catch (e: Exception) { + false + } + } +} + diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index fc3e9d5..7309443 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -67,6 +67,10 @@ jwt.expiration=604800000 # 1天(毫秒),超过此时间但未过期时自动刷新 jwt.refresh-threshold=86400000 +# 加密配置(用于加密私钥等敏感数据) +# 如果不设置,默认使用 jwt.secret +encryption.key=${ENCRYPTION_KEY:${jwt.secret}} + # 密码重置配置 admin.reset-password.key=${ADMIN_RESET_PASSWORD_KEY:change-me-in-production} diff --git a/docker/start.sh b/docker/start.sh index f44472f..8dece49 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -19,7 +19,7 @@ trap cleanup SIGTERM SIGINT # 启动后端服务(以 appuser 用户运行,后台运行) echo "启动后端服务..." -su appuser -c "cd /app && java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod}" & +java -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} & BACKEND_PID=$! # 等待后端服务启动