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,
|
val id: Long? = null,
|
||||||
|
|
||||||
@Column(name = "private_key", nullable = false, length = 500)
|
@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)
|
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ class AccountService(
|
|||||||
private val blockchainService: BlockchainService,
|
private val blockchainService: BlockchainService,
|
||||||
private val apiKeyService: PolymarketApiKeyService,
|
private val apiKeyService: PolymarketApiKeyService,
|
||||||
private val orderPushService: OrderPushService,
|
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)
|
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(
|
val account = Account(
|
||||||
privateKey = request.privateKey,
|
privateKey = encryptedPrivateKey, // 存储加密后的私钥
|
||||||
walletAddress = request.walletAddress,
|
walletAddress = request.walletAddress,
|
||||||
proxyAddress = proxyAddress,
|
proxyAddress = proxyAddress,
|
||||||
apiKey = apiKeyCreds.apiKey,
|
apiKey = apiKeyCreds.apiKey,
|
||||||
@@ -569,6 +573,26 @@ class AccountService(
|
|||||||
val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey
|
val cleanKey = if (privateKey.startsWith("0x")) privateKey.substring(2) else privateKey
|
||||||
return cleanKey.length == 64 && cleanKey.matches(Regex("^[0-9a-fA-F]{64}$"))
|
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 订单才需要设置具体的过期时间
|
// 只有 GTD 订单才需要设置具体的过期时间
|
||||||
val expiration = "0"
|
val expiration = "0"
|
||||||
|
|
||||||
// 7. 创建并签名订单
|
// 7. 解密私钥
|
||||||
|
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||||
|
|
||||||
|
// 8. 创建并签名订单
|
||||||
val signedOrder = try {
|
val signedOrder = try {
|
||||||
orderSigningService.createAndSignOrder(
|
orderSigningService.createAndSignOrder(
|
||||||
privateKey = account.privateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
makerAddress = account.proxyAddress, // 使用代理地址作为 maker
|
makerAddress = account.proxyAddress, // 使用代理地址作为 maker
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "SELL",
|
side = "SELL",
|
||||||
@@ -1047,9 +1074,12 @@ class AccountService(
|
|||||||
for ((marketId, marketPositions) in positionsByMarket) {
|
for ((marketId, marketPositions) in positionsByMarket) {
|
||||||
val indexSets = marketPositions.map { it.second }
|
val indexSets = marketPositions.map { it.second }
|
||||||
|
|
||||||
|
// 解密私钥
|
||||||
|
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||||
|
|
||||||
// 调用区块链服务赎回仓位
|
// 调用区块链服务赎回仓位
|
||||||
val redeemResult = blockchainService.redeemPositions(
|
val redeemResult = blockchainService.redeemPositions(
|
||||||
privateKey = account.privateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
proxyAddress = account.proxyAddress,
|
proxyAddress = account.proxyAddress,
|
||||||
conditionId = marketId,
|
conditionId = marketId,
|
||||||
indexSets = indexSets
|
indexSets = indexSets
|
||||||
|
|||||||
+34
-4
@@ -33,11 +33,32 @@ class CopyOrderTrackingService(
|
|||||||
private val leaderRepository: LeaderRepository,
|
private val leaderRepository: LeaderRepository,
|
||||||
private val orderSigningService: OrderSigningService,
|
private val orderSigningService: OrderSigningService,
|
||||||
private val blockchainService: BlockchainService,
|
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 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 或轮询)
|
* 处理交易事件(WebSocket 或轮询)
|
||||||
* 根据交易方向调用相应的处理方法
|
* 根据交易方向调用相应的处理方法
|
||||||
@@ -214,10 +235,13 @@ class CopyOrderTrackingService(
|
|||||||
account.walletAddress
|
account.walletAddress
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 解密私钥
|
||||||
|
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||||
|
|
||||||
// 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名)
|
// 调用API创建订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||||
val createOrderResult = createOrderWithRetry(
|
val createOrderResult = createOrderWithRetry(
|
||||||
clobApi = clobApi,
|
clobApi = clobApi,
|
||||||
privateKey = account.privateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
makerAddress = account.proxyAddress,
|
makerAddress = account.proxyAddress,
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
@@ -442,9 +466,12 @@ class CopyOrderTrackingService(
|
|||||||
val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), template, isBuy = false)
|
val sellPrice = calculateAdjustedPrice(leaderSellTrade.price.toSafeBigDecimal(), template, isBuy = false)
|
||||||
|
|
||||||
// 7. 创建并签名卖出订单
|
// 7. 创建并签名卖出订单
|
||||||
|
// 解密私钥
|
||||||
|
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||||
|
|
||||||
val signedOrder = try {
|
val signedOrder = try {
|
||||||
orderSigningService.createAndSignOrder(
|
orderSigningService.createAndSignOrder(
|
||||||
privateKey = account.privateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
makerAddress = account.proxyAddress,
|
makerAddress = account.proxyAddress,
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "SELL",
|
side = "SELL",
|
||||||
@@ -479,9 +506,12 @@ class CopyOrderTrackingService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 10. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
// 10. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||||
|
// 解密私钥
|
||||||
|
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||||
|
|
||||||
val createOrderResult = createOrderWithRetry(
|
val createOrderResult = createOrderWithRetry(
|
||||||
clobApi = clobApi,
|
clobApi = clobApi,
|
||||||
privateKey = account.privateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
makerAddress = account.proxyAddress,
|
makerAddress = account.proxyAddress,
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "SELL",
|
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天(毫秒),超过此时间但未过期时自动刷新
|
# 1天(毫秒),超过此时间但未过期时自动刷新
|
||||||
jwt.refresh-threshold=86400000
|
jwt.refresh-threshold=86400000
|
||||||
|
|
||||||
|
# 加密配置(用于加密私钥等敏感数据)
|
||||||
|
# 如果不设置,默认使用 jwt.secret
|
||||||
|
encryption.key=${ENCRYPTION_KEY:${jwt.secret}}
|
||||||
|
|
||||||
# 密码重置配置
|
# 密码重置配置
|
||||||
admin.reset-password.key=${ADMIN_RESET_PASSWORD_KEY:change-me-in-production}
|
admin.reset-password.key=${ADMIN_RESET_PASSWORD_KEY:change-me-in-production}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ trap cleanup SIGTERM SIGINT
|
|||||||
|
|
||||||
# 启动后端服务(以 appuser 用户运行,后台运行)
|
# 启动后端服务(以 appuser 用户运行,后台运行)
|
||||||
echo "启动后端服务..."
|
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=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# 等待后端服务启动
|
# 等待后端服务启动
|
||||||
|
|||||||
Reference in New Issue
Block a user