From 9ee8aabbb73db678bd3bed8bff1accef2d423107 Mon Sep 17 00:00:00 2001 From: Greg Edwards Date: Wed, 10 Sep 2014 16:10:48 -0400 Subject: [PATCH] Migrated and generalized fGTS Gamestats logic to external library. --- GamestatsBase/Class1.cs | 12 - GamestatsBase/Common.cs | 75 +++++ GamestatsBase/GamestatsBase.csproj | 9 +- GamestatsBase/GamestatsHandler.cs | 361 +++++++++++++++++++++++ GamestatsBase/GamestatsSession.cs | 151 ++++++++++ GamestatsBase/GamestatsSessionManager.cs | 86 ++++++ 6 files changed, 680 insertions(+), 14 deletions(-) delete mode 100644 GamestatsBase/Class1.cs create mode 100644 GamestatsBase/Common.cs create mode 100644 GamestatsBase/GamestatsHandler.cs create mode 100644 GamestatsBase/GamestatsSession.cs create mode 100644 GamestatsBase/GamestatsSessionManager.cs diff --git a/GamestatsBase/Class1.cs b/GamestatsBase/Class1.cs deleted file mode 100644 index 28753ef..0000000 --- a/GamestatsBase/Class1.cs +++ /dev/null @@ -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 - { - } -} diff --git a/GamestatsBase/Common.cs b/GamestatsBase/Common.cs new file mode 100644 index 0000000..02ed6ed --- /dev/null +++ b/GamestatsBase/Common.cs @@ -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 result = new List(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(); + } + } +} diff --git a/GamestatsBase/GamestatsBase.csproj b/GamestatsBase/GamestatsBase.csproj index 172a162..a4e3a13 100644 --- a/GamestatsBase/GamestatsBase.csproj +++ b/GamestatsBase/GamestatsBase.csproj @@ -9,8 +9,9 @@ Properties GamestatsBase GamestatsBase - v4.5 + v4.0 512 + true @@ -32,6 +33,7 @@ + @@ -39,7 +41,10 @@ - + + + + diff --git a/GamestatsBase/GamestatsHandler.cs b/GamestatsBase/GamestatsHandler.cs new file mode 100644 index 0000000..fb84400 --- /dev/null +++ b/GamestatsBase/GamestatsHandler.cs @@ -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) + { + + } + + /// + /// Session factory if you need it. You probably don't. + /// + public virtual GamestatsSession CreateSession(int pid, String url) + { + return new GamestatsSession(GameId, Salt, pid, url); + } + + /// + /// 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. + /// + 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"; + } + } + } +} diff --git a/GamestatsBase/GamestatsSession.cs b/GamestatsBase/GamestatsSession.cs new file mode 100644 index 0000000..2614c60 --- /dev/null +++ b/GamestatsBase/GamestatsSession.cs @@ -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; + } + + /// + /// A PID is a user ID which is associated with a game cartridge. + /// + public int PID + { + get + { + return m_pid; + } + protected set + { + m_pid = value; + } + } + + /// + /// The URL in which this session began + /// + public String URL + { + get + { + return m_url; + } + protected set + { + m_url = value; + } + } + + /// + /// 32 chars of random data which both functions as a challenge + /// and as a session ID + /// + 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(); + } + } +} diff --git a/GamestatsBase/GamestatsSessionManager.cs b/GamestatsBase/GamestatsSessionManager.cs new file mode 100644 index 0000000..f1c9874 --- /dev/null +++ b/GamestatsBase/GamestatsSessionManager.cs @@ -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 Sessions + = new Dictionary(); + + public GamestatsSessionManager() + { + } + + private void PruneSessions() + { + Dictionary sessions = Sessions; + DateTime now = DateTime.UtcNow; + + lock (sessions) + { + Queue toRemove = new Queue(); + foreach (KeyValuePair 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); + } + + /// + /// Retrieves the GamestatsSessionManager from the context's application state. + /// + 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; + } + + + } +}