From da3182ba08d48e07077fece082d4168313c1447d Mon Sep 17 00:00:00 2001 From: EisigesEis Date: Thu, 2 Jul 2026 17:37:04 +0200 Subject: [PATCH 1/3] auth: db transaction drain into cached map lookups - Replace: load all api keys from db > hash > constant time check all keys on each request - Rename: auth.ts > authuser.ts, apikeys.ts > authgameserver.ts - Initial key drain wrapped in db transaction - Add: apikeycache.ts - Cache.refresh() Initial drain loads all hashed keys into memory buffer - Cache.get(key) .has(key): On each user request: hash > Map lookup --- .../src/controllers/apikeycache.ts | 77 +++++++++++++++++++ UndauntedMetagame/src/controllers/apikeys.ts | 44 ----------- UndauntedMetagame/src/controllers/auth.ts | 70 ----------------- .../src/controllers/authgameserver.ts | 40 ++++++++++ UndauntedMetagame/src/controllers/authuser.ts | 69 +++++++++++++++++ 5 files changed, 186 insertions(+), 114 deletions(-) create mode 100644 UndauntedMetagame/src/controllers/apikeycache.ts delete mode 100644 UndauntedMetagame/src/controllers/apikeys.ts delete mode 100644 UndauntedMetagame/src/controllers/auth.ts create mode 100644 UndauntedMetagame/src/controllers/authgameserver.ts create mode 100644 UndauntedMetagame/src/controllers/authuser.ts diff --git a/UndauntedMetagame/src/controllers/apikeycache.ts b/UndauntedMetagame/src/controllers/apikeycache.ts new file mode 100644 index 0000000..61507d7 --- /dev/null +++ b/UndauntedMetagame/src/controllers/apikeycache.ts @@ -0,0 +1,77 @@ +import crypto from "crypto"; +import { logger } from "../logger"; + +type KeyRow = { keyHash?: string | null } +type APIKeyLookup = { + find: (apiKey: string) => Promise, + has: (apiKey: string) => Promise, + refresh: () => Promise +} + +export function HashAPIKey(apiKeyToHash: string){ + return crypto.createHash("sha256").update(apiKeyToHash, "utf8").digest("hex"); +} + +function normalizeHash(cacheName: string, keyHash: string){ + const hash = Buffer.from(keyHash, "hex"); + + if(hash.length !== 32){ + logger.warn(`Ignoring invalid ${cacheName} API Key hash with ${hash.length} bytes`); + return undefined; + } + + return keyHash.toLowerCase(); +} + +function cached(loadFresh: () => Promise){ + let cache: Promise | undefined = undefined; + + function load(){ + const fresh = loadFresh().catch((error) => { + if(cache === fresh){ + cache = undefined; + } + + throw error; + }); + + return fresh; + } + + return { + get: () => cache ??= load(), + refresh: () => cache = load() + }; +} + +export function CreateAPIKeyCache( + cacheName: string, + loadRows: () => Promise, + select: (row: TRow) => TValue = () => true as TValue +): APIKeyLookup { + const keys = cached(async () => { + const fresh = new Map(); + + for(const row of await loadRows()){ + const hash = row.keyHash == null ? undefined : normalizeHash(cacheName, row.keyHash); + + if(hash !== undefined){ + fresh.set(hash, select(row)); + } + } + + return fresh; + }); + + return { + async find(apiKey: string){ + return (await keys.get()).get(HashAPIKey(apiKey)); + }, + async has(apiKey: string){ + return (await keys.get()).has(HashAPIKey(apiKey)); + }, + async refresh(){ + await keys.refresh(); + } + }; +} diff --git a/UndauntedMetagame/src/controllers/apikeys.ts b/UndauntedMetagame/src/controllers/apikeys.ts deleted file mode 100644 index bd2408e..0000000 --- a/UndauntedMetagame/src/controllers/apikeys.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {GetDb} from "../db" -import { gameserverapikeys, gameserverapikeystoregister } from "../db/schema"; -import crypto from "crypto"; -import { logger } from "../logger"; - -function HashGameserverAPIKey(GameserverAPIKeyToHash: string){ - return crypto.createHash("sha256").update(GameserverAPIKeyToHash, "utf8").digest("hex"); -} - -export async function DrainAndRegisterAPIKeys(){ - const APIKeysToRegister = await GetDb().query.gameserverapikeystoregister.findMany(); - - await GetDb().delete(gameserverapikeystoregister); - - for(const APIKey of APIKeysToRegister){ - await GetDb().insert(gameserverapikeys).values({ - keyHash: HashGameserverAPIKey(APIKey.key) - }); - } - - logger.info(`Registered ${APIKeysToRegister.length} new Gameserver API Key(s) on boot!`); -} - -export async function IsValidGameserverAPIKey(GameserverAPIKey: string){ - const AllAPIKeyHashes = await GetDb().query.gameserverapikeys.findMany(); - - const IncomingGameserverAPIKeyHashBuffer = Buffer.from(HashGameserverAPIKey(GameserverAPIKey), "hex"); - - let Match: boolean = false; - - for(const CmpAPIKeyHash of AllAPIKeyHashes){ - const CmpAPIKeyHashBuffer = Buffer.from(CmpAPIKeyHash.keyHash!, "hex"); - - if(CmpAPIKeyHashBuffer.length !== IncomingGameserverAPIKeyHashBuffer.length){ - continue; - } - - if(crypto.timingSafeEqual(IncomingGameserverAPIKeyHashBuffer, CmpAPIKeyHashBuffer)){ - Match = true; - } - } - - return Match; -} \ No newline at end of file diff --git a/UndauntedMetagame/src/controllers/auth.ts b/UndauntedMetagame/src/controllers/auth.ts deleted file mode 100644 index cae47ee..0000000 --- a/UndauntedMetagame/src/controllers/auth.ts +++ /dev/null @@ -1,70 +0,0 @@ -import jwt, {JwtPayload} from "jsonwebtoken"; -import crypto from "crypto"; -import { GetDb } from "../db"; -import { userapikeys, userapikeystoregister } from "../db/schema"; -import { logger } from "../logger"; - -const PRIVKEY = Buffer.from(process.env.AUTH_SIGNING_PRIVKEY_B64!, "base64").toString("utf-8"); -const PUBKEY = Buffer.from(process.env.AUTH_SIGNING_PUBKEY_B64!, "base64").toString("utf-8"); - -function HashUserAPIKey(UserAPIKeyToHash: string){ - return crypto.createHash("sha256").update(UserAPIKeyToHash, "utf8").digest("hex"); -} - -export async function DrainAndRegisterUserAPIKeys(){ - const APIKeysToRegister = await GetDb().query.userapikeystoregister.findMany(); - - await GetDb().delete(userapikeystoregister); - - for(const APIKey of APIKeysToRegister){ - await GetDb().insert(userapikeys).values({ - userId: APIKey.userId, - keyHash: HashUserAPIKey(APIKey.key) - }); - } - - logger.info(`Registered ${APIKeysToRegister.length} new User API Key(s) on boot!`); -} - -export async function GetUserIDForAPIKey(UserAPIKey: string){ - const AllAPIKeyHashes = await GetDb().query.userapikeys.findMany(); - - const IncomingUserAPIKeyHashBuffer = Buffer.from(HashUserAPIKey(UserAPIKey), "hex"); - - let UserId: string | undefined = undefined; - - for(const CmpAPIKeyHash of AllAPIKeyHashes){ - const CmpAPIKeyHashBuffer = Buffer.from(CmpAPIKeyHash.keyHash!, "hex"); - - if(CmpAPIKeyHashBuffer.length !== IncomingUserAPIKeyHashBuffer.length){ - continue; - } - - if(crypto.timingSafeEqual(IncomingUserAPIKeyHashBuffer, CmpAPIKeyHashBuffer)){ - UserId = CmpAPIKeyHash.userId; - } - } - - return UserId; -} - -function SignMetagameJWTForUid(userId: string){ - return jwt.sign({ - userId: userId - }, PRIVKEY, { - algorithm: "RS256", - expiresIn: "24h", - issuer: "undaunted-metagame", - audience: "undaunted-metagame" - }); -} - -function ValidateMetagameJWTAndGetPayload(token: string){ - return jwt.verify(token, PUBKEY, { - algorithms: ["RS256"], - issuer: "undaunted-metagame", - audience: "undaunted-metagame" - }); -} - -export { SignMetagameJWTForUid, ValidateMetagameJWTAndGetPayload } \ No newline at end of file diff --git a/UndauntedMetagame/src/controllers/authgameserver.ts b/UndauntedMetagame/src/controllers/authgameserver.ts new file mode 100644 index 0000000..a60bdc6 --- /dev/null +++ b/UndauntedMetagame/src/controllers/authgameserver.ts @@ -0,0 +1,40 @@ +import {GetDb} from "../db" +import { gameserverapikeys, gameserverapikeystoregister } from "../db/schema"; +import { logger } from "../logger"; +import { CreateAPIKeyCache, HashAPIKey } from "./apikeycache"; + +const GameserverKeys = CreateAPIKeyCache("Gameserver", () => + GetDb().query.gameserverapikeys.findMany({ + columns: { + keyHash: true + } + }) +); + +export async function DrainAndRegisterAPIKeys(){ + const RegisteredAPIKeyCount = GetDb().transaction((tx) => { + const KeysToRegister = tx.query.gameserverapikeystoregister.findMany({ + columns: { + key: true + } + }).sync(); + + if(KeysToRegister.length > 0){ + tx.insert(gameserverapikeys).values(KeysToRegister.map((APIKey) => ({ + keyHash: HashAPIKey(APIKey.key) + }))).run(); + + tx.delete(gameserverapikeystoregister).run(); + } + + return KeysToRegister.length; + }); + + await GameserverKeys.refresh(); + + logger.info(`Registered ${RegisteredAPIKeyCount} new Gameserver API Key(s) on boot!`); +} + +export async function IsValidGameserverAPIKey(GameserverAPIKey: string){ + return GameserverKeys.has(GameserverAPIKey); +} diff --git a/UndauntedMetagame/src/controllers/authuser.ts b/UndauntedMetagame/src/controllers/authuser.ts new file mode 100644 index 0000000..802f19c --- /dev/null +++ b/UndauntedMetagame/src/controllers/authuser.ts @@ -0,0 +1,69 @@ +import jwt, {JwtPayload} from "jsonwebtoken"; +import { GetDb } from "../db"; +import { userapikeys, userapikeystoregister } from "../db/schema"; +import { logger } from "../logger"; +import { CreateAPIKeyCache, HashAPIKey } from "./apikeycache"; + +const PRIVKEY = Buffer.from(process.env.AUTH_SIGNING_PRIVKEY_B64!, "base64").toString("utf-8"); +const PUBKEY = Buffer.from(process.env.AUTH_SIGNING_PUBKEY_B64!, "base64").toString("utf-8"); + +const UserKeys = CreateAPIKeyCache("User", () => + GetDb().query.userapikeys.findMany({ + columns: { + keyHash: true, + userId: true + } + }), + (Row) => Row.userId +); + +export async function DrainAndRegisterUserAPIKeys(){ + const RegisteredAPIKeyCount = GetDb().transaction((tx) => { + const KeysToRegister = tx.query.userapikeystoregister.findMany({ + columns: { + userId: true, + key: true + } + }).sync(); + + if(KeysToRegister.length > 0){ + tx.insert(userapikeys).values(KeysToRegister.map((APIKey) => ({ + userId: APIKey.userId, + keyHash: HashAPIKey(APIKey.key) + }))).run(); + + tx.delete(userapikeystoregister).run(); + } + + return KeysToRegister.length; + }); + + await UserKeys.refresh(); + + logger.info(`Registered ${RegisteredAPIKeyCount} new User API Key(s) on boot!`); +} + +export async function GetUserIDForAPIKey(UserAPIKey: string){ + return UserKeys.find(UserAPIKey); +} + +function SignMetagameJWTForUid(userId: string){ + return jwt.sign({ + userId: userId + }, PRIVKEY, { + algorithm: "RS256", + expiresIn: "24h", + issuer: "undaunted-metagame", + audience: "undaunted-metagame" + }); +} + +function ValidateMetagameJWTAndGetPayload(token: string){ + return jwt.verify(token, PUBKEY, { + algorithms: ["RS256"], + issuer: "undaunted-metagame", + audience: "undaunted-metagame" + }); +} + +export { SignMetagameJWTForUid, ValidateMetagameJWTAndGetPayload } From 5b2dc6400f817e546a6de42789cc7d42d7b96770 Mon Sep 17 00:00:00 2001 From: EisigesEis Date: Thu, 2 Jul 2026 17:58:18 +0200 Subject: [PATCH 2/3] fix imports after rename --- .../src/middleware/HasUndauntedMetagameAuth.ts | 4 ++-- UndauntedMetagame/src/routes/eos.ts | 4 ++-- UndauntedMetagame/src/server.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/UndauntedMetagame/src/middleware/HasUndauntedMetagameAuth.ts b/UndauntedMetagame/src/middleware/HasUndauntedMetagameAuth.ts index b7c2d27..0973f8b 100644 --- a/UndauntedMetagame/src/middleware/HasUndauntedMetagameAuth.ts +++ b/UndauntedMetagame/src/middleware/HasUndauntedMetagameAuth.ts @@ -1,7 +1,7 @@ import { NextFunction, Request, Response } from "express"; import { logger } from "../logger"; -import { ValidateMetagameJWTAndGetPayload } from "../controllers/auth"; -import { IsValidGameserverAPIKey } from "../controllers/apikeys"; +import { ValidateMetagameJWTAndGetPayload } from "../controllers/authuser"; +import { IsValidGameserverAPIKey } from "../controllers/authgameserver"; import { JwtPayload } from "jsonwebtoken"; export async function HasUndauntedMetagameAuth(req: Request, res: Response, next: NextFunction){ diff --git a/UndauntedMetagame/src/routes/eos.ts b/UndauntedMetagame/src/routes/eos.ts index 81ab2fa..abd8430 100644 --- a/UndauntedMetagame/src/routes/eos.ts +++ b/UndauntedMetagame/src/routes/eos.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { logger } from "../logger"; -import { GetUserIDForAPIKey, SignMetagameJWTForUid } from "../controllers/auth"; +import { GetUserIDForAPIKey, SignMetagameJWTForUid } from "../controllers/authuser"; import { HasUndauntedMetagameAuth } from "../middleware/HasUndauntedMetagameAuth"; import { GetUsernameForUserId } from "../controllers/login"; @@ -137,4 +137,4 @@ eosRouter.get("/account/api/public/account", HasUndauntedMetagameAuth, (req: any "minorVerified": false, "minorStatus": "NOT_MINOR" }); -}); \ No newline at end of file +}); diff --git a/UndauntedMetagame/src/server.ts b/UndauntedMetagame/src/server.ts index 2c83822..3e11a15 100644 --- a/UndauntedMetagame/src/server.ts +++ b/UndauntedMetagame/src/server.ts @@ -1,6 +1,6 @@ import { app } from "./app"; -import { DrainAndRegisterAPIKeys } from "./controllers/apikeys"; -import { DrainAndRegisterUserAPIKeys } from "./controllers/auth"; +import { DrainAndRegisterAPIKeys } from "./controllers/authgameserver"; +import { DrainAndRegisterUserAPIKeys } from "./controllers/authuser"; import { GetDb } from "./db"; import { logger } from "./logger"; @@ -15,4 +15,4 @@ DrainAndRegisterAPIKeys().then(async () => { logger.info(`Undaunted Metagame on port ${PORT}`); logger.info(`Clear Skies, Slayer.`); }); -}); \ No newline at end of file +}); From d5739278272891f3afd4b23b9b052dd410d09b10 Mon Sep 17 00:00:00 2001 From: EisigesEis Date: Thu, 2 Jul 2026 18:03:02 +0200 Subject: [PATCH 3/3] flatMap for build cache, null explicit treatment, direct length --- .../src/controllers/apikeycache.ts | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/UndauntedMetagame/src/controllers/apikeycache.ts b/UndauntedMetagame/src/controllers/apikeycache.ts index 61507d7..9101845 100644 --- a/UndauntedMetagame/src/controllers/apikeycache.ts +++ b/UndauntedMetagame/src/controllers/apikeycache.ts @@ -1,7 +1,7 @@ import crypto from "crypto"; import { logger } from "../logger"; -type KeyRow = { keyHash?: string | null } +type KeyRow = { keyHash: string | null } type APIKeyLookup = { find: (apiKey: string) => Promise, has: (apiKey: string) => Promise, @@ -12,11 +12,15 @@ export function HashAPIKey(apiKeyToHash: string){ return crypto.createHash("sha256").update(apiKeyToHash, "utf8").digest("hex"); } -function normalizeHash(cacheName: string, keyHash: string){ - const hash = Buffer.from(keyHash, "hex"); +function normalizeHash(cacheName: string, keyHash: string | null){ + if(keyHash === null){ + return undefined; + } + + const hashBytes = Buffer.from(keyHash, "hex").length; - if(hash.length !== 32){ - logger.warn(`Ignoring invalid ${cacheName} API Key hash with ${hash.length} bytes`); + if(hashBytes !== 32){ + logger.warn(`Ignoring invalid ${cacheName} API Key hash with ${hashBytes} bytes`); return undefined; } @@ -49,19 +53,12 @@ export function CreateAPIKeyCache( loadRows: () => Promise, select: (row: TRow) => TValue = () => true as TValue ): APIKeyLookup { - const keys = cached(async () => { - const fresh = new Map(); - - for(const row of await loadRows()){ - const hash = row.keyHash == null ? undefined : normalizeHash(cacheName, row.keyHash); - - if(hash !== undefined){ - fresh.set(hash, select(row)); - } - } - - return fresh; - }); + const keys = cached(async () => new Map( + (await loadRows()).flatMap((row): [string, TValue][] => { + const hash = normalizeHash(cacheName, row.keyHash); + return hash === undefined ? [] : [[hash, select(row)]]; + }) + )); return { async find(apiKey: string){