Ajoute un ping serveur régulier comme troisième couche d'anti-triche sur Icare et la Corne
Build and deploy / deploy (push) Successful in 36s

duration_ms/actions/merges restaient entièrement déclaratifs (calculés et envoyés par le client en une fois à la fin), donc falsifiables par un appel RPC direct. Le client envoie maintenant un ping toutes les 5s pendant la partie (ping_game_run), horodaté par le serveur, jamais par le client. submit_icarus_score/submit_melon_score corrèlent le nombre de pings et leur écart réel avec la durée annoncée (parties ≥ 15s uniquement) et signalent toute incohérence dans game_cheat_flags (missing_pings, ping_span_mismatch) — toujours en signalement silencieux, jamais de blocage, comme pour le reste de l'anti-triche.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-25 16:58:51 +02:00
parent 088cd92e5f
commit 00743067cc
6 changed files with 290 additions and 11 deletions
+180
View File
@@ -638,6 +638,15 @@ declare
v_duration_ms numeric;
v_actions numeric;
v_details jsonb;
-- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(),
-- section 18) : preuve horodatée par le SERVEUR, contrairement à
-- duration_ms/actions ci-dessus qui restent entièrement déclaratifs
-- (calculés et envoyés par le client en une seule fois à la fin, donc
-- falsifiables par un appel RPC direct sans avoir vraiment joué).
v_session_token uuid;
v_ping_count integer;
v_first_ping_at timestamptz;
v_last_ping_at timestamptz;
begin
if auth.uid() is null then
raise exception 'authentication required';
@@ -680,6 +689,17 @@ begin
v_actions := (p_run ->> 'actions')::numeric;
end if;
-- Extraction protégée (begin/exception imbriqué) : une valeur
-- malformée ne doit jamais faire échouer toute la soumission, juste
-- être traitée comme une absence de token (donc 0 ping trouvé,
-- signalé plus bas comme les autres incohérences).
begin
v_session_token := nullif(p_run ->> 'session_token', '')::uuid;
exception
when invalid_text_representation then
v_session_token := null;
end;
v_details := jsonb_build_object(
'duration_ms', v_duration_ms,
'actions', v_actions,
@@ -747,6 +767,53 @@ begin
v_details
);
end if;
-- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive
-- qu'après un cycle d'intervalle côté client, ~5s) : pas de
-- signalement pour une partie courte, y compris un échec immédiat.
if v_duration_ms >= 15000 then
select count(*), min(created_at), max(created_at)
into v_ping_count, v_first_ping_at, v_last_ping_at
from public.game_run_pings
where user_id = auth.uid() and game = 'icarus' and session_token = v_session_token;
if v_ping_count = 0 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'icarus',
p_score,
'high',
'missing_pings',
'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.',
v_details || jsonb_build_object('ping_count', v_ping_count)
);
elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'icarus',
p_score,
'high',
'ping_span_mismatch',
'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.',
v_details || jsonb_build_object(
'ping_count', v_ping_count,
'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000
)
);
end if;
end if;
end if;
-- Nettoyage : les pings de cette partie ont été consommés (signalés ou
-- non), inutile de les garder — évite aussi qu'un même session_token
-- réutilisé par erreur ne corrèle avec une partie précédente.
if v_session_token is not null then
delete from public.game_run_pings
where user_id = auth.uid() and session_token = v_session_token;
end if;
end if;
@@ -1750,6 +1817,14 @@ declare
v_citizen_count integer;
v_max_tier integer;
v_details jsonb;
-- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(),
-- section 18) : preuve horodatée par le SERVEUR, contrairement à
-- duration_ms/actions/merges ci-dessus qui restent entièrement
-- déclaratifs — voir la même remarque sur submit_icarus_score.
v_session_token uuid;
v_ping_count integer;
v_first_ping_at timestamptz;
v_last_ping_at timestamptz;
begin
if auth.uid() is null then
raise exception 'authentication required';
@@ -1802,6 +1877,17 @@ begin
v_merges := (p_run ->> 'merges')::numeric;
end if;
-- Extraction protégée (begin/exception imbriqué) : une valeur
-- malformée ne doit jamais faire échouer toute la soumission, juste
-- être traitée comme une absence de token (donc 0 ping trouvé,
-- signalé plus bas comme les autres incohérences).
begin
v_session_token := nullif(p_run ->> 'session_token', '')::uuid;
exception
when invalid_text_representation then
v_session_token := null;
end;
v_details := jsonb_build_object(
'duration_ms', v_duration_ms,
'actions', v_actions,
@@ -1888,6 +1974,53 @@ begin
v_details
);
end if;
-- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive
-- qu'après un cycle d'intervalle côté client, ~5s) : pas de
-- signalement pour une partie courte, y compris un échec immédiat.
if v_duration_ms >= 15000 then
select count(*), min(created_at), max(created_at)
into v_ping_count, v_first_ping_at, v_last_ping_at
from public.game_run_pings
where user_id = auth.uid() and game = 'melon' and session_token = v_session_token;
if v_ping_count = 0 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'missing_pings',
'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.',
v_details || jsonb_build_object('ping_count', v_ping_count)
);
elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then
insert into public.game_cheat_flags
(user_id, game, score, severity, trigger_code, reason, details)
values (
auth.uid(),
'melon',
p_score,
'high',
'ping_span_mismatch',
'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.',
v_details || jsonb_build_object(
'ping_count', v_ping_count,
'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000
)
);
end if;
end if;
end if;
-- Nettoyage : les pings de cette partie ont été consommés (signalés ou
-- non), inutile de les garder — évite aussi qu'un même session_token
-- réutilisé par erreur ne corrèle avec une partie précédente.
if v_session_token is not null then
delete from public.game_run_pings
where user_id = auth.uid() and session_token = v_session_token;
end if;
end if;
@@ -2034,3 +2167,50 @@ create policy "game_cheat_flags readable by judges"
on public.game_cheat_flags for select
to authenticated
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
-- 18. Anti-triche : ping régulier pendant la partie --------------------------
-- Complète le signalement (section 17) : duration_ms/actions/merges dans
-- p_run sont entièrement déclaratifs (calculés et envoyés par le client en
-- une seule fois à la fin), donc falsifiables par quelqu'un qui appelle
-- submit_icarus_score/submit_melon_score directement sans avoir vraiment
-- joué. Le client envoie un ping régulier PENDANT la partie (toutes les
-- ~5s tant qu'elle est en cours), horodaté ici par l'horloge du SERVEUR —
-- submit_icarus_score/submit_melon_score (section 11/16) corrèlent ensuite
-- le nombre de pings et l'écart réel entre le premier et le dernier avec
-- la durée annoncée, et signalent (jamais ne bloquent, même philosophie
-- que le reste de l'anti-triche) toute incohérence.
create table if not exists public.game_run_pings (
id bigint generated always as identity primary key,
user_id uuid not null references public.profiles (id) on delete cascade,
game text not null check (game in ('icarus', 'melon')),
session_token uuid not null,
created_at timestamptz not null default now()
);
alter table public.game_run_pings enable row level security;
-- Aucun grant à authenticated/anon (même principe que l'ancien icarus_runs,
-- section 11) : seules ping_game_run() (insert) et submit_icarus_score/
-- submit_melon_score (lecture + nettoyage), toutes SECURITY DEFINER,
-- contournent la RLS en tant que propriétaires des fonctions.
create or replace function public.ping_game_run(p_game text, p_session_token uuid)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
if p_game not in ('icarus', 'melon') then
raise exception 'invalid game';
end if;
insert into public.game_run_pings (user_id, game, session_token)
values (auth.uid(), p_game, p_session_token);
end;
$$;
grant execute on function public.ping_game_run(text, uuid) to authenticated;