diff --git a/OpenRA.Game/CryptoUtil.cs b/OpenRA.Game/CryptoUtil.cs index fb5304c12e..8a3b973f4f 100644 --- a/OpenRA.Game/CryptoUtil.cs +++ b/OpenRA.Game/CryptoUtil.cs @@ -22,6 +22,12 @@ namespace OpenRA // Fixed byte pattern for the OID header static readonly byte[] OIDHeader = { 0x30, 0xD, 0x6, 0x9, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0xD, 0x1, 0x1, 0x1, 0x5, 0x0 }; + public static string PublicKeyFingerprint(RSAParameters parameters) + { + // Public key fingerprint is defined as the SHA1 of the modulus + exponent bytes + return SHA1Hash(parameters.Modulus.Append(parameters.Exponent).ToArray()); + } + public static string EncodePEMPublicKey(RSAParameters parameters) { var data = Convert.ToBase64String(EncodePublicKey(parameters)); diff --git a/OpenRA.Game/Game.cs b/OpenRA.Game/Game.cs index b80e37c86e..f0beefa47d 100644 --- a/OpenRA.Game/Game.cs +++ b/OpenRA.Game/Game.cs @@ -55,6 +55,7 @@ namespace OpenRA public static bool BenchmarkMode = false; public static string EngineVersion { get; private set; } + public static LocalPlayerProfile LocalPlayerProfile; static Task discoverNat; static bool takeScreenshot = false; @@ -407,6 +408,8 @@ namespace OpenRA ModData = new ModData(Mods[mod], Mods, true); + LocalPlayerProfile = new LocalPlayerProfile(Platform.ResolvePath(Path.Combine("^", Settings.Game.AuthProfile)), ModData.Manifest.Get()); + if (!ModData.LoadScreen.BeforeLoad()) return; diff --git a/OpenRA.Game/LocalPlayerProfile.cs b/OpenRA.Game/LocalPlayerProfile.cs new file mode 100644 index 0000000000..614bd35d49 --- /dev/null +++ b/OpenRA.Game/LocalPlayerProfile.cs @@ -0,0 +1,185 @@ +#region Copyright & License Information +/* + * Copyright 2007-2018 The OpenRA Developers (see AUTHORS) + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using OpenRA.Network; + +namespace OpenRA +{ + public sealed class LocalPlayerProfile + { + const int AuthKeySize = 2048; + public enum LinkState { Uninitialized, GeneratingKeys, Unlinked, CheckingLink, ConnectionFailed, Linked } + + public LinkState State { get { return innerState; } } + public string Fingerprint { get { return innerFingerprint; } } + public string PublicKey { get { return innerPublicKey; } } + + public PlayerProfile ProfileData { get { return innerData; } } + + volatile LinkState innerState; + volatile PlayerProfile innerData; + volatile string innerFingerprint; + volatile string innerPublicKey; + + RSAParameters parameters; + readonly string filePath; + readonly PlayerDatabase playerDatabase; + + public LocalPlayerProfile(string filePath, PlayerDatabase playerDatabase) + { + this.filePath = filePath; + this.playerDatabase = playerDatabase; + innerState = LinkState.Uninitialized; + + try + { + if (File.Exists(filePath)) + { + using (var rsa = new RSACryptoServiceProvider()) + { + using (var data = File.OpenRead(filePath)) + { + var keyData = Convert.FromBase64String(data.ReadAllText()); + rsa.FromXmlString(new string(Encoding.ASCII.GetChars(keyData))); + } + + parameters = rsa.ExportParameters(true); + innerPublicKey = CryptoUtil.EncodePEMPublicKey(parameters); + innerFingerprint = CryptoUtil.PublicKeyFingerprint(parameters); + innerState = LinkState.Unlinked; + } + } + } + catch (Exception e) + { + Console.WriteLine("Failed to load keys: {0}", e); + Log.Write("debug", "Failed to load player keypair from `{0}` with exception: {1}", filePath, e); + } + } + + public void RefreshPlayerData(Action onComplete = null) + { + if (State != LinkState.Unlinked && State != LinkState.Linked && State != LinkState.ConnectionFailed) + return; + + Action onQueryComplete = i => + { + try + { + innerState = LinkState.Unlinked; + + if (i.Error != null) + { + innerState = LinkState.ConnectionFailed; + return; + } + + var yaml = MiniYaml.FromString(Encoding.UTF8.GetString(i.Result)).First(); + if (yaml.Key == "Player") + { + innerData = FieldLoader.Load(yaml.Value); + if (innerData.KeyRevoked) + { + Log.Write("debug", "Revoking key with fingerprint {0}", Fingerprint); + DeleteKeypair(); + } + else + innerState = LinkState.Linked; + } + } + catch (Exception e) + { + Log.Write("debug", "Failed to parse player data result with exception: {0}", e); + innerState = LinkState.ConnectionFailed; + } + finally + { + if (onComplete != null) + onComplete(); + } + }; + + innerState = LinkState.CheckingLink; + new Download(playerDatabase.Profile + Fingerprint, _ => { }, onQueryComplete); + } + + public void GenerateKeypair() + { + if (State != LinkState.Uninitialized) + return; + + innerState = LinkState.GeneratingKeys; + new Task(() => + { + try + { + var rsa = new RSACryptoServiceProvider(AuthKeySize); + parameters = rsa.ExportParameters(true); + innerPublicKey = CryptoUtil.EncodePEMPublicKey(parameters); + innerFingerprint = CryptoUtil.PublicKeyFingerprint(parameters); + + var data = Convert.ToBase64String(Encoding.ASCII.GetBytes(rsa.ToXmlString(true))); + File.WriteAllText(filePath, data); + + innerState = LinkState.Unlinked; + } + catch (Exception e) + { + Log.Write("debug", "Failed to generate keypair with exception: {1}", e); + Console.WriteLine("Key generation failed: {0}", e); + + innerState = LinkState.Uninitialized; + } + }).Start(); + } + + public void DeleteKeypair() + { + try + { + File.Delete(filePath); + } + catch (Exception e) + { + Log.Write("debug", "Failed to delete keypair with exception: {1}", e); + Console.WriteLine("Key deletion failed: {0}", e); + } + + innerState = LinkState.Uninitialized; + parameters = new RSAParameters(); + innerFingerprint = null; + innerData = null; + } + + public string Sign(params string[] data) + { + if (State != LinkState.Linked) + return null; + + return CryptoUtil.Sign(parameters, data.Where(x => !string.IsNullOrEmpty(x)).JoinWith(string.Empty)); + } + + public string DecryptString(string data) + { + if (State != LinkState.Linked) + return null; + + return CryptoUtil.DecryptString(parameters, data); + } + } +} \ No newline at end of file diff --git a/OpenRA.Game/Network/Handshake.cs b/OpenRA.Game/Network/Handshake.cs index f612cb7667..d7345f7dd7 100644 --- a/OpenRA.Game/Network/Handshake.cs +++ b/OpenRA.Game/Network/Handshake.cs @@ -19,6 +19,7 @@ namespace OpenRA.Network public string Mod; public string Version; public string Map; + public string AuthToken; public static HandshakeRequest Deserialize(string data) { @@ -40,6 +41,11 @@ namespace OpenRA.Network public string Mod; public string Version; public string Password; + + // For player authentication + public string Fingerprint; + public string AuthSignature; + [FieldLoader.Ignore] public Session.Client Client; public static HandshakeResponse Deserialize(string data) @@ -68,7 +74,7 @@ namespace OpenRA.Network { var data = new List(); data.Add(new MiniYamlNode("Handshake", null, - new string[] { "Mod", "Version", "Password" }.Select(p => FieldSaver.SaveField(this, p)).ToList())); + new string[] { "Mod", "Version", "Password", "Fingerprint", "AuthSignature" }.Select(p => FieldSaver.SaveField(this, p)).ToList())); data.Add(new MiniYamlNode("Client", FieldSaver.Save(Client))); return data.WriteToString(); diff --git a/OpenRA.Game/Network/Session.cs b/OpenRA.Game/Network/Session.cs index de9ea4ccb3..9f08efb1d0 100644 --- a/OpenRA.Game/Network/Session.cs +++ b/OpenRA.Game/Network/Session.cs @@ -126,6 +126,9 @@ namespace OpenRA.Network public bool IsInvalid { get { return State == ClientState.Invalid; } } public bool IsObserver { get { return Slot == null; } } + // Linked to the online player database + public string Fingerprint; + public MiniYamlNode Serialize() { return new MiniYamlNode("Client@{0}".F(Index), FieldSaver.Save(this)); diff --git a/OpenRA.Game/Network/UnitOrders.cs b/OpenRA.Game/Network/UnitOrders.cs index 31cacf920f..855988a20b 100644 --- a/OpenRA.Game/Network/UnitOrders.cs +++ b/OpenRA.Game/Network/UnitOrders.cs @@ -9,9 +9,12 @@ */ #endregion +using System; using System.Collections.Generic; using System.Drawing; using System.Linq; +using System.Security.Cryptography; +using System.Text; using OpenRA.Traits; namespace OpenRA.Network @@ -175,14 +178,19 @@ namespace OpenRA.Network State = Session.ClientState.Invalid }; + var localProfile = Game.LocalPlayerProfile; var response = new HandshakeResponse() { Client = info, Mod = mod.Id, Version = mod.Metadata.Version, - Password = orderManager.Password + Password = orderManager.Password, + Fingerprint = localProfile.Fingerprint }; + if (request.AuthToken != null && response.Fingerprint != null) + response.AuthSignature = localProfile.Sign(request.AuthToken); + orderManager.IssueOrder(Order.HandshakeResponse(response.Serialize())); break; } diff --git a/OpenRA.Game/OpenRA.Game.csproj b/OpenRA.Game/OpenRA.Game.csproj index 420bf790f6..4ee3719e29 100644 --- a/OpenRA.Game/OpenRA.Game.csproj +++ b/OpenRA.Game/OpenRA.Game.csproj @@ -261,6 +261,9 @@ + + + diff --git a/OpenRA.Game/PlayerDatabase.cs b/OpenRA.Game/PlayerDatabase.cs new file mode 100644 index 0000000000..fdfff950b7 --- /dev/null +++ b/OpenRA.Game/PlayerDatabase.cs @@ -0,0 +1,18 @@ +#region Copyright & License Information +/* + * Copyright 2007-2018 The OpenRA Developers (see AUTHORS) + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +namespace OpenRA +{ + public class PlayerDatabase : IGlobalModData + { + public readonly string Profile = "https://forum.openra.net/openra/info/"; + } +} diff --git a/OpenRA.Game/PlayerProfile.cs b/OpenRA.Game/PlayerProfile.cs new file mode 100644 index 0000000000..33ac290adf --- /dev/null +++ b/OpenRA.Game/PlayerProfile.cs @@ -0,0 +1,24 @@ +#region Copyright & License Information +/* + * Copyright 2007-2018 The OpenRA Developers (see AUTHORS) + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +namespace OpenRA +{ + public class PlayerProfile + { + public readonly string Fingerprint; + public readonly string PublicKey; + public readonly bool KeyRevoked; + + public readonly int ProfileID; + public readonly string ProfileName; + public readonly string ProfileRank = "Registered Player"; + } +} diff --git a/OpenRA.Game/Server/Connection.cs b/OpenRA.Game/Server/Connection.cs index ba60fecaa2..b341c6f2b4 100644 --- a/OpenRA.Game/Server/Connection.cs +++ b/OpenRA.Game/Server/Connection.cs @@ -33,6 +33,7 @@ namespace OpenRA.Server /* client data */ public int PlayerIndex; + public string AuthToken; public byte[] PopBytes(int n) { diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 6f97722196..058b4b50fd 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -16,6 +16,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Text; using System.Threading; using OpenRA.Graphics; using OpenRA.Network; @@ -57,9 +58,13 @@ namespace OpenRA.Server readonly int randomSeed; readonly TcpListener listener; readonly TypeDictionary serverTraits = new TypeDictionary(); + readonly PlayerDatabase playerDatabase; protected volatile ServerState internalState = ServerState.WaitingPlayers; + volatile ActionQueue delayedActions = new ActionQueue(); + int waitingForAuthenticationCallback = 0; + public ServerState State { get { return internalState; } @@ -132,6 +137,8 @@ namespace OpenRA.Server ModData = modData; + playerDatabase = modData.Manifest.Get(); + randomSeed = (int)DateTime.Now.ToBinary(); if (UPnP.Status == UPnPStatus.Enabled) @@ -173,8 +180,9 @@ namespace OpenRA.Server checkRead.AddRange(Conns.Select(c => c.Socket)); checkRead.AddRange(PreConns.Select(c => c.Socket)); + var localTimeout = waitingForAuthenticationCallback > 0 ? 100000 : timeout; if (checkRead.Count > 0) - Socket.Select(checkRead, null, null, timeout); + Socket.Select(checkRead, null, null, localTimeout); if (State == ServerState.ShuttingDown) { @@ -202,6 +210,8 @@ namespace OpenRA.Server conn.ReadData(this); } + delayedActions.PerformActions(0); + foreach (var t in serverTraits.WithInterface()) t.Tick(this); @@ -255,8 +265,13 @@ namespace OpenRA.Server newConn.Socket.Blocking = false; newConn.Socket.NoDelay = true; - // assign the player number. + // Validate player identity by asking them to sign a random blob of data + // which we can then verify against the player public key database + var token = Convert.ToBase64String(OpenRA.Exts.MakeArray(256, _ => (byte)Random.Next())); + + // Assign the player number. newConn.PlayerIndex = ChooseFreePlayerIndex(); + newConn.AuthToken = token; SendData(newConn.Socket, BitConverter.GetBytes(ProtocolVersion.Version)); SendData(newConn.Socket, BitConverter.GetBytes(newConn.PlayerIndex)); PreConns.Add(newConn); @@ -266,7 +281,8 @@ namespace OpenRA.Server { Mod = ModData.Manifest.Id, Version = ModData.Manifest.Metadata.Version, - Map = LobbyInfo.GlobalSettings.Map + Map = LobbyInfo.GlobalSettings.Map, + AuthToken = token }; DispatchOrdersToClient(newConn, 0, 0, new ServerOrder("HandshakeRequest", request.Serialize()).Serialize()); @@ -359,50 +375,133 @@ namespace OpenRA.Server return; } - // Promote connection to a valid client - PreConns.Remove(newConn); - Conns.Add(newConn); - LobbyInfo.Clients.Add(client); - newConn.Validated = true; - - var clientPing = new Session.ClientPing { Index = client.Index }; - LobbyInfo.ClientPings.Add(clientPing); - - Log.Write("server", "Client {0}: Accepted connection from {1}.", - newConn.PlayerIndex, newConn.Socket.RemoteEndPoint); - - foreach (var t in serverTraits.WithInterface()) - t.ClientJoined(this, newConn); - - SyncLobbyInfo(); - - Log.Write("server", "{0} ({1}) has joined the game.", - client.Name, newConn.Socket.RemoteEndPoint); - - // Report to all other players - SendMessage("{0} has joined the game.".F(client.Name), newConn); - - // Send initial ping - SendOrderTo(newConn, "Ping", Game.RunTime.ToString(CultureInfo.InvariantCulture)); - - if (Dedicated) + Action completeConnection = () => { - var motdFile = Platform.ResolvePath(Platform.SupportDirPrefix, "motd.txt"); - if (!File.Exists(motdFile)) - File.WriteAllText(motdFile, "Welcome, have fun and good luck!"); + // Promote connection to a valid client + PreConns.Remove(newConn); + Conns.Add(newConn); + LobbyInfo.Clients.Add(client); + newConn.Validated = true; - var motd = File.ReadAllText(motdFile); - if (!string.IsNullOrEmpty(motd)) - SendOrderTo(newConn, "Message", motd); + var clientPing = new Session.ClientPing { Index = client.Index }; + LobbyInfo.ClientPings.Add(clientPing); + + Log.Write("server", "Client {0}: Accepted connection from {1}.", + newConn.PlayerIndex, newConn.Socket.RemoteEndPoint); + + if (client.Fingerprint != null) + Log.Write("server", "Client {0}: Player fingerprint is {1}.", + newConn.PlayerIndex, client.Fingerprint); + + foreach (var t in serverTraits.WithInterface()) + t.ClientJoined(this, newConn); + + SyncLobbyInfo(); + + Log.Write("server", "{0} ({1}) has joined the game.", + client.Name, newConn.Socket.RemoteEndPoint); + + // Report to all other players + SendMessage("{0} has joined the game.".F(client.Name), newConn); + + // Send initial ping + SendOrderTo(newConn, "Ping", Game.RunTime.ToString(CultureInfo.InvariantCulture)); + + if (Dedicated) + { + var motdFile = Platform.ResolvePath(Platform.SupportDirPrefix, "motd.txt"); + if (!File.Exists(motdFile)) + File.WriteAllText(motdFile, "Welcome, have fun and good luck!"); + + var motd = File.ReadAllText(motdFile); + if (!string.IsNullOrEmpty(motd)) + SendOrderTo(newConn, "Message", motd); + } + + if (Map.DefinesUnsafeCustomRules) + SendOrderTo(newConn, "Message", "This map contains custom rules. Game experience may change."); + + if (!LobbyInfo.GlobalSettings.EnableSingleplayer) + SendOrderTo(newConn, "Message", TwoHumansRequiredText); + else if (Map.Players.Players.Where(p => p.Value.Playable).All(p => !p.Value.AllowBots)) + SendOrderTo(newConn, "Message", "Bots have been disabled on this map."); + }; + + if (!string.IsNullOrEmpty(handshake.Fingerprint) && !string.IsNullOrEmpty(handshake.AuthSignature)) + { + waitingForAuthenticationCallback++; + + Action onQueryComplete = i => + { + PlayerProfile profile = null; + + if (i.Error == null) + { + try + { + var yaml = MiniYaml.FromString(Encoding.UTF8.GetString(i.Result)).First(); + if (yaml.Key == "Player") + { + profile = FieldLoader.Load(yaml.Value); + + var publicKey = Encoding.ASCII.GetString(Convert.FromBase64String(profile.PublicKey)); + var parameters = CryptoUtil.DecodePEMPublicKey(publicKey); + if (!profile.KeyRevoked && CryptoUtil.VerifySignature(parameters, newConn.AuthToken, handshake.AuthSignature)) + { + client.Fingerprint = handshake.Fingerprint; + Log.Write("server", "{0} authenticated as {1} (UID {2})", newConn.Socket.RemoteEndPoint, + profile.ProfileName, profile.ProfileID); + } + else if (profile.KeyRevoked) + Log.Write("server", "{0} failed to authenticate as {1} (key revoked)", newConn.Socket.RemoteEndPoint, handshake.Fingerprint); + else + Log.Write("server", "{0} failed to authenticate as {1} (signature verification failed)", + newConn.Socket.RemoteEndPoint, handshake.Fingerprint); + } + else + Log.Write("server", "{0} failed to authenticate as {1} (invalid server response: `{2}` is not `Player`)", + newConn.Socket.RemoteEndPoint, handshake.Fingerprint, yaml.Key); + } + catch (Exception ex) + { + Log.Write("server", "{0} failed to authenticate as {1} (exception occurred)", + newConn.Socket.RemoteEndPoint, handshake.Fingerprint); + Log.Write("server", ex.ToString()); + } + } + else + Log.Write("server", "{0} failed to authenticate as {1} (server error: `{2}`)", + newConn.Socket.RemoteEndPoint, handshake.Fingerprint, i.Error); + + delayedActions.Add(() => + { + if (Dedicated && Settings.RequireAuthIDs.Any() && + (profile == null || !Settings.RequireAuthIDs.Contains(profile.ProfileID))) + { + Log.Write("server", "Rejected connection from {0}; Not in server whitelist.", newConn.Socket.RemoteEndPoint); + SendOrderTo(newConn, "ServerError", "You are not authenticated for this server"); + DropClient(newConn); + } + else + completeConnection(); + + waitingForAuthenticationCallback--; + }, 0); + }; + + new Download(playerDatabase.Profile + handshake.Fingerprint, _ => { }, onQueryComplete); + } + else + { + if (Dedicated && Settings.RequireAuthIDs.Any()) + { + Log.Write("server", "Rejected connection from {0}; Not authenticated and whitelist is set.", newConn.Socket.RemoteEndPoint); + SendOrderTo(newConn, "ServerError", "You are not authenticated for this server"); + DropClient(newConn); + } + else + completeConnection(); } - - if (Map.DefinesUnsafeCustomRules) - SendOrderTo(newConn, "Message", "This map contains custom rules. Game experience may change."); - - if (!LobbyInfo.GlobalSettings.EnableSingleplayer) - SendOrderTo(newConn, "Message", TwoHumansRequiredText); - else if (Map.Players.Players.Where(p => p.Value.Playable).All(p => !p.Value.AllowBots)) - SendOrderTo(newConn, "Message", "Bots have been disabled on this map."); } catch (Exception ex) { diff --git a/OpenRA.Game/Settings.cs b/OpenRA.Game/Settings.cs index aa313b2ca5..58587b76b8 100644 --- a/OpenRA.Game/Settings.cs +++ b/OpenRA.Game/Settings.cs @@ -59,6 +59,9 @@ namespace OpenRA [Desc("Takes a comma separated list of IP addresses that are not allowed to join.")] public string[] Ban = { }; + [Desc("If non-empty, only allow authenticated players with these user IDs to join.")] + public int[] RequireAuthIDs = { }; + [Desc("For dedicated servers only, controls whether a game can be started with just one human player in the lobby.")] public bool EnableSingleplayer = false; @@ -183,6 +186,9 @@ namespace OpenRA public bool AllowDownloading = true; + [Desc("Filename of the authentication profile to use.")] + public string AuthProfile = "player.oraid"; + public bool AllowZoom = true; public Modifiers ZoomModifier = Modifiers.Ctrl;