[mai2] Support encryption (#130)

Similar to O.N.G.E.K.I. and CHUNITHM, with the caveat that the obfuscated endpoint is created using `md5(endpoint + salt)` instead of using PBKDF2 like other games.

Tested and confirmed working on FESTiVAL+.

The current implementation is also affected by #129, so I'm open to ideas.

Reviewed-on: Hay1tsme/artemis#130
Co-authored-by: beerpsi <beerpsi@duck.com>
Co-committed-by: beerpsi <beerpsi@duck.com>
This commit is contained in:
beerpsi 2024-04-24 16:59:33 +00:00 committed by Hay1tsme
parent 9175670d0d
commit a8daa0344a
3 changed files with 129 additions and 13 deletions

View File

@ -12,3 +12,6 @@ uploads:
photos_dir: "" photos_dir: ""
movies: False movies: False
movies_dir: "" movies_dir: ""
crypto:
encrypted_only: False

View File

@ -1,3 +1,5 @@
from typing import Dict
from core.config import CoreConfig from core.config import CoreConfig
@ -70,8 +72,32 @@ class Mai2UploadsConfig:
) )
class Mai2CryptoConfig:
def __init__(self, parent_config: "Mai2Config") -> None:
self.__config = parent_config
@property
def keys(self) -> Dict[int, list[str]]:
"""
in the form of:
internal_version: [key, iv, salt]
key and iv are hex strings
salt is a normal UTF-8 string
"""
return CoreConfig.get_config_field(
self.__config, "mai2", "crypto", "keys", default={}
)
@property
def encrypted_only(self) -> bool:
return CoreConfig.get_config_field(
self.__config, "mai2", "crypto", "encrypted_only", default=False
)
class Mai2Config(dict): class Mai2Config(dict):
def __init__(self) -> None: def __init__(self) -> None:
self.server = Mai2ServerConfig(self) self.server = Mai2ServerConfig(self)
self.deliver = Mai2DeliverConfig(self) self.deliver = Mai2DeliverConfig(self)
self.uploads = Mai2UploadsConfig(self) self.uploads = Mai2UploadsConfig(self)
self.crypto = Mai2CryptoConfig(self)

View File

@ -6,9 +6,13 @@ import inflection
import yaml import yaml
import logging, coloredlogs import logging, coloredlogs
import zlib import zlib
import string
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from os import path, mkdir from os import path, mkdir
from typing import Tuple, List, Dict from typing import Tuple, List, Dict
from Crypto.Hash import MD5
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from core.config import CoreConfig from core.config import CoreConfig
from core.utils import Utils from core.utils import Utils
@ -32,6 +36,7 @@ class Mai2Servlet(BaseServlet):
def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None: def __init__(self, core_cfg: CoreConfig, cfg_dir: str) -> None:
super().__init__(core_cfg, cfg_dir) super().__init__(core_cfg, cfg_dir)
self.game_cfg = Mai2Config() self.game_cfg = Mai2Config()
self.hash_table: Dict[int, Dict[str, str]] = {}
if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"): if path.exists(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"):
self.game_cfg.update( self.game_cfg.update(
yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}")) yaml.safe_load(open(f"{cfg_dir}/{Mai2Constants.CONFIG_NAME}"))
@ -87,6 +92,37 @@ class Mai2Servlet(BaseServlet):
) )
self.logger.initted = True self.logger.initted = True
for version, keys in self.game_cfg.crypto.keys.items():
if version < Mai2Constants.VER_MAIMAI_DX:
continue
if len(keys) < 3:
continue
self.hash_table[version] = {}
method_list = [
method
for method in dir(self.versions[version])
if not method.startswith("__")
]
for method in method_list:
# handle_method_api_request -> HandleMethodApiRequest
# remove the first 6 chars and the final 7 chars to get the canonical
# endpoint name.
method_fixed = inflection.camelize(method)[6:-7]
hash = MD5.new((method_fixed + keys[2]).encode())
# truncate unused bytes like the game does
hashed_name = hash.hexdigest()
self.hash_table[version][hashed_name] = method_fixed
self.logger.debug(
"Hashed v%s method %s with %s to get %s",
version, method_fixed, keys[2], hashed_name
)
@classmethod @classmethod
def is_game_enabled( def is_game_enabled(
cls, game_code: str, core_cfg: CoreConfig, cfg_dir: str cls, game_code: str, core_cfg: CoreConfig, cfg_dir: str
@ -234,7 +270,7 @@ class Mai2Servlet(BaseServlet):
self.logger.error(f"Error handling v{version} method {endpoint} - {e}") self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
return Response(zlib.compress(b'{"returnCode": "0"}')) return Response(zlib.compress(b'{"returnCode": "0"}'))
if resp == None: if resp is None:
resp = {"returnCode": 1} resp = {"returnCode": 1}
self.logger.debug(f"Response {resp}") self.logger.debug(f"Response {resp}")
@ -252,6 +288,8 @@ class Mai2Servlet(BaseServlet):
req_raw = await request.body() req_raw = await request.body()
internal_ver = 0 internal_ver = 0
client_ip = Utils.get_ip_addr(request) client_ip = Utils.get_ip_addr(request)
encrypted = False
if version < 105: # 1.0 if version < 105: # 1.0
internal_ver = Mai2Constants.VER_MAIMAI_DX internal_ver = Mai2Constants.VER_MAIMAI_DX
elif version >= 105 and version < 110: # PLUS elif version >= 105 and version < 110: # PLUS
@ -271,19 +309,54 @@ class Mai2Servlet(BaseServlet):
elif version >= 140: # BUDDiES elif version >= 140: # BUDDiES
internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES internal_ver = Mai2Constants.VER_MAIMAI_DX_BUDDIES
if ( if all(c in string.hexdigits for c in endpoint) and len(endpoint) == 32:
request.headers.get("Mai-Encoding") is not None # If we get a 32 character long hex string, it's a hash and we're
or request.headers.get("X-Mai-Encoding") is not None # dealing with an encrypted request. False positives shouldn't happen
): # as long as requests are suffixed with `Api`.
# The has is some flavor of MD5 of the endpoint with a constant bolted onto the end of it. if internal_ver not in self.hash_table:
# See cake.dll's Obfuscator function for details. Hopefully most DLL edits will remove self.logger.error(
# these two(?) headers to not cause issues, but given the general quality of SEGA data... "v%s does not support encryption or no keys entered",
enc_ver = request.headers.get("Mai-Encoding") version,
if enc_ver is None:
enc_ver = request.headers.get("X-Mai-Encoding")
self.logger.debug(
f"Encryption v{enc_ver} - User-Agent: {request.headers.get('User-Agent')}"
) )
return Response(zlib.compress(b'{"stat": "0"}'))
elif endpoint.lower() not in self.hash_table[internal_ver]:
self.logger.error(
"No hash found for v%s endpoint %s",
version, endpoint
)
return Response(zlib.compress(b'{"stat": "0"}'))
endpoint = self.hash_table[internal_ver][endpoint.lower()]
try:
crypt = AES.new(
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
AES.MODE_CBC,
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
)
req_raw = crypt.decrypt(req_raw)
except Exception as e:
self.logger.error(
"Failed to decrypt v%s request to %s",
version, endpoint,
exc_info=e,
)
return Response(zlib.compress(b'{"stat": "0"}'))
encrypted = True
if (
not encrypted
and self.game_cfg.crypto.encrypted_only
and version >= 110
):
self.logger.error(
"Unencrypted v%s %s request, but config is set to encrypted only: %r",
version, endpoint, req_raw
)
return Response(zlib.compress(b'{"stat": "0"}'))
try: try:
unzip = zlib.decompress(req_raw) unzip = zlib.decompress(req_raw)
@ -320,12 +393,26 @@ class Mai2Servlet(BaseServlet):
self.logger.error(f"Error handling v{version} method {endpoint} - {e}") self.logger.error(f"Error handling v{version} method {endpoint} - {e}")
return Response(zlib.compress(b'{"stat": "0"}')) return Response(zlib.compress(b'{"stat": "0"}'))
if resp == None: if resp is None:
resp = {"returnCode": 1} resp = {"returnCode": 1}
self.logger.debug(f"Response {resp}") self.logger.debug(f"Response {resp}")
return Response(zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8"))) zipped = zlib.compress(json.dumps(resp, ensure_ascii=False).encode("utf-8"))
if not encrypted or version < 110:
return Response(zipped)
padded = pad(zipped, 16)
crypt = AES.new(
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][0]),
AES.MODE_CBC,
bytes.fromhex(self.game_cfg.crypto.keys[internal_ver][1]),
)
return Response(crypt.encrypt(padded))
async def handle_old_srv(self, request: Request) -> bytes: async def handle_old_srv(self, request: Request) -> bytes:
endpoint = request.path_params.get('endpoint') endpoint = request.path_params.get('endpoint')