From 46f095ff8b041a45574afbb0dc9dfb9ea0588451 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 19:06:11 -0800 Subject: [PATCH] feat: add server-side anti-cheat (option A + D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option A — state validation on every save: - Cap all resources to RESOURCE_CAP (server enforces, not just client) - Block boss status rollback (defeated can't become non-defeated) - Block quest status rollback (completed can't become non-completed) - Block achievement rollback (unlockedAt can't be cleared or future-dated) - Block prestige count rollback (count can only go up) Option D — HMAC signed save chain: - Server signs the saved state with ANTI_CHEAT_SECRET (env var) - Signature returned from both /game/load and /game/save - Client stores signature in localStorage, sends it with every save - Server verifies signature matches the previous DB state before accepting - Gracefully degrades: if secret unset or first save, checks are skipped Both options combine: a valid signature doesn't bypass A-validation; A-validation runs regardless and silently corrects tampered fields. --- apps/api/src/routes/game.ts | 97 ++++++++++++++++++++++++++-- apps/web/src/context/GameContext.tsx | 23 ++++++- packages/types/src/interfaces/Api.ts | 6 ++ 3 files changed, 117 insertions(+), 9 deletions(-) diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index e44e776..2ed145c 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -1,4 +1,5 @@ import type { GameState, SaveRequest } from "@elysium/types"; +import { createHmac } from "node:crypto"; import { Hono } from "hono"; import { prisma } from "../db/client.js"; import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.js"; @@ -7,6 +8,65 @@ import { DEFAULT_EQUIPMENT } from "../data/equipment.js"; import { authMiddleware } from "../middleware/auth.js"; import { calculateOfflineGold } from "../services/offlineProgress.js"; +const RESOURCE_CAP = 1e300; + +const computeHmac = (data: string, secret: string): string => + createHmac("sha256", secret).update(data).digest("hex"); + +/** + * Validates the incoming state against the previous saved state and returns a + * sanitised copy. Protects against: + * - Resources exceeding the cap + * - Defeating a boss being reversed + * - Completing a quest being reversed + * - Unlocking an achievement being reversed or backdated to a future timestamp + * - Prestige count going backwards + */ +const validateAndSanitize = (incoming: GameState, previous: GameState): GameState => { + const resources = { + gold: Math.min(incoming.resources.gold, RESOURCE_CAP), + essence: Math.min(incoming.resources.essence, RESOURCE_CAP), + crystals: Math.min(incoming.resources.crystals, RESOURCE_CAP), + runestones: Math.min(incoming.resources.runestones, RESOURCE_CAP), + }; + + const bosses = incoming.bosses.map((b) => { + const prev = previous.bosses.find((p) => p.id === b.id); + if (!prev) return b; + if (prev.status === "defeated" && b.status !== "defeated") { + return { ...b, status: "defeated" as const, currentHp: 0 }; + } + return b; + }); + + const quests = incoming.quests.map((q) => { + const prev = previous.quests.find((p) => p.id === q.id); + if (!prev) return q; + if (prev.status === "completed" && q.status !== "completed") { + return { ...prev }; + } + return q; + }); + + const now = Date.now(); + const achievements = incoming.achievements.map((a) => { + const prev = previous.achievements.find((p) => p.id === a.id); + if (!prev) return a; + if (prev.unlockedAt !== null && a.unlockedAt === null) { + return { ...a, unlockedAt: prev.unlockedAt }; + } + if (a.unlockedAt !== null && a.unlockedAt > now) { + return { ...a, unlockedAt: prev.unlockedAt ?? null }; + } + return a; + }); + + const prestige = + incoming.prestige.count < previous.prestige.count ? previous.prestige : incoming.prestige; + + return { ...incoming, resources, bosses, quests, achievements, prestige }; +}; + export const gameRouter = new Hono(); gameRouter.use("*", authMiddleware); @@ -272,7 +332,9 @@ gameRouter.get("/load", async (context) => { }); } - return context.json({ state, offlineGold, offlineSeconds }); + const secret = process.env.ANTI_CHEAT_SECRET; + const signature = secret ? computeHmac(JSON.stringify(state), secret) : undefined; + return context.json({ state, offlineGold, offlineSeconds, signature }); }); gameRouter.post("/save", async (context) => { @@ -283,23 +345,44 @@ gameRouter.post("/save", async (context) => { return context.json({ error: "Missing state in request body" }, 400); } + const secret = process.env.ANTI_CHEAT_SECRET; + const record = await prisma.gameState.findUnique({ where: { discordId } }); + + let stateToSave = body.state; + + if (record) { + const previousState = record.state as unknown as GameState; + + // Option D: verify HMAC signature if the secret is configured and client sent one + if (secret && body.signature) { + const expectedSig = computeHmac(JSON.stringify(previousState), secret); + if (body.signature !== expectedSig) { + return context.json({ error: "Save rejected: signature mismatch" }, 400); + } + } + + // Option A: sanitise the incoming state against the previous to block rollbacks and cap cheats + stateToSave = validateAndSanitize(body.state, previousState); + } + const now = Date.now(); await prisma.player.update({ where: { discordId }, data: { lastSavedAt: now, - totalGoldEarned: body.state.player.totalGoldEarned, - totalClicks: body.state.player.totalClicks, - characterName: body.state.player.characterName, + totalGoldEarned: stateToSave.player.totalGoldEarned, + totalClicks: stateToSave.player.totalClicks, + characterName: stateToSave.player.characterName, }, }); await prisma.gameState.upsert({ where: { discordId }, - create: { discordId, state: body.state, updatedAt: now }, - update: { state: body.state, updatedAt: now }, + create: { discordId, state: stateToSave, updatedAt: now }, + update: { state: stateToSave, updatedAt: now }, }); - return context.json({ savedAt: now }); + const signature = secret ? computeHmac(JSON.stringify(stateToSave), secret) : undefined; + return context.json({ savedAt: now, signature }); }); diff --git a/apps/web/src/context/GameContext.tsx b/apps/web/src/context/GameContext.tsx index 16c2a32..0a6f83d 100644 --- a/apps/web/src/context/GameContext.tsx +++ b/apps/web/src/context/GameContext.tsx @@ -82,6 +82,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const isSyncingRef = useRef(false); const rafRef = useRef(null); const newlyUnlockedRef = useRef([]); + const signatureRef = useRef(localStorage.getItem("elysium_save_signature")); stateRef.current = state; @@ -92,6 +93,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React const data = await loadGame(); setState(data.state); setLastSavedAt(data.state.player.lastSavedAt); + if (data.signature) { + signatureRef.current = data.signature; + localStorage.setItem("elysium_save_signature", data.signature); + } if (data.offlineGold > 0) { setOfflineGold(data.offlineGold); } @@ -149,8 +154,15 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) { lastSaveRef.current = Date.now(); if (stateRef.current) { - void saveGame({ state: stateRef.current }).then((response) => { + void saveGame({ + state: stateRef.current, + signature: signatureRef.current ?? undefined, + }).then((response) => { setLastSavedAt(response.savedAt); + if (response.signature) { + signatureRef.current = response.signature; + localStorage.setItem("elysium_save_signature", response.signature); + } }); } } @@ -172,9 +184,16 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React isSyncingRef.current = true; setIsSyncing(true); try { - const response = await saveGame({ state: stateRef.current }); + const response = await saveGame({ + state: stateRef.current, + signature: signatureRef.current ?? undefined, + }); setLastSavedAt(response.savedAt); lastSaveRef.current = Date.now(); + if (response.signature) { + signatureRef.current = response.signature; + localStorage.setItem("elysium_save_signature", response.signature); + } } finally { isSyncingRef.current = false; setIsSyncing(false); diff --git a/packages/types/src/interfaces/Api.ts b/packages/types/src/interfaces/Api.ts index 1038a98..8057255 100644 --- a/packages/types/src/interfaces/Api.ts +++ b/packages/types/src/interfaces/Api.ts @@ -10,10 +10,14 @@ export interface AuthResponse { export interface SaveRequest { state: GameState; + /** HMAC-SHA256 signature of the previous save's state, for anti-cheat chain verification */ + signature?: string; } export interface SaveResponse { savedAt: number; + /** HMAC-SHA256 signature of the saved state — store and include in next save request */ + signature?: string; } export interface LoadResponse { @@ -22,6 +26,8 @@ export interface LoadResponse { offlineGold: number; /** Seconds the player was offline (capped at 8 hours) */ offlineSeconds: number; + /** HMAC-SHA256 signature of the loaded state — store and include in next save request */ + signature?: string; } export interface BossChallengeRequest {