kozukata-toa/src/servers/titles/chunithm/index.ts

219 lines
5.7 KiB
TypeScript

import { VERSIONS } from "./versions";
import { GetClassMethods } from "../utils/reflection";
import compression from "compression";
import { Router, json } from "express";
import { ChunithmVersions } from "lib/constants/game-versions";
import CreateLogCtx from "lib/logger/logger";
import { RequestLoggerMiddleware } from "lib/middleware/request-logger";
import { Config, Environment } from "lib/setup/config";
import getRawBody from "raw-body";
import { createDecipheriv, pbkdf2Sync } from "crypto";
import type { RequestHandler } from "express";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: false });
router.use(compression({ threshold: 0 }));
router.use(async (req, res, next) => {
if ((req.headers["content-length"] ?? 0) === 0) {
next();
return;
}
// /SDHD/210/UpsertUserAllApi
const splitPath = req.path.split("/");
const version = splitPath[2];
const versionNum = Number(version);
const endpoint = splitPath.at(-1);
if (!endpoint || !version || Number.isNaN(versionNum)) {
next();
return;
}
const isEncryptedRequest =
endpoint.length === 32 && [...endpoint].every((c) => "0123456789abcdefABCDEF".includes(c));
if (
!isEncryptedRequest &&
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
versionNum >= ChunithmVersions.CRYSTAL_PLUS
) {
logger.error(
"Rejecting unencrypted request, because the server is configured to accept only encrypted requests.",
{ version, endpoint }
);
return res.status(200).send({ stat: "0" });
}
if (!isEncryptedRequest) {
next();
return;
}
logger.debug("Received encrypted request.", { version, endpoint });
const keys = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[version];
if (!keys) {
logger.error("No keys were configured for the requested version.", {
version,
endpoint,
});
return res.status(200).send({ stat: "0" });
}
// Mostly as a convenience, so versions PARADISE LOST and older don't have to provide
// the path salt.
const userAgent = req.headers["user-agent"];
if (versionNum <= ChunithmVersions.PARADISE && userAgent) {
const unhashedEndpoint = userAgent.split("#")[0];
if (unhashedEndpoint) {
req.url = req.url.replace(endpoint, unhashedEndpoint);
} else {
logger.warn(
"User-Agent did not contain the unhashed endpoint for version PARADISE (LOST) or older?",
{ version, userAgent }
);
return res.status(200).send({ stat: "0" });
}
}
const key = keys[0];
const iv = keys[1];
if (!key || !iv) {
logger.error("Key or IV was not provided for the requested version.", {
version,
key,
iv,
});
return res.status(200).send({ stat: "0" });
}
const cipher = createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), Buffer.from(iv, "hex"));
const body = await getRawBody(req);
// eslint-disable-next-line require-atomic-updates
req.body = Buffer.concat([cipher.update(body), cipher.final()]);
// TODO: Encrypt response
next();
});
router.use((req, res, next) => {
if (Environment.nodeEnv === "dev" && req.header("x-debug") !== undefined) {
next();
return;
}
// Get express.json() to recognize
req.headers["content-encoding"] = "deflate";
next();
});
// All requests to the title server will be JSON, I promise!
router.use(json({ type: (_) => true }));
router.use((req, res, next) => {
// Always mount an empty req body. We operate under the assumption that req.body is
// always defined as atleast an object.
if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) {
req.body = {};
}
// req.safeBody *is* just a type-safe req.body!
req.safeBody = req.body as Record<string, unknown>;
next();
});
router.use(RequestLoggerMiddleware);
for (const Title of VERSIONS) {
const inst = new Title();
const endpointSaltString = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[2];
const iterations = inst.numericVersion >= ChunithmVersions.SUN ? 70 : 44;
const key = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[0];
const iv = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[1];
if (
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
inst.numericVersion >= ChunithmVersions.CRYSTAL_PLUS &&
(!key || !iv)
) {
logger.warn(
`Disabling ${inst.gameCode} v${inst.version}, because ENCRYPTED_ONLY was enabled but no keys are provided.`,
{
gameCode: inst.gameCode,
version: inst.version,
}
);
continue;
}
if (
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
inst.numericVersion >= ChunithmVersions.NEW &&
!endpointSaltString
) {
logger.warn(
`Disabling ${inst.gameCode} v${inst.version}, because ENCRYPTED_ONLY was enabled but no endpoint salt was provided.`,
{
gameCode: inst.gameCode,
version: inst.version,
}
);
}
const subrouter: Router = Router({ mergeParams: false });
const methods = GetClassMethods(Title).filter((m) => m.startsWith("handle_"));
const endpointSalt = endpointSaltString ? Buffer.from(endpointSaltString, "hex") : null;
for (const method of methods) {
const methodName = method.split("_")[1];
if (!methodName) {
logger.warn(
"Method starts with 'handle_', but cannot get the part after the underscore?",
{
method,
}
);
continue;
}
const impl = (inst as unknown as Record<string, unknown>)[method] as RequestHandler;
const implWrapper: RequestHandler = (req, res, next) => {
impl.apply(inst, [req, res, next]);
};
subrouter.post(`/${methodName}`, implWrapper);
if (endpointSalt) {
const hash = pbkdf2Sync(methodName, endpointSalt, iterations, 16, "sha1").toString(
"hex"
);
subrouter.post(`/${hash}`, implWrapper);
}
}
router.use(`/${inst.gameCode}/${inst.version}/${inst.servletName}`, subrouter);
// TODO: Add to a title registry
}
export default router;