feat: 实现私钥加密存储功能

- 创建 CryptoUtils 加密工具类,支持 AES-256/CBC/PKCS5Padding 加密
- encryption.key 支持任意长度密钥(通过 SHA-256 哈希成 32 字节)
- 修改 AccountService,在保存时加密私钥,在使用时解密
- 修改 CopyOrderTrackingService,在使用私钥前先解密
- 更新 Account 实体注释,说明私钥已加密存储
- 添加向后兼容支持:如果私钥未加密(旧数据),直接返回明文
This commit is contained in:
WrBug
2025-12-03 06:02:26 +08:00
parent 86dc772b7d
commit 7f85274a96
6 changed files with 206 additions and 12 deletions
@@ -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
@@ -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
View File
@@ -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=$!
# 等待后端服务启动