From 15f37953d0cbfc0fc4c5d447da308aafc09dd35d Mon Sep 17 00:00:00 2001 From: Raymond <101374892+raymonable@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:24:01 -0400 Subject: [PATCH] feat: implement title server encryption --- .gitignore | 3 ++ config/application.properties | 8 ++-- docker-compose.yml | 1 + .../icu/samnyan/aqua/sega/allnet/AllNet.kt | 15 +----- .../sega/cardmaker/CardMakerController.kt | 2 +- .../aqua/sega/chusan/ChusanController.kt | 37 +++++++++------ .../aqua/sega/chusan/handler/ChusanApis.kt | 18 ++++++++ .../samnyan/aqua/sega/diva/DivaController.kt | 2 +- .../samnyan/aqua/sega/general/BaseHandler.kt | 14 +++++- .../sega/general/filter/CompressionFilter.kt | 46 ++++++++++++++++++- .../aqua/sega/general/model/EncryptionKey.kt | 14 ++++++ .../samnyan/aqua/sega/maimai2/Maimai2Apis.kt | 20 ++++++-- .../sega/maimai2/Maimai2ServletController.kt | 42 ++++++++++------- .../samnyan/aqua/sega/maimai2/model/Repos.kt | 2 +- .../samnyan/aqua/sega/ongeki/OngekiApis.kt | 12 +++++ .../aqua/sega/ongeki/OngekiController.kt | 40 ++++++++++------ .../samnyan/aqua/sega/util/GameDataService.kt | 10 +++- .../samnyan/aqua/sega/wacca/WaccaServer.kt | 2 +- 18 files changed, 214 insertions(+), 74 deletions(-) create mode 100644 src/main/java/icu/samnyan/aqua/sega/general/model/EncryptionKey.kt diff --git a/.gitignore b/.gitignore index 31e40848..d69d476a 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,6 @@ src/main/resources/meta/*/*.json test-diff htmlReport docs/logs + +# Sensitive keys +game_encryption.json \ No newline at end of file diff --git a/config/application.properties b/config/application.properties index 4b23d72e..286119d7 100644 --- a/config/application.properties +++ b/config/application.properties @@ -19,7 +19,7 @@ allnet.server.hide-port=true allnet.server.place-name=AquaDX ## This enables client serial validation during power on request using keychip table in database. ## Only enable this if you know what you are doing. -allnet.server.check-keychip=true +allnet.server.check-keychip=false ## Interval between keychip session clean up checks in ms. Default is 1 day. allnet.server.keychip-ses-clean-interval=86400000 ## Token that haven't been used for this amount of time will be removed from the database. Default is 2 days. @@ -86,9 +86,9 @@ spring.servlet.multipart.max-request-size=20MB ## Database Setting spring.datasource.driver-class-name=org.mariadb.jdbc.Driver -spring.datasource.username=aqua -spring.datasource.password=aqua -spring.datasource.url=jdbc:mariadb://localhost:3306/aqua?allowPublicKeyRetrieval=true&useSSL=false +spring.datasource.username=cat +spring.datasource.password=meow +spring.datasource.url=jdbc:mariadb://localhost:3369/main?allowPublicKeyRetrieval=true&useSSL=false spring.datasource.hikari.maximum-pool-size=10 ################################ diff --git a/docker-compose.yml b/docker-compose.yml index 6ffda56f..36efbc07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: app: + build: . image: hykilpikonna/aquadx:latest ports: - "80:80" diff --git a/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt b/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt index 2240772f..9fa44e84 100644 --- a/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt +++ b/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt @@ -181,20 +181,7 @@ class AllNet( // If keychip authentication is enabled, the game URLs will be set to /gs/{token}/{game}/... val base = if (session != null) "gs/$session" else "g" - return "http://$addr/$base/" + when (gameId) { - "SDBT" -> "chu2/$ver/$session/" - "SDHD" -> "chu3/$ver/" - "SDGS" -> "chu3/$ver/" // International (c3exp) - "SBZV" -> "diva/" - "SDDT" -> "ongeki/$ver/" - "SDEY" -> "mai/" - "SDGA" -> "mai2/" // International (Exp) - "SDGB" -> "mai2/" // International (China) - TODO: Test it - "SDEZ" -> "mai2/" - "SDFE" -> "wacca" // Note: Wacca must not end with a trailing slash - "SDED" -> "card/" - else -> "" - } + return "http://$addr/$base/$gameId/$ver/" } companion object { diff --git a/src/main/java/icu/samnyan/aqua/sega/cardmaker/CardMakerController.kt b/src/main/java/icu/samnyan/aqua/sega/cardmaker/CardMakerController.kt index 489b07cb..6f06f38d 100644 --- a/src/main/java/icu/samnyan/aqua/sega/cardmaker/CardMakerController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/cardmaker/CardMakerController.kt @@ -19,7 +19,7 @@ import java.time.format.DateTimeFormatter * @author samnyan (privateamusement@protonmail.com) */ @RestController -@RequestMapping("/g/card") +@RequestMapping("/g/SDED/{version}") class CardMakerController( val mapper: BasicMapper, @param:Value("\${allnet.server.host:}") val ALLNET_HOST: String, diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt index 714580eb..4bbaa203 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/ChusanController.kt @@ -17,13 +17,18 @@ import icu.samnyan.aqua.spring.Metrics import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory import org.springframework.web.bind.annotation.RestController +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec /** * @author samnyan (privateamusement@protonmail.com) */ @Suppress("unused") @RestController -@API(value = ["/g/chu3/{version}/ChuniServlet", "/g/chu3/{version}"]) +@API(value = [ + "/g/SDHD/{version}/ChuniServlet", "/g/SDHD/{version}", + "/g/SDGS/{version}/ChuniServlet", "/g/SDGS/{version}", +]) class ChusanController( val mapper: StringMapper, val cmMapper: BasicMapper, @@ -37,14 +42,19 @@ class ChusanController( ): MeowApi({ api, resp -> if (resp is String) resp else (if ("CM" in api || api == "GetUserItemApi" ) cmMapper else mapper).write(resp) +}, +@kotlin.ExperimentalStdlibApi +{ endpoint: String -> + db.gameData.chu3GameEncryption.map{ + val suffix = when (it.code) { "SDGS" -> "C3Exp" else -> "" } + SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + .generateSecret( + PBEKeySpec((endpoint + suffix).toCharArray(), it.salt!!.hexToByteArray(), it.iterations, 128) + ).encoded.toHexString().substring(0, 32) + } }) { val log = LoggerFactory.getLogger(ChusanController::class.java) - val noopEndpoint = setOf("UpsertClientBookkeepingApi", "UpsertClientDevelopApi", "UpsertClientErrorApi", - "UpsertClientSettingApi", "UpsertClientTestmodeApi", "CreateTokenApi", "RemoveTokenApi", "UpsertClientUploadApi", - "PrinterLoginApi", "PrinterLogoutApi", "Ping", "GameLogoutApi", "RemoveMatchingMemberApi", - "UpsertClientPlayTimeApi", "UpsertClientGameStartApi", "UpsertClientGameEndApi") - init { chusanInit() } val handlers = initH @@ -60,28 +70,27 @@ class ChusanController( data["c3exp"] = true } + val apiFriendlyName = if (endpoint.lowercase() == endpoint && endpoint.length == 32) + hashes.filter { it.value.contains(api) }.keys.first().str + " (encrypt)" else api + if (api.startsWith("CM") && api !in handlers) api = api.removePrefix("CM") val token = TokenChecker.tokenShort() - log.info("$token : $api < ${data.toJson()}") + log.info("$token : $apiFriendlyName < ${data.toJson()}") val noop = """{"returnCode":"1","apiName":"$api"}""" - if (api !in noopEndpoint && !handlers.containsKey(api)) { - log.warn("$token : $api > not found") + if (!handlers.containsKey(api)) { + log.warn("$token : $apiFriendlyName > not found") return noop } // Only record the counter metrics if the API is known. Metrics.counter("aquadx_chusan_api_call", "api" to api).increment() - if (api in noopEndpoint) { - log.info("$token : $api > no-op") - return noop - } return try { Metrics.timer("aquadx_chusan_api_latency", "api" to api).recordCallable { serialize(api, handlers[api]!!(ctx) ?: noop).also { if (api !in setOf("GetUserItemApi", "GetGameEventApi")) - log.info("$token : $api > ${it.truncate(500)}") + log.info("$token : $apiFriendlyName > ${it.truncate(500)}") } } } catch (e: Exception) { diff --git a/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanApis.kt b/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanApis.kt index 07371a45..45485d6a 100644 --- a/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanApis.kt +++ b/src/main/java/icu/samnyan/aqua/sega/chusan/handler/ChusanApis.kt @@ -431,5 +431,23 @@ fun ChusanController.chusanInit() { // process() """{"returnCode":"1"}""" + + // NOTE: no-op APIs moved to respect encryption + "UpsertClientBookkeeping" static { """{returnCode":1, "apiName":"UpsertClientBookkeepingApi"}""" } + "UpsertClientDevelop" static { """{returnCode":1, "apiName":"UpsertClientDevelopApi"}""" } + "UpsertClientError" static { """{returnCode":1, "apiName":"UpsertClientErrorApi"}""" } + "UpsertClientSetting" static { """{returnCode":1, "apiName":"UpsertClientSettingApi"}""" } + "UpsertClientTestmode" static { """{returnCode":1, "apiName":"UpsertClientTestmodeApi"}""" } + "CreateToken" static { """{returnCode":1, "apiName":"CreateTokenApi"}""" } + "RemoveToken" static { """{returnCode":1, "apiName":"RemoveTokenApi"}""" } + "UpsertClientUpload" static { """{returnCode":1, "apiName":"UpsertClientUploadApi"}""" } + "PrinterLogin" static { """{returnCode":1, "apiName":"PrinterLoginApi"}""" } + "PrinterLogout" static { """{returnCode":1, "apiName":"PrinterLogoutApi"}""" } + "Ping" static { """{returnCode":1, "apiName":"Ping"}""" } + "GameLogout" static { """{returnCode":1, "apiName":"GameLogoutApi"}""" } + "RemoveMatchingMember" static { """{returnCode":1, "apiName":"RemoveMatchingMemberApi"}""" } + "UpsertClientPlayTime" static { """{returnCode":1, "apiName":"UpsertClientPlayTimeApi"}""" } + "UpsertClientGameStart" static { """{returnCode":1, "apiName":"UpsertClientGameStartApi"}""" } + "UpsertClientGameEnd" static { """{returnCode":1, "apiName":"UpsertClientGameEndApi"}""" } } } \ No newline at end of file diff --git a/src/main/java/icu/samnyan/aqua/sega/diva/DivaController.kt b/src/main/java/icu/samnyan/aqua/sega/diva/DivaController.kt index 7e349215..1cab32d8 100644 --- a/src/main/java/icu/samnyan/aqua/sega/diva/DivaController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/diva/DivaController.kt @@ -35,7 +35,7 @@ val DIVA_INIT = mapOf("db_close" to "0,0", "retry_time" to "FFFF") * @author samnyan (privateamusement@protonmail.com) */ @RestController -@RequestMapping("/g/diva") +@RequestMapping("/g/SBZV/{version}") class DivaController( val attendHandler: AttendHandler, val cardProcedureHandler: CardProcedureHandler, diff --git a/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt b/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt index 6bda26e1..34bd81f0 100644 --- a/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt +++ b/src/main/java/icu/samnyan/aqua/sega/general/BaseHandler.kt @@ -31,11 +31,23 @@ typealias PagePost = (MutJDict) -> Unit data class PagedProcessor(val add: JDict?, val fn: PagedHandler, var post: PagePost? = null) // A very :3 way of declaring APIs -abstract class MeowApi(val serialize: (String, Any) -> String) { +abstract class MeowApi( + val serialize: (String, Any) -> String, + val endpointHash: ((String) -> List)? = null +) { val initH = mutableMapOf() + val hashes = mutableMapOf>() infix operator fun String.invoke(fn: SpecialHandler) { if (initH.containsKey("${this}Api")) error("Duplicate API $this found! Someone is not smart 👀") initH["${this}Api"] = fn + if (endpointHash != null) { + // Keep track of hashes for logging; they're not necessary for anything else + hashes["${this}Api"] = mutableListOf() + for (hashedEndpoint in endpointHash("${this}Api")) { + initH[hashedEndpoint] = fn + hashes["${this}Api"]?.add(hashedEndpoint) + } + } } infix fun String.static(fn: () -> Any) = serialize(this, fn()).let { resp -> this { resp } } diff --git a/src/main/java/icu/samnyan/aqua/sega/general/filter/CompressionFilter.kt b/src/main/java/icu/samnyan/aqua/sega/general/filter/CompressionFilter.kt index a4242e2c..2491d976 100644 --- a/src/main/java/icu/samnyan/aqua/sega/general/filter/CompressionFilter.kt +++ b/src/main/java/icu/samnyan/aqua/sega/general/filter/CompressionFilter.kt @@ -5,6 +5,8 @@ import ext.logger import ext.toJson import icu.samnyan.aqua.net.components.GeoIP import icu.samnyan.aqua.sega.allnet.TokenChecker +import icu.samnyan.aqua.sega.general.model.GameEncryptionKey +import icu.samnyan.aqua.sega.util.GameDataService import icu.samnyan.aqua.sega.util.ZLib import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest @@ -14,6 +16,9 @@ import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter import org.springframework.web.util.ContentCachingResponseWrapper import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec /** @@ -21,21 +26,46 @@ import java.util.* */ @Component class CompressionFilter( - val geoip: GeoIP + val geoip: GeoIP, + val gameData: GameDataService ) : OncePerRequestFilter() { companion object { val log = logger() val b64d = Base64.getMimeDecoder() val b64e = Base64.getMimeEncoder() + } + var keys = gameData.chu3GameEncryption + gameData.mai2GameEncryption + gameData.ogkGameEncryption + fun getEncryptionKeys(path: String): GameEncryptionKey? { + val endpoint = path.split("/").last() + if (endpoint.lowercase() != endpoint || endpoint.length != 32) return null + + val game = path.split("/")[2] + val version = path.split("/")[3].filterNot { it == '.' }.toInt() + return keys.find { it.version == version && it.code == game } + } + + @OptIn(ExperimentalStdlibApi::class) override fun doFilterInternal(req: HttpServletRequest, resp: HttpServletResponse, chain: FilterChain) { val isDeflate = req.getHeader("content-encoding") == "deflate" val isDfi = req.getHeader("pragma") == "DFI" + val keys = getEncryptionKeys(req.servletPath) + // Decode input val reqSrc = try { req.inputStream.readAllBytes().let { + if (keys != null) { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(keys.key!!.hexToByteArray(), "AES"), + IvParameterSpec(keys.iv!!.hexToByteArray()) + ) + cipher.doFinal(it) + } else it + }.let { if (isDeflate) ZLib.decompress(it) else if (isDfi) ZLib.decompress(b64d.decode(it)) else it @@ -50,7 +80,19 @@ class CompressionFilter( val respW = ContentCachingResponseWrapper(resp) val result = try { chain.doFilter(CompressRequestWrapper(req, reqSrc), respW) - ZLib.compress(respW.contentAsByteArray).let { if (isDfi) b64e.encode(it) else it } + ZLib.compress(respW.contentAsByteArray) + .let { if (isDfi) b64e.encode(it) else it } + .let { + if (keys != null) { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(keys.key!!.hexToByteArray(), "AES"), + IvParameterSpec(keys.iv!!.hexToByteArray()) + ) + cipher.doFinal(it) + } else it + } } finally { if (respW.status != 200) { val details = mapOf( diff --git a/src/main/java/icu/samnyan/aqua/sega/general/model/EncryptionKey.kt b/src/main/java/icu/samnyan/aqua/sega/general/model/EncryptionKey.kt new file mode 100644 index 00000000..656e74c9 --- /dev/null +++ b/src/main/java/icu/samnyan/aqua/sega/general/model/EncryptionKey.kt @@ -0,0 +1,14 @@ +package icu.samnyan.aqua.sega.general.model + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.LocalDateTime + +class GameEncryptionKey { + var code: String? = null + var version = 0 + var key: String? = null + var salt: String? = null + var iv: String? = null + var iterations = 0 +} \ No newline at end of file diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt index a8406721..e547ba4e 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2Apis.kt @@ -424,8 +424,20 @@ fun Maimai2ServletController.initApis() { ) ) } - "UpsertUserPlaceCircleRegist" static { mapOf( - "returnCode" to 0, - "apiName" to "UpsertUserPlaceCircleRegistApi" - ) } + // NOTE: no-op APIs moved to respect encryption + "UpsertUserPlaceCircleRegist" static { mapOf( "returnCode" to 0, "apiName" to "com.sega.maimai2servlet.api.UpsertUserPlaceCircleRegistApi") } + "GetUserScoreRanking" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.GetUserScoreRankingApi"}""" } + "UpsertClientBookkeeping" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.UpsertClientBookkeepingApi"}""" } + "UpsertClientSetting" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.UpsertClientSettingApi"}""" } + "UpsertClientTestmode" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.UpsertClientTestmodeApi"}""" } + "UpsertClientUpload" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.UpsertClientUploadApi"}""" } + "Ping" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.Ping"}""" } + "RemoveToken" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.RemoveTokenApi"}""" } + "CMLogin" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.CMLoginApi"}""" } + "CMLogout" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.CMLogoutApi"}""" } + "CMUpsertBuyCard" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.CMUpsertBuyCardApi"}""" } + "UserLogout" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.UserLogoutApi"}""" } + "GetGameMapAreaCondition" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.GetGameMapAreaConditionApi"}""" } + "UpsertUserChargelog" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.UpsertUserChargelogApi"}""" } + "UpsertClientPlayTime" static { """{returnCode":1, "apiName":"com.sega.maimai2servlet.api.UpsertClientPlayTimeApi"}""" } } diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt index 3cbf80e3..84a40fe3 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/Maimai2ServletController.kt @@ -11,12 +11,14 @@ import icu.samnyan.aqua.sega.maimai2.handler.* import icu.samnyan.aqua.sega.maimai2.model.Mai2Repos import icu.samnyan.aqua.spring.Metrics import jakarta.servlet.http.HttpServletRequest +import kotlinx.io.bytestring.hexToByteString import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Lazy import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import java.security.MessageDigest import java.time.format.DateTimeFormatter import kotlin.reflect.full.declaredMemberProperties @@ -25,7 +27,11 @@ import kotlin.reflect.full.declaredMemberProperties */ @Suppress("unused") @RestController -@RequestMapping(path = ["/g/mai2/Maimai2Servlet/", "/g/mai2/"]) +@RequestMapping(path = [ + "/g/SDGA/{version}/Maimai2Servlet/", "/g/SDGA/{version}", + "/g/SDEZ/{version}/Maimai2Servlet/", "/g/SDEZ/{version}", + "/g/SDGB/{version}/Maimai2Servlet/", "/g/SDGB/{version}", +]) class Maimai2ServletController( val upsertUserAll: UpsertUserAllHandler, val getUserItem: GetUserItemHandler, @@ -40,7 +46,18 @@ class Maimai2ServletController( val getGameRanking: GetGameRankingHandler, val db: Mai2Repos, val net: Maimai2, -): MeowApi(serialize = { _, resp -> if (resp is String) resp else resp.toJson() }) { +): MeowApi( + serialize = { _, resp -> if (resp is String) resp else resp.toJson() }, + @kotlin.ExperimentalStdlibApi + { endpoint: String -> + db.gameData.mai2GameEncryption.map{ + val suffix = when (it.code) { "SDGA" -> "MaimaiExp" "SDGB" -> "MaimaiChn" else -> "" } + MessageDigest.getInstance("MD5") + .digest((endpoint + suffix).toByteArray() + it.salt!!.hexToByteArray()) + .toHexString() + } + } +) { companion object { private val log = logger() private val empty = listOf() @@ -55,11 +72,6 @@ class Maimai2ServletController( "CMGetUserCardApi","CMGetUserCardPrintErrorApi","CMGetUserDataApi","CMGetUserItemApi","CMUpsertUserPrintApi", "GetUserFavoriteItemApi") - val noopEndpoint = setOf("GetUserScoreRankingApi", "UpsertClientBookkeepingApi", - "UpsertClientSettingApi", "UpsertClientTestmodeApi", "UpsertClientUploadApi", "Ping", "RemoveTokenApi", - "CMLoginApi", "CMLogoutApi", "CMUpsertBuyCardApi", "UserLogoutApi", "GetGameMapAreaConditionApi", - "UpsertUserChargelogApi","UpsertClientPlayTimeApi") - val members = this::class.declaredMemberProperties val handlers: Map = initH + endpointList.associateWith { api -> val name = api.replace("Api", "").lowercase() @@ -72,27 +84,25 @@ class Maimai2ServletController( @API("/{api}") fun handle(@PathVariable api: String, @RequestBody data: Map, req: HttpServletRequest): Any { val token = TokenChecker.tokenShort() - log.info("$token : $api < ${data.toJson()}") + + val apiFriendlyName = if (api.lowercase() == api && api.length == 32) + ((hashes.filter { it.value.contains(api) }.keys.firstOrNull()?.str + " (encrypt)") ?: api) else "$api (encrypt)" + log.info("$token : $apiFriendlyName < ${data.toJson()}") val noop = """{"returnCode":1,"apiName":"com.sega.maimai2servlet.api.$api"}""" - if (api !in noopEndpoint && !handlers.containsKey(api)) { - log.warn("$token : $api > not found") + if (!handlers.containsKey(api)) { + log.warn("$token : $apiFriendlyName > not found") return noop } // Only record the counter metrics if the API is known. Metrics.counter("aquadx_maimai2_api_call", "api" to api).increment() - if (api in noopEndpoint) { - log.info("$token : $api > no-op") - return noop - } - return try { Metrics.timer("aquadx_maimai2_api_latency", "api" to api).recordCallable { val ctx = RequestContext(req, data.mut) serialize(api, handlers[api]!!(ctx) ?: noop).also { - log.info("$token : $api > ${it.truncate(500)}") + log.info("$token : $apiFriendlyName > ${it.truncate(500)}") } } } catch (e: Exception) { diff --git a/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt index 7f6fb1f3..b828c7a1 100644 --- a/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt +++ b/src/main/java/icu/samnyan/aqua/sega/maimai2/model/Repos.kt @@ -147,7 +147,7 @@ class Mai2Repos( val userKaleidx: MAi2UserKaleidxRepo, val userIntimate: MAi2UserIntimateRepo, val userRegions: Mai2UserRegionsRepo, - gameData: GameDataService + val gameData: GameDataService ) { val gameCharge = StaticRepo(gameData.mai2Charges) { it.orderId } val gameEvent = StaticRepo(gameData.mai2Events) { it.id } diff --git a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiApis.kt b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiApis.kt index 32e477a0..698812d5 100644 --- a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiApis.kt +++ b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiApis.kt @@ -70,4 +70,16 @@ fun OngekiController.ongekiInit() { "GetClientTestmode" { empty.staticLst("clientTestmodeList") + mapOf("placeId" to data["placeId"]) } + + // NOTE: no-op APIs moved to respect encryption + "ExtendLockTime" static { """{returnCode":1, "apiName":"ExtendLockTimeApi"}""" } + "GameLogout" static { """{returnCode":1, "apiName":"GameLogoutApi"}""" } + "RegisterPromotionCard" static { """{returnCode":1, "apiName":"RegisterPromotionCardApi"}""" } + "UpsertClientBookkeeping" static { """{returnCode":1, "apiName":"UpsertClientBookkeepingApi"}""" } + "UpsertClientDevelop" static { """{returnCode":1, "apiName":"UpsertClientDevelopApi"}""" } + "UpsertClientError" static { """{returnCode":1, "apiName":"UpsertClientErrorApi"}""" } + "UpsertClientSetting" static { """{returnCode":1, "apiName":"UpsertClientSettingApi"}""" } + "UpsertClientTestmode" static { """{returnCode":1, "apiName":"UpsertClientTestmodeApi"}""" } + "UpsertUserGplog" static { """{returnCode":1, "apiName":"UpsertUserGplogApi"}""" } + "Ping" static { """{returnCode":1, "apiName":"Ping"}""" } } \ No newline at end of file diff --git a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt index a82bb06c..b8192fe0 100644 --- a/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt +++ b/src/main/java/icu/samnyan/aqua/sega/ongeki/OngekiController.kt @@ -8,14 +8,18 @@ import icu.samnyan.aqua.sega.general.GameMusicPopularity import icu.samnyan.aqua.sega.general.MeowApi import icu.samnyan.aqua.sega.general.RequestContext import icu.samnyan.aqua.sega.general.service.CardService +import icu.samnyan.aqua.sega.maimai2.Maimai2ServletController import icu.samnyan.aqua.sega.util.BasicMapper import icu.samnyan.aqua.spring.Metrics import jakarta.servlet.http.HttpServletRequest import org.springframework.web.bind.annotation.RestController +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + @Suppress("unused") @RestController -@API("/g/ongeki/{version}", "/g/ongeki") +@API("/g/SDDT/{version}", "/g/SDDT") class OngekiController( val mapper: BasicMapper, val db: OngekiUserRepos, @@ -23,14 +27,23 @@ class OngekiController( val us: AquaUserServices, val pop: GameMusicPopularity, val cardService: CardService, -): MeowApi({ _, resp -> if (resp is String) resp else mapper.write(resp) }) { +): MeowApi( + { _, resp -> if (resp is String) resp else mapper.write(resp) }, + @OptIn(ExperimentalStdlibApi::class) + { endpoint: String -> + gdb.gameData.ogkGameEncryption.map{ + SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + .generateSecret(PBEKeySpec( + endpoint.toCharArray(), + it.salt!!.hexToByteArray(), + it.iterations, 128 + )).encoded.toHexString().substring(0, 32) + } + } +) { val log = logger() - val noopEndpoint = setOf("ExtendLockTimeApi", "GameLogoutApi", "RegisterPromotionCardApi", - "UpsertClientBookkeepingApi", "UpsertClientDevelopApi", "UpsertClientErrorApi", "UpsertClientSettingApi", - "UpsertClientTestmodeApi", "UpsertUserGplogApi", "Ping") - init { ongekiInit() } val handlers = initH @@ -40,26 +53,25 @@ class OngekiController( version?.let { data["version"] = it } val token = TokenChecker.tokenShort() - log.info("$token : $api < ${data.toJson()}") + + val apiFriendlyName = if (api.lowercase() == api && api.length == 32) + ((hashes.filter { it.value.contains(api) }.keys.firstOrNull()?.str + " (encrypt)") ?: api) else "$api (encrypt)" + log.info("$token : $apiFriendlyName < ${data.toJson()}") val noop = """{"returnCode":1,"apiName":"${api.substringBefore("Api").firstCharLower()}"}""" - if (api !in noopEndpoint && !handlers.containsKey(api)) { - log.warn("$token : $api > not found") + if (!handlers.containsKey(api)) { + log.warn("$token : $apiFriendlyName > not found") return noop } // Only record the counter metrics if the API is known. Metrics.counter("aquadx_ongeki_api_call", "api" to api).increment() - if (api in noopEndpoint) { - log.info("$token : $api > no-op") - return noop - } return try { Metrics.timer("aquadx_ongeki_api_latency", "api" to api).recordCallable { serialize(api, handlers[api]!!(ctx) ?: noop).also { if (api !in setOf("GetUserItemApi", "GetGameEventApi")) - log.info("$token : $api > ${it.truncate(500)}") + log.info("$token : $apiFriendlyName > ${it.truncate(500)}") } } } catch (e: Exception) { diff --git a/src/main/java/icu/samnyan/aqua/sega/util/GameDataService.kt b/src/main/java/icu/samnyan/aqua/sega/util/GameDataService.kt index b2322465..285866d9 100644 --- a/src/main/java/icu/samnyan/aqua/sega/util/GameDataService.kt +++ b/src/main/java/icu/samnyan/aqua/sega/util/GameDataService.kt @@ -3,6 +3,7 @@ package icu.samnyan.aqua.sega.util import com.fasterxml.jackson.core.type.TypeReference import ext.logger import ext.toJson +import icu.samnyan.aqua.sega.general.model.GameEncryptionKey import icu.samnyan.aqua.sega.chusan.model.GameCharge as Chu3GameCharge import icu.samnyan.aqua.sega.chusan.model.GameEvent as Chu3GameEvent import icu.samnyan.aqua.sega.chusan.model.GameGacha as Chu3GameGacha @@ -45,7 +46,8 @@ class GameDataService() { return mapper.read(resStream.bufferedReader().use { it.readText() }, object : TypeReference>() {}) } - log.warn("Game data file $f or resource $resPath not found, using empty list") + if (file != "game_encryption.json") + log.warn("Game data file $f or resource $resPath not found, using empty list") return emptyList() } } @@ -54,6 +56,7 @@ class GameDataService() { lateinit var mai2Events: List lateinit var mai2Charges: List lateinit var mai2SellingCards: List + lateinit var mai2GameEncryption: List // chusan lateinit var chu3GameLinkedVerses: List @@ -63,6 +66,7 @@ class GameDataService() { lateinit var chu3GameGachas: List lateinit var chu3GameLoginBonusPresets: List lateinit var chu3GameLoginBonuses: List + lateinit var chu3GameEncryption: List // ongeki lateinit var ogkGameCards: List @@ -75,6 +79,7 @@ class GameDataService() { lateinit var ogkGameSkills: List lateinit var ogkGameGachaCards: List lateinit var ogkGameGachas: List + lateinit var ogkGameEncryption: List init { load() @@ -84,6 +89,7 @@ class GameDataService() { mai2Events = load("maimai2", "game_event.json") mai2Charges = load("maimai2", "game_charge.json") mai2SellingCards = load("maimai2", "game_selling_card.json") + mai2GameEncryption = load(game = "maimai2", file = "game_encryption.json") chu3GameLinkedVerses = load("chusan", "game_linked_verse.json") chu3GameCharges = load("chusan", "game_charge.json") @@ -92,6 +98,7 @@ class GameDataService() { chu3GameGachas = load("chusan", "game_gacha.json") chu3GameLoginBonusPresets = load("chusan", "game_login_bonus_preset.json") chu3GameLoginBonuses = load("chusan", "game_login_bonus.json") + chu3GameEncryption = load(game = "chusan", file = "game_encryption.json") ogkGameCards = load("ongeki", "game_card.json") ogkGameCharas = load("ongeki", "game_chara.json") @@ -103,6 +110,7 @@ class GameDataService() { ogkGameSkills = load("ongeki", "game_skill.json") ogkGameGachaCards = load("ongeki", "game_gacha_card.json") ogkGameGachas = load("ongeki", "game_gacha.json") + ogkGameEncryption = load(game = "ongeki", file = "game_encryption.json") } } diff --git a/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt b/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt index 3c5e28a4..87178ac0 100644 --- a/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt +++ b/src/main/java/icu/samnyan/aqua/sega/wacca/WaccaServer.kt @@ -25,7 +25,7 @@ import kotlin.math.max import kotlin.math.min @RestController -@API("/g/wacca/") +@API("/g/SDFE/{version}") class WaccaServer { // These are lateinit autowired instead of constructor injection because the tests depend on creating // an instance of this class with no arguments.