Migrated and generalized fGTS Gamestats logic to external library.

This commit is contained in:
Greg Edwards
2014-09-10 16:10:48 -04:00
parent d03b9821b1
commit 9ee8aabbb7
6 changed files with 680 additions and 14 deletions

View File

@@ -1,12 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GamestatsBase
{
public class Class1
{
}
}

75
GamestatsBase/Common.cs Normal file
View File

@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace GamestatsBase
{
public static class Common
{
public static string ToHexStringUpper(this byte[] bytes)
{
// http://stackoverflow.com/questions/311165/how-do-you-convert-byte-array-to-hexadecimal-string-and-vice-versa/14333437#14333437
char[] c = new char[bytes.Length * 2];
int b;
for (int i = 0; i < bytes.Length; i++)
{
b = bytes[i] >> 4;
c[i * 2] = (char)(55 + b + (((b - 10) >> 31) & -7));
b = bytes[i] & 0xF;
c[i * 2 + 1] = (char)(55 + b + (((b - 10) >> 31) & -7));
}
return new string(c);
}
public static string ToHexStringLower(this byte[] bytes)
{
char[] c = new char[bytes.Length * 2];
int b;
for (int i = 0; i < bytes.Length; i++)
{
b = bytes[i] >> 4;
c[i * 2] = (char)(87 + b + (((b - 10) >> 31) & -39));
b = bytes[i] & 0xF;
c[i * 2 + 1] = (char)(87 + b + (((b - 10) >> 31) & -39));
}
return new string(c);
}
public static byte[] FromHexString(String hex)
{
// very suboptimal but error tolerant
byte output = 0;
List<byte> result = new List<byte>(hex.Length / 2);
bool havePrev = false;
foreach (char c in hex.ToCharArray())
{
if (c >= '0' && c <= '9')
{
output |= (byte)(c - '0');
}
if (c >= 'A' && c <= 'F')
{
output |= (byte)(c - '7');
}
if (c >= 'a' && c <= 'f')
{
output |= (byte)(c - 'W');
}
if (havePrev)
{
havePrev = false;
result.Add(output);
output = 0;
}
else
{
havePrev = true;
output <<= 4;
}
}
return result.ToArray();
}
}
}

View File

@@ -9,8 +9,9 @@
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>GamestatsBase</RootNamespace>
<AssemblyName>GamestatsBase</AssemblyName>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<TargetFrameworkProfile />
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
@@ -32,6 +33,7 @@
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Web" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
@@ -39,7 +41,10 @@
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Class1.cs" />
<Compile Include="Common.cs" />
<Compile Include="GamestatsHandler.cs" />
<Compile Include="GamestatsSession.cs" />
<Compile Include="GamestatsSessionManager.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />

View File

@@ -0,0 +1,361 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Web.SessionState;
namespace GamestatsBase
{
public class GamestatsHandler : IHttpHandler, IRequiresSessionState
{
public GamestatsHandler(String initString, GamestatsRequestVersions reqVersion,
GamestatsResponseVersions respVersion)
{
if (initString.Length < 44) throw new FormatException();
String salt = initString.Substring(0, 20);
uint rngMul = UInt32.Parse(initString.Substring(20, 8), NumberStyles.AllowHexSpecifier);
uint rngAdd = UInt32.Parse(initString.Substring(28, 8), NumberStyles.AllowHexSpecifier);
uint rngMask = UInt32.Parse(initString.Substring(36, 8), NumberStyles.AllowHexSpecifier);
uint hashMask = UInt32.Parse(initString.Substring(44, 8), NumberStyles.AllowHexSpecifier);
String gameId = initString.Substring(52);
Initialize(salt, rngMul, rngAdd, rngMask, hashMask, gameId, reqVersion, respVersion);
}
public GamestatsHandler(String salt, uint rngMul, uint rngAdd, uint rngMask,
uint hashMask,
String gameId, GamestatsRequestVersions reqVersion, GamestatsResponseVersions respVersion)
{
Initialize(salt, rngMul, rngAdd, rngMask, hashMask, gameId, reqVersion, respVersion);
}
private void Initialize(String salt, uint rngMul, uint rngAdd, uint rngMask,
uint hashMask,
String gameId, GamestatsRequestVersions reqVersion, GamestatsResponseVersions respVersion)
{
if (salt.Length != 20) throw new FormatException();
Salt = salt;
RngMul = rngMul;
RngAdd = rngAdd;
RngMask = rngMask;
HashMask = hashMask;
GameId = gameId;
RequestVersion = reqVersion;
ResponseVersion = respVersion;
}
public String Salt { get; private set; }
public uint RngMul { get; private set; }
public uint RngAdd { get; private set; }
public uint RngMask { get; private set; }
public uint HashMask { get; private set; }
public String GameId { get; private set; }
public GamestatsRequestVersions RequestVersion { get; private set; }
public GamestatsResponseVersions ResponseVersion { get; private set; }
public void ProcessRequest(HttpContext context)
{
int pid;
if (context.Request.QueryString["pid"] == null ||
!Int32.TryParse(context.Request.QueryString["pid"], out pid))
{
// pid missing or bad format
ShowError(context, 400);
return;
}
if (context.Request.QueryString.Count == 1)
{
// this is a new session request
GamestatsSession session = CreateSession(pid, context.Request.PathInfo);
GamestatsSessionManager manager = GamestatsSessionManager.FromContext(context);
manager.Add(session);
context.Response.Write(session.Token);
return;
}
else if (context.Request.QueryString.Count >= 3)
{
// this is a main request
if (context.Request.QueryString["hash"] == null ||
context.Request.QueryString["data"] == null ||
context.Request.QueryString["data"].Length <
((RequestVersion == GamestatsRequestVersions.Version1) ? 12 : 16))
{
// arguments missing, partial check for data length.
// In version 1, we require data to hold at least 7 bytes
// in this check. In reality, it must hold at least 8,
// which is checked for below after decoding
// In version 2, we require data to hold at least 10 bytes
// in this check, but it actually needs to hold 12.
// We do incomplete checks so we can fail as early as
// possible. In this case, before base64 decoding happens.
ShowError(context, 400);
return;
}
GamestatsSessionManager manager = GamestatsSessionManager.FromContext(context);
if (!manager.Sessions.ContainsKey(context.Request.QueryString["hash"]))
{
// session hash not matched
ShowError(context, 400);
return;
}
GamestatsSession session = manager.Sessions[context.Request.QueryString["hash"]];
if (session.GameId != GameId)
{
// matched wrong game. Highly unlikely
ShowError(context, 400);
return;
}
byte[] data;
try
{
data = DecryptData(context.Request.QueryString["data"]);
if (data.Length < ((RequestVersion == GamestatsRequestVersions.Version1)
? 4 : 8))
{
// data too short to contain a pid
// We check for 4 bytes, not 8, since the decrypt seed
// isn't included in DecryptData's result.
// On version 2, we require 8 bytes (but not 12) to
// make room for the length field.
ShowError(context, 400);
return;
}
}
catch (FormatException)
{
// data too short to contain a checksum,
// base64 format errors
ShowError(context, 400);
return;
}
int pid2 = BitConverter.ToInt32(data, 0);
if (pid2 != pid)
{
// packed pid doesn't match ?pid=
ShowError(context, 400);
return;
}
if (RequestVersion != GamestatsRequestVersions.Version1)
{
int length = BitConverter.ToInt32(data, 4);
if (length + 8 != data.Length)
{
// message length is incorrect
ShowError(context, 400);
return;
}
}
int trimLength = (RequestVersion == GamestatsRequestVersions.Version1) ? 4 : 8;
byte[] dataTrim = new byte[data.Length - trimLength];
Array.Copy(data, trimLength, dataTrim, 0, data.Length - trimLength);
MemoryStream response = new MemoryStream();
try
{
ProcessGamestatsRequest(dataTrim, response, context.Request.PathInfo, pid, context);
}
catch (GamestatsException ex)
{
ShowError(context, ex.ResponseCode, ex.Message);
return;
}
catch (Exception)
{
ShowError(context, 500);
return;
}
response.Flush();
byte[] responseArray = response.ToArray();
response.Dispose();
response = null;
context.Response.OutputStream.Write(responseArray, 0, responseArray.Length);
if (ResponseVersion == GamestatsResponseVersions.Version3)
context.Response.Write("done");
if (ResponseVersion != GamestatsResponseVersions.Version1)
context.Response.Write(ResponseChecksum(responseArray));
}
else
{
// wrong number of querystring arguments
ShowError(context, 400);
return;
}
}
private void ShowError(HttpContext context, int responseCode)
{
ShowError(context, responseCode, GamestatsException.defaultMessage(responseCode));
}
private void ShowError(HttpContext context, int responseCode, String message)
{
context.Response.StatusCode = responseCode;
context.Response.Write(GamestatsException.defaultMessage(responseCode));
}
public virtual bool IsReusable
{
get
{
return true;
}
}
public virtual void ProcessGamestatsRequest(byte[] request, MemoryStream response, String url, int pid, HttpContext context)
{
}
/// <summary>
/// Session factory if you need it. You probably don't.
/// </summary>
public virtual GamestatsSession CreateSession(int pid, String url)
{
return new GamestatsSession(GameId, Salt, pid, url);
}
/// <summary>
/// Decrypts the NDS &data= querystring into readable binary data.
/// The PID (little endian) is left at the start of the output
/// but the (unencrypted) checksum is removed.
/// </summary>
private byte[] DecryptData(String data)
{
byte[] data2 = FromUrlSafeBase64String(data);
if (data2.Length < 4) throw new FormatException("Data must contain at least 4 bytes.");
byte[] data3 = new byte[data2.Length - 4];
int checksum = BitConverter.ToInt32(data2, 0);
checksum = IPAddress.NetworkToHostOrder(checksum); // endian flip
checksum ^= (int)HashMask;
uint rand = (uint)(checksum | (checksum << 16));
for (int pos = 0; pos < data3.Length; pos++)
{
rand = DecryptRNG(rand);
data3[pos] = (byte)(data2[pos + 4] ^ (byte)(rand >> 16));
}
int checkedsum = 0;
foreach (byte b in data3)
checkedsum += b;
if (checkedsum != checksum) throw new FormatException("Data checksum is incorrect.");
return data3;
}
private static uint DecryptRNG(uint prev, uint mul, uint add, uint mask)
{
return (prev * mul + add) & ~mask;
}
private uint DecryptRNG(uint prev)
{
return DecryptRNG(prev, RngMul, RngAdd, RngMask);
}
public static byte[] FromUrlSafeBase64String(String data)
{
return Convert.FromBase64String(data.Replace('-', '+').Replace('_', '/'));
}
public static String ToUrlSafeBase64String(byte[] data)
{
return Convert.ToBase64String(data).Replace('+', '-').Replace('/', '_');
}
private static SHA1 m_sha1;
private String ResponseChecksum(byte[] responseArray)
{
if (m_sha1 == null) m_sha1 = SHA1.Create();
String toCheck = Salt + ToUrlSafeBase64String(responseArray) + Salt;
byte[] data = new byte[toCheck.Length];
MemoryStream stream = new MemoryStream(data);
StreamWriter writer = new StreamWriter(stream);
writer.Write(toCheck);
writer.Flush();
return m_sha1.ComputeHash(data).ToHexStringLower();
}
}
public enum GamestatsRequestVersions
{
Version1,
Version2
}
public enum GamestatsResponseVersions
{
Version1,
Version2,
Version3
}
public class GamestatsException : ApplicationException
{
public readonly int ResponseCode;
public GamestatsException() : this(500)
{
}
public GamestatsException(int responseCode)
: base(defaultMessage(responseCode))
{
ResponseCode = responseCode;
}
public GamestatsException(int responseCode, String message)
: base(message)
{
ResponseCode = responseCode;
}
public GamestatsException(int responseCode, String message, Exception innerException)
: base(message, innerException)
{
ResponseCode = responseCode;
}
public static String defaultMessage(int responseCode)
{
switch (responseCode)
{
case 400:
return "Bad request";
case 404:
return "This handler is not supported. (404)";
case 500:
default:
return "Server error";
}
}
}
}

View File

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
namespace GamestatsBase
{
public class GamestatsSession
{
protected static RNGCryptoServiceProvider m_rng;
protected static SHA1 m_sha1;
private int m_pid;
private String m_url;
private String m_token;
private String m_hash;
private DateTime m_expiry_date;
public GamestatsSession(String gameId, String salt, int pid, String url)
{
PID = pid;
URL = url;
ExpiryDate = DateTime.UtcNow.AddMinutes(10);
Token = CreateToken();
Hash = CreateHash(Token, salt);
GameId = gameId;
}
/// <summary>
/// A PID is a user ID which is associated with a game cartridge.
/// </summary>
public int PID
{
get
{
return m_pid;
}
protected set
{
m_pid = value;
}
}
/// <summary>
/// The URL in which this session began
/// </summary>
public String URL
{
get
{
return m_url;
}
protected set
{
m_url = value;
}
}
/// <summary>
/// 32 chars of random data which both functions as a challenge
/// and as a session ID
/// </summary>
public String Token
{
get
{
return m_token;
}
protected set
{
m_token = value;
}
}
public String Hash
{
get
{
return m_hash;
}
protected set
{
m_hash = value;
}
}
public DateTime ExpiryDate
{
get
{
return m_expiry_date;
}
protected set
{
m_expiry_date = value;
}
}
public String GameId
{
get;
protected set;
}
public object Tag
{
get;
set;
}
public static String CreateToken()
{
if (m_rng == null) m_rng = new RNGCryptoServiceProvider();
char[] token = new char[32];
byte[] data = new byte[4];
for (int x = 0; x < token.Length; x++)
{
// tokens have 62 possible chars: 0-9, A-Z, and a-z
m_rng.GetBytes(data);
uint rand = BitConverter.ToUInt32(data, 0) % 62u;
if (rand < 10)
token[x] = (char)('0' + rand);
else if (rand < 36)
token[x] = (char)('7' + rand); // 'A' + rand - 10
else
token[x] = (char)('=' + rand); // 'a' + rand - 36
}
return new String(token);
}
public static String CreateHash(String token, String salt)
{
if (m_sha1 == null) m_sha1 = SHA1.Create();
String longToken = salt + token;
byte[] data = new byte[longToken.Length];
MemoryStream stream = new MemoryStream(data);
StreamWriter writer = new StreamWriter(stream);
writer.Write(longToken); // fixme: this throws an OutOfBoundsException if the passed token contains non-ascii.
writer.Flush();
return m_sha1.ComputeHash(data).ToHexStringLower();
}
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web;
namespace GamestatsBase
{
public class GamestatsSessionManager
{
public readonly Dictionary<String, GamestatsSession> Sessions
= new Dictionary<String, GamestatsSession>();
public GamestatsSessionManager()
{
}
private void PruneSessions()
{
Dictionary<String, GamestatsSession> sessions = Sessions;
DateTime now = DateTime.UtcNow;
lock (sessions)
{
Queue<String> toRemove = new Queue<String>();
foreach (KeyValuePair<String, GamestatsSession> session in sessions)
{
if (session.Value.ExpiryDate < now) toRemove.Enqueue(session.Key);
}
while (toRemove.Count > 0)
{
sessions.Remove(toRemove.Dequeue());
}
}
}
public void Add(GamestatsSession session)
{
Sessions.Add(session.Hash, session);
}
public void Remove(GamestatsSession session)
{
Sessions.Remove(session.Hash);
}
/// <summary>
/// Retrieves the GamestatsSessionManager from the context's application state.
/// </summary>
public static GamestatsSessionManager FromContext(HttpContext context)
{
object oManager = context.Application["GamestatsSessionManager"];
GamestatsSessionManager manager = oManager as GamestatsSessionManager;
if (manager == null)
{
manager = new GamestatsSessionManager();
context.Application.Add("GamestatsSessionManager", manager);
}
return manager;
}
public GamestatsSession FindSession(int pid, String url)
{
// todo: keep a hash table of pids that maps them onto session objects
GamestatsSession result = null;
foreach (GamestatsSession sess in Sessions.Values)
{
if (sess.PID == pid && sess.URL == url)
{
if (result != null)
{
// todo: there's more than one matching session... delete them all.
}
return sess; // temp until I get it to cleanup old sessions
result = sess;
}
}
return result;
}
}
}