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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user