using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using VanadiumGuard.Protocol;
using VanadiumGuard.Server.Policy;
using VanadiumGuard.Server.Sessions;
namespace VanadiumGuard.Server.Challenges
{
///
/// Issues memory challenges and grades the answers.
///
/// A challenge asks the client to hash a random region of a known module with a random
/// salt. Three outcomes matter: the right hash means a real guard is running inside a
/// real image; the wrong hash means the memory it read does not match what shipped;
/// no answer at all means the guard is not running, which is graded by the liveness
/// sweeper rather than here.
///
///
public sealed class ChallengeService
{
/// How long a client has to answer before the challenge is written off.
public static readonly TimeSpan AnswerWindow = TimeSpan.FromSeconds(60);
private const int MinReadLength = 256;
private readonly ILogger _logger;
private readonly IReferenceImageStore _images;
private readonly PolicyEngine _policy;
public ChallengeService(ILogger logger, IReferenceImageStore images, PolicyEngine policy)
{
_logger = logger;
_images = images;
_policy = policy;
}
/// Builds the next challenge for a session, or null if none can be issued.
public MemoryChallenge? Issue(GuardSession session, GuardPolicy policy)
{
string[] modules = policy.ChallengeableModules;
if (modules.Length == 0)
return null;
// Try each module once, in random order, so an unavailable reference image for
// one module does not silently stop challenges altogether.
foreach (string module in Shuffle(modules))
{
long size = _images.Size(session.GameBuild, module);
if (size < MinReadLength)
continue;
int length = RandomNumberGenerator.GetInt32(MinReadLength, ProtocolConstants.MaxChallengeReadLength + 1);
if (length > size)
length = (int)Math.Min(size, ProtocolConstants.MaxChallengeReadLength);
uint rva = (uint)RandomNumberGenerator.GetInt32(0, (int)Math.Min(int.MaxValue, size - length + 1));
var salt = new byte[16];
RandomNumberGenerator.Fill(salt);
var challenge = new MemoryChallenge
{
ChallengeId = Guid.NewGuid().ToString("N"),
Module = module,
Rva = rva,
Length = length,
Salt = Convert.ToHexString(salt).ToLowerInvariant(),
IssuedUtc = DateTimeOffset.UtcNow,
};
session.AddChallenge(challenge);
return challenge;
}
return null;
}
/// Grades answers, recording detections for anything that does not add up.
public void Grade(GuardSession session, IReadOnlyList answers)
{
foreach (ChallengeAnswer answer in answers)
{
MemoryChallenge? challenge = session.ClaimChallenge(answer.ChallengeId);
if (challenge == null)
{
// An answer to something never asked, or asked twice. Not worth a
// detection on its own, but worth knowing about.
_logger.LogInformation(
"Unsolicited challenge answer {Id} from session {Session}",
answer.ChallengeId, session.SessionId);
continue;
}
if (!answer.Readable)
{
_policy.RecordServerSide(
session, DetectionCode.ChallengeUnreadable, challenge.Module, 60,
new Dictionary
{
["error"] = answer.Error ?? "unspecified",
["rva"] = challenge.Rva.ToString(),
});
continue;
}
byte[]? reference = _images.Read(session.GameBuild, challenge.Module, challenge.Rva, challenge.Length);
if (reference == null)
{
// No reference image, so this answer proves liveness but nothing else.
// Explicitly NOT a detection: failing players because the operator has
// not published a build manifest would be the worst kind of bug.
_logger.LogDebug(
"No reference for build={Build} module={Module}; challenge {Id} counts as liveness only",
session.GameBuild, challenge.Module, challenge.ChallengeId);
continue;
}
string expected = RequestSigner.HashWithSalt(Convert.FromHexString(challenge.Salt), reference);
if (string.Equals(expected, answer.Hash, StringComparison.OrdinalIgnoreCase))
continue;
_policy.RecordServerSide(
session, DetectionCode.ChallengeFailed, challenge.Module, 95,
new Dictionary
{
["rva"] = "0x" + challenge.Rva.ToString("X"),
["length"] = challenge.Length.ToString(),
["expected"] = expected,
["received"] = answer.Hash,
});
}
}
private static IEnumerable Shuffle(string[] source)
{
var copy = (string[])source.Clone();
for (int i = copy.Length - 1; i > 0; i--)
{
int j = RandomNumberGenerator.GetInt32(i + 1);
(copy[i], copy[j]) = (copy[j], copy[i]);
}
return copy;
}
}
}