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 type { GameState, SaveRequest } from "@elysium/types";
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { DEFAULT_ACHIEVEMENTS } from "../data/achievements.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 { authMiddleware } from "../middleware/auth.js";
|
||||||
import { calculateOfflineGold } from "../services/offlineProgress.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();
|
export const gameRouter = new Hono();
|
||||||
|
|
||||||
gameRouter.use("*", authMiddleware);
|
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) => {
|
gameRouter.post("/save", async (context) => {
|
||||||
@@ -283,23 +345,44 @@ gameRouter.post("/save", async (context) => {
|
|||||||
return context.json({ error: "Missing state in request body" }, 400);
|
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();
|
const now = Date.now();
|
||||||
|
|
||||||
await prisma.player.update({
|
await prisma.player.update({
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
data: {
|
data: {
|
||||||
lastSavedAt: now,
|
lastSavedAt: now,
|
||||||
totalGoldEarned: body.state.player.totalGoldEarned,
|
totalGoldEarned: stateToSave.player.totalGoldEarned,
|
||||||
totalClicks: body.state.player.totalClicks,
|
totalClicks: stateToSave.player.totalClicks,
|
||||||
characterName: body.state.player.characterName,
|
characterName: stateToSave.player.characterName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.gameState.upsert({
|
await prisma.gameState.upsert({
|
||||||
where: { discordId },
|
where: { discordId },
|
||||||
create: { discordId, state: body.state, updatedAt: now },
|
create: { discordId, state: stateToSave, updatedAt: now },
|
||||||
update: { state: body.state, 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 isSyncingRef = useRef(false);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
const newlyUnlockedRef = useRef<Achievement[]>([]);
|
const newlyUnlockedRef = useRef<Achievement[]>([]);
|
||||||
|
const signatureRef = useRef<string | null>(localStorage.getItem("elysium_save_signature"));
|
||||||
|
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
|
|
||||||
@@ -92,6 +93,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
const data = await loadGame();
|
const data = await loadGame();
|
||||||
setState(data.state);
|
setState(data.state);
|
||||||
setLastSavedAt(data.state.player.lastSavedAt);
|
setLastSavedAt(data.state.player.lastSavedAt);
|
||||||
|
if (data.signature) {
|
||||||
|
signatureRef.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
if (data.offlineGold > 0) {
|
if (data.offlineGold > 0) {
|
||||||
setOfflineGold(data.offlineGold);
|
setOfflineGold(data.offlineGold);
|
||||||
}
|
}
|
||||||
@@ -149,8 +154,15 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
if (Date.now() - lastSaveRef.current >= AUTO_SAVE_INTERVAL_MS) {
|
||||||
lastSaveRef.current = Date.now();
|
lastSaveRef.current = Date.now();
|
||||||
if (stateRef.current) {
|
if (stateRef.current) {
|
||||||
void saveGame({ state: stateRef.current }).then((response) => {
|
void saveGame({
|
||||||
|
state: stateRef.current,
|
||||||
|
signature: signatureRef.current ?? undefined,
|
||||||
|
}).then((response) => {
|
||||||
setLastSavedAt(response.savedAt);
|
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;
|
isSyncingRef.current = true;
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
try {
|
try {
|
||||||
const response = await saveGame({ state: stateRef.current });
|
const response = await saveGame({
|
||||||
|
state: stateRef.current,
|
||||||
|
signature: signatureRef.current ?? undefined,
|
||||||
|
});
|
||||||
setLastSavedAt(response.savedAt);
|
setLastSavedAt(response.savedAt);
|
||||||
lastSaveRef.current = Date.now();
|
lastSaveRef.current = Date.now();
|
||||||
|
if (response.signature) {
|
||||||
|
signatureRef.current = response.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", response.signature);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isSyncingRef.current = false;
|
isSyncingRef.current = false;
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ export interface AuthResponse {
|
|||||||
|
|
||||||
export interface SaveRequest {
|
export interface SaveRequest {
|
||||||
state: GameState;
|
state: GameState;
|
||||||
|
/** HMAC-SHA256 signature of the previous save's state, for anti-cheat chain verification */
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveResponse {
|
export interface SaveResponse {
|
||||||
savedAt: number;
|
savedAt: number;
|
||||||
|
/** HMAC-SHA256 signature of the saved state — store and include in next save request */
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadResponse {
|
export interface LoadResponse {
|
||||||
@@ -22,6 +26,8 @@ export interface LoadResponse {
|
|||||||
offlineGold: number;
|
offlineGold: number;
|
||||||
/** Seconds the player was offline (capped at 8 hours) */
|
/** Seconds the player was offline (capped at 8 hours) */
|
||||||
offlineSeconds: number;
|
offlineSeconds: number;
|
||||||
|
/** HMAC-SHA256 signature of the loaded state — store and include in next save request */
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BossChallengeRequest {
|
export interface BossChallengeRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user