Files
Vanadium-2023-2025-Leak-Server/Guard/Server/Sessions/GuardSession.cs
T
skidfire fe3468c65d yikes,
2026-09-06 19:59:27 -04:00

250 lines
8.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using VanadiumGuard.Protocol;
namespace VanadiumGuard.Server.Sessions
{
/// <summary>
/// Everything the service knows about one player's guard for one play session.
/// <para>
/// Instances are mutated from concurrent requests, so all access goes through the
/// instance lock. Sessions are short-lived and low in number relative to request rate,
/// so a lock per session is cheaper and far easier to reason about than making every
/// field individually atomic.
/// </para>
/// </summary>
public sealed class GuardSession
{
private readonly object _gate = new object();
private readonly Dictionary<string, RecordedDetection> _detections = new Dictionary<string, RecordedDetection>();
private readonly HashSet<string> _seenNonces = new HashSet<string>(StringComparer.Ordinal);
private readonly Dictionary<string, MemoryChallenge> _openChallenges = new Dictionary<string, MemoryChallenge>();
public GuardSession(string sessionId, byte[] sessionKey, string playerId, string gameSessionId, string gameBuild)
{
SessionId = sessionId;
SessionKey = sessionKey;
PlayerId = playerId;
GameSessionId = gameSessionId;
GameBuild = gameBuild;
OpenedUtc = DateTimeOffset.UtcNow;
_lastSeenUtc = OpenedUtc;
}
public string SessionId { get; }
public byte[] SessionKey { get; }
public string PlayerId { get; }
public string GameSessionId { get; }
public string GameBuild { get; }
public DateTimeOffset OpenedUtc { get; }
// DateTimeOffset is wider than a machine word, so these are read under the lock
// rather than exposed as plain auto-properties. The sweeper reads them from a
// different thread than the request handlers that write them.
private DateTimeOffset _lastSeenUtc;
private DateTimeOffset? _lastHeartbeatUtc;
public DateTimeOffset LastSeenUtc
{
get { lock (_gate) { return _lastSeenUtc; } }
}
public DateTimeOffset? LastHeartbeatUtc
{
get { lock (_gate) { return _lastHeartbeatUtc; } }
}
public string PolicyVersion { get; set; } = "0";
/// <summary>
/// Composite machine id this session opened from, empty when the client could not
/// identify the machine. Held so a hardware ban issued mid-match can find the live
/// sessions it applies to.
/// </summary>
public string MachineComposite { get; set; } = string.Empty;
/// <summary>
/// Base64 public key bound at session open, empty when the client had none. When set,
/// every signed request must also carry a signature from its private half.
/// </summary>
public string AttestationKey { get; set; } = string.Empty;
/// <summary>What is holding that key. Only Tpm counts as attested.</summary>
public AttestationBacking KeyBacking { get; set; } = AttestationBacking.None;
/// <summary>
/// True when requests on this session are proven to come from the machine that opened
/// it. A software key is deliberately not enough: the guard can read it, so anyone
/// with the same access can too.
/// </summary>
public bool IsAttested => KeyBacking == AttestationBacking.Tpm && AttestationKey.Length > 0;
/// <summary>Set once a disconnect has been issued, so it is not issued repeatedly.</summary>
public bool Terminated { get; private set; }
/// <summary>Highest sequence number accepted. Replays and reorders are rejected.</summary>
public long LastSequence { get; private set; }
/// <summary>
/// Set when something outside the scoring path has already decided this session is
/// over - today, a hardware ban issued while the player was mid-match. Held so the
/// player gets the real reason instead of a generic termination code.
/// </summary>
private Verdict? _forced;
public Verdict? ForcedVerdict
{
get { lock (_gate) { return _forced; } }
}
public void Force(Verdict verdict)
{
lock (_gate)
{
_forced = verdict;
Terminated = true;
}
}
public sealed class RecordedDetection
{
public Detection Detection { get; init; } = null!;
public double Score { get; set; }
public DateTimeOffset FirstUtc { get; init; }
public DateTimeOffset LastUtc { get; set; }
}
/// <summary>
/// Checks a request's sequence and nonce. Returns false for a replay.
/// </summary>
public bool AcceptRequest(long sequence, string nonce)
{
lock (_gate)
{
if (sequence <= LastSequence)
return false;
if (!_seenNonces.Add(nonce))
return false;
// The nonce set is bounded by the sequence check anyway; trim it so a long
// session cannot grow it without limit.
if (_seenNonces.Count > 4096)
_seenNonces.Clear();
LastSequence = sequence;
_lastSeenUtc = DateTimeOffset.UtcNow;
return true;
}
}
public void MarkHeartbeat()
{
lock (_gate)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_lastHeartbeatUtc = now;
_lastSeenUtc = now;
}
}
public void MarkTerminated()
{
lock (_gate)
{
Terminated = true;
}
}
/// <summary>
/// Records a detection and its score contribution. Repeats of the same subject add
/// a heavily damped amount, so a detector firing in a loop cannot inflate a score
/// past the threshold on its own.
/// </summary>
public void Record(Detection detection, double score)
{
lock (_gate)
{
string key = detection.DedupeKey();
if (_detections.TryGetValue(key, out RecordedDetection? existing))
{
existing.LastUtc = DateTimeOffset.UtcNow;
existing.Score += score * 0.1;
existing.Detection.OccurrenceCount += detection.OccurrenceCount;
return;
}
_detections[key] = new RecordedDetection
{
Detection = detection,
Score = score,
FirstUtc = DateTimeOffset.UtcNow,
LastUtc = DateTimeOffset.UtcNow,
};
}
}
/// <summary>Total risk score, capped at 100.</summary>
public int RiskScore()
{
lock (_gate)
{
double total = _detections.Values.Sum(d => d.Score);
return (int)Math.Clamp(total, 0, 100);
}
}
public IReadOnlyList<RecordedDetection> Detections()
{
lock (_gate)
{
return _detections.Values
.OrderByDescending(d => d.Score)
.ToList();
}
}
public void AddChallenge(MemoryChallenge challenge)
{
lock (_gate)
{
_openChallenges[challenge.ChallengeId] = challenge;
}
}
/// <summary>Takes a challenge out of the open set. Null means unknown or already answered.</summary>
public MemoryChallenge? ClaimChallenge(string challengeId)
{
lock (_gate)
{
if (!_openChallenges.TryGetValue(challengeId, out MemoryChallenge? challenge))
return null;
_openChallenges.Remove(challengeId);
return challenge;
}
}
/// <summary>Challenges issued long enough ago that an answer should have arrived.</summary>
public IReadOnlyList<MemoryChallenge> ExpiredChallenges(TimeSpan age)
{
DateTimeOffset cutoff = DateTimeOffset.UtcNow - age;
lock (_gate)
{
var expired = _openChallenges.Values.Where(c => c.IssuedUtc < cutoff).ToList();
foreach (MemoryChallenge challenge in expired)
_openChallenges.Remove(challenge.ChallengeId);
return expired;
}
}
}
}