feat: 实现私钥加密存储功能
- 创建 CryptoUtils 加密工具类,支持 AES-256/CBC/PKCS5Padding 加密 - encryption.key 支持任意长度密钥(通过 SHA-256 哈希成 32 字节) - 修改 AccountService,在保存时加密私钥,在使用时解密 - 修改 CopyOrderTrackingService,在使用私钥前先解密 - 更新 Account 实体注释,说明私钥已加密存储 - 添加向后兼容支持:如果私钥未加密(旧数据),直接返回明文
This commit is contained in:
@@ -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, // 钱包地址(从私钥推导)
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-4
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+1
-1
@@ -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=$!
|
||||
|
||||
# 等待后端服务启动
|
||||
|
||||
Reference in New Issue
Block a user