generated from nhcarrigan/template
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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
const isSyncingRef = useRef(false);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const newlyUnlockedRef = useRef<Achievement[]>([]);
|
||||
const signatureRef = useRef<string | null>(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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user