feat: add server-side anti-cheat (option A + D)

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.
This commit is contained in:
2026-03-06 19:06:11 -08:00
committed by Naomi Carrigan
parent 5ad2c44399
commit 46f095ff8b
3 changed files with 117 additions and 9 deletions
+90 -7
View File
@@ -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 });
});