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

348 lines
9.0 KiB
TypeScript

import { VERSIONS } from "./versions";
import { ChunkLength, ToBuffer } from "../utils/buffer";
import { GetClassMethods } from "../utils/reflection";
import compression from "compression";
import { Router } 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 onHeaders from "on-headers";
import getRawBody from "raw-body";
import { IsHexadecimalString } from "utils/misc";
import { createCipheriv, createDecipheriv, pbkdf2Sync } from "crypto";
import { promisify } from "util";
import zlib, { createDeflate, inflate } from "zlib";
import type { RequestHandler } from "express";
import type { Transform } from "stream";
const logger = CreateLogCtx(__filename);
const inflateAsync = promisify(inflate);
const router: Router = Router({ mergeParams: false });
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 && IsHexadecimalString(endpoint);
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" });
}
const body = await getRawBody(req, { limit: "20mb" });
if (!isEncryptedRequest) {
const decompressed = await inflateAsync(body);
// eslint-disable-next-line require-atomic-updates
req.body = JSON.parse(decompressed.toString("utf-8"));
next();
return;
}
logger.debug("Received encrypted request.", { version, endpoint });
res.locals.encrypted = true;
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) {
logger.debug("Resolved to unhashed endpoint from User-Agent.", { 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 }
);
}
}
const keyString = keys[0];
const ivString = keys[1];
if (!keyString || !ivString) {
logger.error("Key or IV was not provided for the requested version.", {
version,
key: keyString,
iv: ivString,
});
return res.status(200).send({ stat: "0" });
}
const key = Buffer.from(keyString, "hex");
const iv = Buffer.from(ivString, "hex");
const cipher = createDecipheriv("aes-256-cbc", key, iv);
const decrypted = Buffer.concat([cipher.update(body), cipher.final()]);
const decompressed = await inflateAsync(decrypted);
// eslint-disable-next-line require-atomic-updates
req.body = JSON.parse(decompressed.toString("utf-8"));
// Set up response necryption
// Eldritch horrors beyond my understanding down here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const listeners: Array<[string | symbol, any]> = [];
let stream: Transform | null = null;
let ended = false;
/* eslint-disable @typescript-eslint/unbound-method */
const _on = res.on;
const _write = res.write;
const _end = res.end;
/* eslint-enable @typescript-eslint/unbound-method */
/* eslint-disable prefer-arrow-callback */
res.write = function write(chunk, encoding, ...rest) {
if (ended) {
return false;
}
// @ts-expect-error monkey noises
if (!this._header && !this.headersSent) {
this.writeHead(this.statusCode);
}
const enc = typeof encoding === "function" ? undefined : encoding;
return stream
? stream.write(ToBuffer(chunk, enc))
: // @ts-expect-error reeeee
_write.call(this, chunk, encoding, ...rest);
};
// @ts-expect-error idk
res.end = function end(chunk, encoding, ...rest) {
if (ended) {
return false;
}
const enc = typeof encoding === "function" ? undefined : encoding;
// @ts-expect-error how do express people live like this?
if (!this._header && !this.headersSent) {
this.writeHead(this.statusCode);
// estimate the length
if (!this.getHeader("Content-Length")) {
this.setHeader("Content-Length", ChunkLength(chunk, enc));
}
}
if (!stream) {
// @ts-expect-error bruh
return _end.call(this, chunk, encoding, ...rest);
}
// mark ended
ended = true;
// write Buffer for Node.js 0.8
// @ts-expect-error bruh
return chunk ? stream.end(ToBuffer(chunk, encoding)) : stream.end();
};
// @ts-expect-error zzz
res.on = function on(event, listener) {
if (event !== "drain") {
return _on.call(this, event, listener);
}
if (stream) {
return stream.on(event, listener);
}
// buffer listeners for future stream
listeners.push([event, listener]);
return this;
};
onHeaders(res, function onResponseHeaders() {
const cipher = createCipheriv("aes-256-cbc", key, iv);
stream = createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH });
stream.pipe(cipher);
for (const listener of listeners) {
stream.on(listener[0], listener[1]);
}
res.removeHeader("Content-Length");
cipher.on("data", (chunk) => {
// @ts-expect-error ugggh i hate monkeys
if (!_write.call(res, chunk)) {
stream?.pause();
}
});
cipher.on("end", () => {
// @ts-expect-error ugggh i hate monkeys
_end.call(res);
});
_on.call(res, "drain", () => {
stream?.resume();
});
});
/* eslint-enable prefer-arrow-callback */
next();
});
// you WILL compress the response and you WILL be happy about it.
// chunithm assumes all responses are deflated.
router.use(
compression({
threshold: 0,
filter: (req, res) => !res.locals.encrypted,
})
);
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();
});
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;