yikes,
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace VanadiumGuard.Server.Sessions
|
||||
{
|
||||
/// <summary>
|
||||
/// Session persistence. The in-memory implementation is fine for a single instance;
|
||||
/// swap in Redis or a database behind this interface to run more than one.
|
||||
/// </summary>
|
||||
public interface ISessionStore
|
||||
{
|
||||
void Add(GuardSession session);
|
||||
|
||||
GuardSession? Get(string sessionId);
|
||||
|
||||
/// <summary>Looks a session up by the match id the game server knows it as.</summary>
|
||||
GuardSession? GetByGameSession(string gameSessionId);
|
||||
|
||||
void Remove(string sessionId);
|
||||
|
||||
IReadOnlyList<GuardSession> All();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace VanadiumGuard.Server.Sessions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public sealed class InMemorySessionStore : ISessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, GuardSession> _bySessionId =
|
||||
new ConcurrentDictionary<string, GuardSession>(StringComparer.Ordinal);
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _gameSessionToSessionId =
|
||||
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
public void Add(GuardSession session)
|
||||
{
|
||||
_bySessionId[session.SessionId] = session;
|
||||
|
||||
if (!string.IsNullOrEmpty(session.GameSessionId))
|
||||
_gameSessionToSessionId[session.GameSessionId] = session.SessionId;
|
||||
}
|
||||
|
||||
public GuardSession? Get(string sessionId)
|
||||
{
|
||||
return _bySessionId.TryGetValue(sessionId, out GuardSession? session) ? session : null;
|
||||
}
|
||||
|
||||
public GuardSession? GetByGameSession(string gameSessionId)
|
||||
{
|
||||
if (!_gameSessionToSessionId.TryGetValue(gameSessionId, out string? sessionId))
|
||||
return null;
|
||||
|
||||
return Get(sessionId);
|
||||
}
|
||||
|
||||
public void Remove(string sessionId)
|
||||
{
|
||||
if (!_bySessionId.TryRemove(sessionId, out GuardSession? session))
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(session.GameSessionId))
|
||||
_gameSessionToSessionId.TryRemove(session.GameSessionId, out _);
|
||||
}
|
||||
|
||||
public IReadOnlyList<GuardSession> All()
|
||||
{
|
||||
return _bySessionId.Values.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user