From 81ffbbd6b1f3650b00c9e9ce7b70029b7098ec23 Mon Sep 17 00:00:00 2001 From: rensumo <15206641+rensumo@user.noreply.gitee.com> Date: Sun, 22 Feb 2026 12:24:47 +0800 Subject: [PATCH] feat: harden donated-account failure token and document key usage --- .gitignore | 1 + README.md | 12 + parser/doc/auth-param/AUTH_PARAM_GUIDE.md | 29 +- web-front/src/views/Home.vue | 82 ++++-- .../cn/qaiu/lz/web/controller/ServerApi.java | 48 ++- .../java/cn/qaiu/lz/web/model/AuthParam.java | 7 + .../cn/qaiu/lz/web/model/DonatedAccount.java | 3 + .../cn/qaiu/lz/web/service/DbService.java | 9 + .../lz/web/service/impl/DbServiceImpl.java | 276 ++++++++++++++---- .../cn/qaiu/lz/web/util/CryptoException.java | 7 + .../java/cn/qaiu/lz/web/util/CryptoUtil.java | 100 +++++++ 11 files changed, 489 insertions(+), 85 deletions(-) create mode 100644 web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java create mode 100644 web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java diff --git a/.gitignore b/.gitignore index 264979c..e0e6875 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ test-filelist.java *.temp *.log *.bak +**/secret.yml *.swp *.swo *~ diff --git a/README.md b/README.md index 19b0c0b..e1e0e7d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,18 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数} > 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。 > [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html) + +#### 密钥作用说明 + +- `server.authEncryptKey` + - 作用:用于 `auth` 参数的 AES 加解密 + - 要求:16位(AES-128) + +- `server.donatedAccountFailureTokenSignKey` + - 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签 + - 目的:防止客户端伪造失败计数请求 + - 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同 + ### 特殊说明 - 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值 diff --git a/parser/doc/auth-param/AUTH_PARAM_GUIDE.md b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md index c3e3f20..39ebdaa 100644 --- a/parser/doc/auth-param/AUTH_PARAM_GUIDE.md +++ b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md @@ -36,6 +36,22 @@ URL解码 → Base64解码 → AES解密 → JSON对象 - **密钥长度**: 16位(128位) - **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置) +### 密钥作用说明(重要) + +当前系统中涉及两类不同用途的密钥: + +1. `server.authEncryptKey` + - 用途:加解密 `auth` 参数(前端/调用方传入的认证信息) + - 影响范围:`/parser`、`/json/parser`、`/v2/linkInfo` 等接口中的 `auth` 参数 + - 注意:这是 **AES 对称加密密钥**,要求 16 位 + +2. `server.donatedAccountFailureTokenSignKey` + - 用途:签名和验签“捐赠账号失败计数 token”(用于防伪造、失败计数) + - 影响范围:捐赠账号失败计数与自动失效逻辑 + - 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串 + +> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。 + ## JSON 模型定义 ### AuthParam 对象 @@ -301,14 +317,25 @@ if (auths != null) { ## 配置说明 -在 `app-dev.yml` 中配置加密密钥: +在 `app-dev.yml` 中配置密钥: ```yaml server: # auth参数加密密钥(16位AES密钥) authEncryptKey: 'your_custom_key16' + + # 捐赠账号失败计数token签名密钥(HMAC) + # 建议使用较长随机字符串,并与 authEncryptKey 不同 + donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key' ``` +### 密钥管理建议 + +- 不要在公开仓库提交生产密钥 +- 建议通过环境变量或私有配置注入 +- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性 +- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受) + ## 更新日志 - **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型 diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index e6857f1..d34a070 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -369,16 +369,34 @@ -
- 当前账号池(共 {{ donateAccountCounts.total }} 个) - - {{ getPanDisplayName(panType) }}: {{ count }} 个 - +
+ + 当前账号池(活跃 {{ donateAccountCounts.active.total }} / 失效 {{ donateAccountCounts.inactive.total }}) + + +
+ 活跃账号 + + {{ getPanDisplayName(panType) }}: {{ count }} 个 + +
+ +
+ 失效账号 + + {{ getPanDisplayName(panType) }}: {{ count }} 个 + +
暂无捐赠账号,成为第一个捐赠者吧! @@ -530,8 +548,11 @@ export default { token: '', remark: '' }, - // 捐赠账号数量统计 { panType: count, total: N } - donateAccountCounts: { total: 0 } + // 捐赠账号数量统计 + donateAccountCounts: { + active: { total: 0 }, + inactive: { total: 0 } + } } }, computed: { @@ -804,7 +825,8 @@ export default { if (config.ext3) authObj.ext3 = config.ext3 if (config.ext4) authObj.ext4 = config.ext4 if (config.ext5) authObj.ext5 = config.ext5 - + if (config.donatedAccountToken) authObj.donatedAccountToken = config.donatedAccountToken + // AES 加密 + Base64 + URL 编码 try { const jsonStr = JSON.stringify(authObj) @@ -1310,21 +1332,39 @@ export default { async loadDonateAccountCounts() { try { const response = await axios.get(`${this.baseAPI}/v2/donateAccountCounts`) - // 解包可能的 JsonResult 嵌套: { code, data: { code, data: { QK: 3, total: 4 } } } + // 解包可能的 JsonResult 嵌套 let data = response.data while (data && data.data !== undefined && data.code !== undefined) { data = data.data } + if (data && typeof data === 'object') { - // 确保有 total 字段 - if (data.total === undefined) { - let total = 0 - for (const [key, val] of Object.entries(data)) { - if (typeof val === 'number') total += val + // 兼容新结构: { active: {...}, inactive: {...} } + if (data.active && data.inactive) { + if (data.active.total === undefined) { + data.active.total = Object.entries(data.active) + .filter(([k, v]) => k !== 'total' && typeof v === 'number') + .reduce((s, [, v]) => s + v, 0) + } + if (data.inactive.total === undefined) { + data.inactive.total = Object.entries(data.inactive) + .filter(([k, v]) => k !== 'total' && typeof v === 'number') + .reduce((s, [, v]) => s + v, 0) + } + this.donateAccountCounts = data + } else { + // 兼容旧结构: { QK: 3, total: 4 } + const active = { ...data } + if (active.total === undefined) { + active.total = Object.entries(active) + .filter(([k, v]) => k !== 'total' && typeof v === 'number') + .reduce((s, [, v]) => s + v, 0) + } + this.donateAccountCounts = { + active, + inactive: { total: 0 } } - data.total = total } - this.donateAccountCounts = data } } catch (e) { console.error('加载捐赠账号统计失败:', e) diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java index 4cff5b8..baeae2e 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java @@ -5,6 +5,7 @@ import cn.qaiu.lz.common.util.URLParamUtil; import cn.qaiu.lz.web.model.AuthParam; import cn.qaiu.lz.web.model.CacheLinkInfo; import cn.qaiu.lz.web.service.CacheService; +import cn.qaiu.lz.web.service.DbService; import cn.qaiu.vx.core.annotaions.RouteHandler; import cn.qaiu.vx.core.annotaions.RouteMapping; import cn.qaiu.vx.core.enums.RouteMethod; @@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; public class ServerApi { private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class); + private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class); @RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1) public Future parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd, String auth) { @@ -43,7 +45,10 @@ public class ServerApi { response.putHeader("nfd-cache-hit", res.getCacheHit().toString()) .putHeader("nfd-cache-expires", res.getExpires()), res.getDirectLink(), promise)) - .onFailure(t -> promise.fail(t.fillInStackTrace())); + .onFailure(t -> { + recordDonatedAccountFailureIfNeeded(otherParam, t); + promise.fail(t.fillInStackTrace()); + }); return promise.future(); } @@ -51,7 +56,8 @@ public class ServerApi { public Future parseJson(HttpServerRequest request, String pwd, String auth) { String url = URLParamUtil.parserParams(request); JsonObject otherParam = buildOtherParam(request, auth); - return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam); + return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam) + .onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t)); } @RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET) @@ -106,10 +112,48 @@ public class ServerApi { otherParam.put("authInfo3", authParam.getExt3()); otherParam.put("authInfo4", authParam.getExt4()); otherParam.put("authInfo5", authParam.getExt5()); + if (authParam.getDonatedAccountToken() != null && !authParam.getDonatedAccountToken().isBlank()) { + otherParam.put("donatedAccountToken", authParam.getDonatedAccountToken()); + } log.debug("已解码认证参数: authType={}", authParam.getAuthType()); } } return otherParam; } + + private void recordDonatedAccountFailureIfNeeded(JsonObject otherParam, Throwable cause) { + if (!isLikelyAuthFailure(cause)) { + return; + } + String donatedAccountToken = otherParam.getString("donatedAccountToken"); + if (donatedAccountToken == null || donatedAccountToken.isBlank()) { + return; + } + dbService.recordDonatedAccountFailureByToken(donatedAccountToken) + .onFailure(e -> log.warn("记录捐赠账号失败次数失败", e)); + } + + private boolean isLikelyAuthFailure(Throwable cause) { + if (cause == null) { + return false; + } + String msg = cause.getMessage(); + if (msg == null || msg.isBlank()) { + return false; + } + String lower = msg.toLowerCase(); + return lower.contains("auth") + || lower.contains("token") + || lower.contains("cookie") + || lower.contains("password") + || lower.contains("credential") + || lower.contains("401") + || lower.contains("403") + || lower.contains("unauthorized") + || lower.contains("forbidden") + || lower.contains("expired") + || lower.contains("登录") + || lower.contains("认证"); + } } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java b/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java index 4666911..6148b6a 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java @@ -93,6 +93,11 @@ public class AuthParam implements ToJson { */ private String ext5; + /** + * 捐赠账号失败计数令牌(服务端签发,不可伪造) + */ + private String donatedAccountToken; + /** * 从 JsonObject 构造 */ @@ -111,6 +116,7 @@ public class AuthParam implements ToJson { this.ext3 = json.getString("ext3"); this.ext4 = json.getString("ext4"); this.ext5 = json.getString("ext5"); + this.donatedAccountToken = json.getString("donatedAccountToken"); } /** @@ -129,6 +135,7 @@ public class AuthParam implements ToJson { if (ext3 != null) json.put("ext3", ext3); if (ext4 != null) json.put("ext4", ext4); if (ext5 != null) json.put("ext5", ext5); + if (donatedAccountToken != null) json.put("donatedAccountToken", donatedAccountToken); return json; } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java b/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java index f374a04..6947eaa 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java @@ -47,6 +47,9 @@ public class DonatedAccount { @Constraint(notNull = true, defaultValue = "true") private Boolean enabled = true; // 是否启用 + @Constraint(notNull = true, defaultValue = "0") + private Integer failCount = 0; // 失败次数,达到阈值自动禁用 + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createTime = new Date(); } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java b/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java index 8dcc8d6..a996b4a 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java @@ -67,4 +67,13 @@ public interface DbService extends BaseAsyncService { */ Future getRandomDonatedAccount(String panType); + /** + * 签发捐赠账号失败计数令牌(服务端临时令牌) + */ + Future issueDonatedAccountFailureToken(Long accountId); + + /** + * 使用服务端失败计数令牌记录捐赠账号解析失败 + */ + Future recordDonatedAccountFailureByToken(String failureToken); } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java index 7ebee36..01037f7 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java @@ -2,8 +2,9 @@ package cn.qaiu.lz.web.service.impl; import cn.qaiu.db.pool.JDBCPoolInit; import cn.qaiu.lz.common.model.UserInfo; -import cn.qaiu.lz.web.service.DbService; import cn.qaiu.lz.web.model.StatisticsInfo; +import cn.qaiu.lz.web.service.DbService; +import cn.qaiu.lz.web.util.CryptoUtil; import cn.qaiu.vx.core.annotaions.Service; import cn.qaiu.vx.core.model.JsonResult; import io.vertx.core.Future; @@ -14,8 +15,13 @@ import io.vertx.sqlclient.Row; import io.vertx.sqlclient.Tuple; import io.vertx.sqlclient.templates.SqlTemplate; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; @@ -28,6 +34,11 @@ import java.util.List; @Slf4j @Service public class DbServiceImpl implements DbService { + private static final int DONATED_ACCOUNT_DISABLE_THRESHOLD = 3; + private static final long FAILURE_TOKEN_TTL_MILLIS = 10 * 60 * 1000L; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG = "donatedAccountFailureTokenSignKey"; + private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK = "nfd_donate_fail_token_sign_2026"; @Override public Future sayOk(String data) { log.info("say ok1 -> wait..."); @@ -271,88 +282,231 @@ public class DbServiceImpl implements DbService { @Override public Future saveDonatedAccount(JsonObject account) { JDBCPool client = JDBCPoolInit.instance().getPool(); - Promise promise = Promise.promise(); - String sql = """ - INSERT INTO donated_account - (pan_type, auth_type, username, password, token, remark, ip, enabled, create_time) - VALUES (?, ?, ?, ?, ?, ?, ?, true, NOW()) - """; + Future encryptedUsername = CryptoUtil.encrypt(account.getString("username")); + Future encryptedPassword = CryptoUtil.encrypt(account.getString("password")); + Future encryptedToken = CryptoUtil.encrypt(account.getString("token")); - client.preparedQuery(sql) - .execute(Tuple.of( - account.getString("panType"), - account.getString("authType"), - account.getString("username"), - account.getString("password"), - account.getString("token"), - account.getString("remark"), - account.getString("ip") - )) - .onSuccess(res -> { - promise.complete(JsonResult.success("捐赠成功").toJsonObject()); - }) - .onFailure(e -> { - log.error("saveDonatedAccount failed", e); - promise.fail(e); - }); + return ensureFailCountColumn(client).compose(v -> + Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> { + String sql = """ + INSERT INTO donated_account + (pan_type, auth_type, username, password, token, remark, ip, enabled, fail_count, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, true, 0, NOW()) + """; - return promise.future(); + return client.preparedQuery(sql) + .execute(Tuple.of( + account.getString("panType"), + account.getString("authType"), + encryptedUsername.result(), + encryptedPassword.result(), + encryptedToken.result(), + account.getString("remark"), + account.getString("ip") + )) + .map(res -> JsonResult.success("捐赠成功").toJsonObject()) + .onFailure(e -> log.error("saveDonatedAccount failed", e)); + })); } @Override public Future getDonatedAccountCounts() { JDBCPool client = JDBCPoolInit.instance().getPool(); - Promise promise = Promise.promise(); - String sql = "SELECT pan_type, COUNT(*) as count FROM donated_account WHERE enabled = true GROUP BY pan_type"; + String sql = "SELECT pan_type, enabled, COUNT(*) as count FROM donated_account GROUP BY pan_type, enabled"; + + return client.query(sql).execute().map(rows -> { + JsonObject result = new JsonObject(); + JsonObject activeCounts = new JsonObject(); + JsonObject inactiveCounts = new JsonObject(); + int totalActive = 0; + int totalInactive = 0; - client.query(sql).execute().onSuccess(rows -> { - JsonObject counts = new JsonObject(); - int total = 0; for (Row row : rows) { String panType = row.getString("pan_type"); - Integer count = row.getInteger("count"); - counts.put(panType, count); - total += count; - } - counts.put("total", total); - promise.complete(JsonResult.data(counts).toJsonObject()); - }).onFailure(e -> { - log.error("getDonatedAccountCounts failed", e); - promise.fail(e); - }); + boolean enabled = row.getBoolean("enabled"); + int count = row.getInteger("count"); - return promise.future(); + if (enabled) { + activeCounts.put(panType, count); + totalActive += count; + } else { + inactiveCounts.put(panType, count); + totalInactive += count; + } + } + + activeCounts.put("total", totalActive); + inactiveCounts.put("total", totalInactive); + + result.put("active", activeCounts); + result.put("inactive", inactiveCounts); + + return JsonResult.data(result).toJsonObject(); + }).onFailure(e -> log.error("getDonatedAccountCounts failed", e)); } @Override public Future getRandomDonatedAccount(String panType) { JDBCPool client = JDBCPoolInit.instance().getPool(); - Promise promise = Promise.promise(); String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1"; - client.preparedQuery(sql) - .execute(Tuple.of(panType)) - .onSuccess(rows -> { - if (rows.size() > 0) { - Row row = rows.iterator().next(); - JsonObject account = new JsonObject(); - account.put("authType", row.getString("auth_type")); - account.put("username", row.getString("username")); - account.put("password", row.getString("password")); - account.put("token", row.getString("token")); - promise.complete(JsonResult.data(account).toJsonObject()); - } else { - promise.complete(JsonResult.data(new JsonObject()).toJsonObject()); - } - }) - .onFailure(e -> { - log.error("getRandomDonatedAccount failed", e); - promise.fail(e); - }); + return client.preparedQuery(sql) + .execute(Tuple.of(panType)) + .compose(rows -> { + if (rows.size() > 0) { + Row row = rows.iterator().next(); + Future usernameFuture = decryptOrPlain(row.getString("username")); + Future passwordFuture = decryptOrPlain(row.getString("password")); + Future tokenFuture = decryptOrPlain(row.getString("token")); + Future failureTokenFuture = issueDonatedAccountFailureToken(row.getLong("id")); + + return Future.all(usernameFuture, passwordFuture, tokenFuture, failureTokenFuture) + .map(compositeFuture -> { + JsonObject account = new JsonObject(); + account.put("authType", row.getString("auth_type")); + account.put("username", usernameFuture.result()); + account.put("password", passwordFuture.result()); + account.put("token", tokenFuture.result()); + account.put("donatedAccountToken", failureTokenFuture.result()); + return JsonResult.data(account).toJsonObject(); + }); + } else { + return Future.succeededFuture(JsonResult.data(new JsonObject()).toJsonObject()); + } + }) + .onFailure(e -> log.error("getRandomDonatedAccount failed", e)); + } + + @Override + public Future issueDonatedAccountFailureToken(Long accountId) { + if (accountId == null) { + return Future.failedFuture("accountId is null"); + } + try { + long issuedAt = System.currentTimeMillis(); + String payload = accountId + ":" + issuedAt; + String signature = hmacSha256(payload); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8)) + + "." + + Base64.getUrlEncoder().withoutPadding().encodeToString(signature.getBytes(StandardCharsets.UTF_8)); + return Future.succeededFuture(token); + } catch (Exception e) { + return Future.failedFuture(e); + } + } + + @Override + public Future recordDonatedAccountFailureByToken(String failureToken) { + JDBCPool client = JDBCPoolInit.instance().getPool(); + + Long accountId; + try { + accountId = parseAndVerifyFailureToken(failureToken); + } catch (Exception e) { + return Future.failedFuture(e); + } + + String updateSql = """ + UPDATE donated_account + SET fail_count = fail_count + 1, + enabled = CASE + WHEN fail_count + 1 >= ? THEN false + ELSE enabled + END + WHERE id = ? + """; + + return ensureFailCountColumn(client) + .compose(v -> client.preparedQuery(updateSql) + .execute(Tuple.of(DONATED_ACCOUNT_DISABLE_THRESHOLD, accountId)) + .mapEmpty()) + .onFailure(e -> log.error("recordDonatedAccountFailureByToken failed", e)); + } + + private Future ensureFailCountColumn(JDBCPool client) { + Promise promise = Promise.promise(); + String sql = "ALTER TABLE donated_account ADD COLUMN IF NOT EXISTS fail_count INT DEFAULT 0 NOT NULL"; + client.query(sql).execute() + .onSuccess(res -> promise.complete()) + .onFailure(e -> { + String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); + if (!(msg.contains("duplicate") || msg.contains("exists") || msg.contains("already"))) { + log.warn("ensure fail_count column failed, continue without schema migration", e); + } + promise.complete(); + }); return promise.future(); } + + private Future decryptOrPlain(String value) { + if (value == null) { + return Future.succeededFuture(null); + } + if (!isLikelyEncrypted(value)) { + return Future.succeededFuture(value); + } + return CryptoUtil.decrypt(value).recover(e -> { + log.warn("decrypt donated account field failed, fallback to plaintext", e); + return Future.succeededFuture(value); + }); + } + + private boolean isLikelyEncrypted(String value) { + try { + byte[] decoded = Base64.getDecoder().decode(value); + return decoded.length > 16; + } catch (Exception e) { + return false; + } + } + + private Long parseAndVerifyFailureToken(String token) throws Exception { + if (token == null || token.isBlank() || !token.contains(".")) { + throw new IllegalArgumentException("invalid donated account token"); + } + String[] parts = token.split("\\.", 2); + String payload = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8); + String signature = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); + String expected = hmacSha256(payload); + if (!expected.equals(signature)) { + throw new IllegalArgumentException("donated account token signature invalid"); + } + + String[] payloadParts = payload.split(":", 2); + if (payloadParts.length != 2) { + throw new IllegalArgumentException("invalid donated account token payload"); + } + Long accountId = Long.parseLong(payloadParts[0]); + long issuedAt = Long.parseLong(payloadParts[1]); + if (System.currentTimeMillis() - issuedAt > FAILURE_TOKEN_TTL_MILLIS) { + throw new IllegalArgumentException("donated account token expired"); + } + return accountId; + } + + private String hmacSha256(String payload) throws Exception { + String secret = getDonatedAccountFailureTokenSignKey(); + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(digest); + } + + private String getDonatedAccountFailureTokenSignKey() { + try { + String configKey = cn.qaiu.vx.core.util.SharedDataUtil + .getJsonStringForServerConfig(DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG); + if (StringUtils.isNotBlank(configKey)) { + return configKey; + } + } catch (Exception e) { + log.debug("读取捐赠账号失败计数签名密钥失败,使用默认值: {}", e.getMessage()); + } + return DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK; + } } + diff --git a/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java new file mode 100644 index 0000000..291aa6b --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java @@ -0,0 +1,7 @@ +package cn.qaiu.lz.web.util; + +public class CryptoException extends RuntimeException { + public CryptoException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java new file mode 100644 index 0000000..a7c6b44 --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java @@ -0,0 +1,100 @@ +package cn.qaiu.lz.web.util; + +import cn.qaiu.vx.core.util.ConfigUtil; +import cn.qaiu.vx.core.util.VertxHolder; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +public class CryptoUtil { + + private static final Logger logger = LoggerFactory.getLogger(CryptoUtil.class); + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; // 96 bits + private static final int GCM_TAG_LENGTH = 16; // 128 bits + + private static Future secretKeyFuture; + + static { + Vertx vertx = VertxHolder.getVertxInstance(); + if (vertx != null) { + secretKeyFuture = ConfigUtil.readYamlConfig("secret", vertx) + .map(config -> { + String key = config.getJsonObject("encrypt").getString("key"); + if (key == null || key.length() != 32) { + throw new IllegalArgumentException("Invalid AES key length in secret.yml. Key must be 32 bytes."); + } + return new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"); + }) + .onFailure(err -> logger.error("Failed to load encryption key from secret.yml", err)); + } else { + logger.error("Vertx instance is not available for CryptoUtil initialization."); + secretKeyFuture = Future.failedFuture("Vertx instance not available."); + } + } + + public static Future encrypt(String strToEncrypt) { + if (strToEncrypt == null) { + return Future.succeededFuture(null); + } + return secretKeyFuture.compose(secretKey -> { + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + SecureRandom random = new SecureRandom(); + random.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec); + + byte[] cipherText = cipher.doFinal(strToEncrypt.getBytes(StandardCharsets.UTF_8)); + + // Prepend IV to ciphertext + ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length); + byteBuffer.put(iv); + byteBuffer.put(cipherText); + + return Future.succeededFuture(Base64.getEncoder().encodeToString(byteBuffer.array())); + } catch (Exception e) { + return Future.failedFuture(new CryptoException("Encryption failed", e)); + } + }); + } + + public static Future decrypt(String strToDecrypt) { + if (strToDecrypt == null) { + return Future.succeededFuture(null); + } + return secretKeyFuture.compose(secretKey -> { + try { + byte[] decodedBytes = Base64.getDecoder().decode(strToDecrypt); + + // Extract IV from the beginning of the decoded bytes + ByteBuffer byteBuffer = ByteBuffer.wrap(decodedBytes); + byte[] iv = new byte[GCM_IV_LENGTH]; + byteBuffer.get(iv); + byte[] cipherText = new byte[byteBuffer.remaining()]; + byteBuffer.get(cipherText); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec); + + byte[] decryptedText = cipher.doFinal(cipherText); + + return Future.succeededFuture(new String(decryptedText, StandardCharsets.UTF_8)); + } catch (Exception e) { + return Future.failedFuture(new CryptoException("Decryption failed", e)); + } + }); + } +}