Adds PBKDF2 protection for account passwords. (#122)
This commit is contained in:
parent
1ee6830637
commit
29467a3ed9
9 changed files with 209 additions and 172 deletions
|
|
@ -0,0 +1,30 @@
|
|||
using Xunit;
|
||||
using Server.Accounting;
|
||||
|
||||
namespace Server.Tests.Accounting
|
||||
{
|
||||
public class PBKDF2PasswordProtectionTest
|
||||
{
|
||||
private const string plainPassword = "hello-good-sir";
|
||||
|
||||
[Fact]
|
||||
public void TestValidates()
|
||||
{
|
||||
var passwordProtection = new PBKDF2PasswordProtection();
|
||||
|
||||
string encryptedPassword = passwordProtection.EncryptPassword(plainPassword);
|
||||
|
||||
Assert.True(passwordProtection.ValidatePassword(encryptedPassword, plainPassword));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestPasswordDoesNotValidate()
|
||||
{
|
||||
var passwordProtection = new PBKDF2PasswordProtection();
|
||||
|
||||
string encryptedPassword = passwordProtection.EncryptPassword(plainPassword);
|
||||
|
||||
Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password"));
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Projects/Scripts.Tests/Misc/HexStringConverterTest.cs
Normal file
20
Projects/Scripts.Tests/Misc/HexStringConverterTest.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using System;
|
||||
using Xunit;
|
||||
using Server.Misc;
|
||||
|
||||
namespace Server.Tests.Accounting
|
||||
{
|
||||
public class HexStringConverterTest
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ABCDEF1234", new byte[]{ 0xAB, 0xCD, 0xEF, 0x12, 0x34 })]
|
||||
public void ConvertsProperly(string input, byte[] bytes)
|
||||
{
|
||||
Span<byte> outputBytes = stackalloc byte[input.Length / 2];
|
||||
HexStringConverter.GetBytes(input, outputBytes);
|
||||
|
||||
Assert.Equal(bytes, outputBytes.ToArray());
|
||||
Assert.Equal(input, HexStringConverter.GetString(bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,4 +9,8 @@
|
|||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="1.2.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Server\Server.csproj" />
|
||||
<ProjectReference Include="..\Scripts\Scripts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using Server.Misc;
|
||||
using Server.Mobiles;
|
||||
|
|
@ -14,9 +12,7 @@ namespace Server.Accounting
|
|||
public class Account : IAccount, IComparable<Account>
|
||||
{
|
||||
public static readonly TimeSpan YoungDuration = TimeSpan.FromHours(40.0);
|
||||
|
||||
public static readonly TimeSpan InactiveDuration = TimeSpan.FromDays(180.0);
|
||||
|
||||
public static readonly TimeSpan EmptyInactiveDuration = TimeSpan.FromDays(30.0);
|
||||
|
||||
private AccessLevel m_AccessLevel;
|
||||
|
|
@ -24,6 +20,7 @@ namespace Server.Accounting
|
|||
private List<AccountComment> m_Comments;
|
||||
private List<AccountTag> m_Tags;
|
||||
private readonly Mobile[] m_Mobiles;
|
||||
private PasswordProtectionAlgorithm m_PasswordAlgorithm;
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the account, all characters of the account, and all houses of those characters
|
||||
|
|
@ -90,19 +87,9 @@ namespace Server.Accounting
|
|||
public string Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account password. Plain text. Case sensitive validation. May be null.
|
||||
/// Account username and password. May be null.
|
||||
/// </summary>
|
||||
public string PlainPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account password. Hashed with MD5. May be null.
|
||||
/// </summary>
|
||||
public string CryptPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account username and password hashed with SHA1. May be null.
|
||||
/// </summary>
|
||||
public string NewCryptPassword { get; set; }
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initial AccessLevel for new characters created on this account.
|
||||
|
|
@ -328,94 +315,23 @@ namespace Server.Accounting
|
|||
return banTime != DateTime.MinValue && banDuration != TimeSpan.Zero;
|
||||
}
|
||||
|
||||
private static MD5CryptoServiceProvider m_MD5HashProvider;
|
||||
private static SHA1CryptoServiceProvider m_SHA1HashProvider;
|
||||
private static byte[] m_HashBuffer;
|
||||
|
||||
public static string HashMD5(string phrase)
|
||||
{
|
||||
if (m_MD5HashProvider == null)
|
||||
m_MD5HashProvider = new MD5CryptoServiceProvider();
|
||||
|
||||
if (m_HashBuffer == null)
|
||||
m_HashBuffer = new byte[256];
|
||||
|
||||
int length = Encoding.ASCII.GetBytes(phrase, 0, phrase.Length > 256 ? 256 : phrase.Length, m_HashBuffer, 0);
|
||||
byte[] hashed = m_MD5HashProvider.ComputeHash(m_HashBuffer, 0, length);
|
||||
|
||||
return BitConverter.ToString(hashed);
|
||||
}
|
||||
|
||||
public static string HashSHA1(string phrase)
|
||||
{
|
||||
if (m_SHA1HashProvider == null)
|
||||
m_SHA1HashProvider = new SHA1CryptoServiceProvider();
|
||||
|
||||
if (m_HashBuffer == null)
|
||||
m_HashBuffer = new byte[256];
|
||||
|
||||
int length = Encoding.ASCII.GetBytes(phrase, 0, phrase.Length > 256 ? 256 : phrase.Length, m_HashBuffer, 0);
|
||||
byte[] hashed = m_SHA1HashProvider.ComputeHash(m_HashBuffer, 0, length);
|
||||
|
||||
return BitConverter.ToString(hashed);
|
||||
}
|
||||
|
||||
public void SetPassword(string plainPassword)
|
||||
{
|
||||
switch (AccountHandler.ProtectPasswords)
|
||||
{
|
||||
case PasswordProtection.None:
|
||||
{
|
||||
PlainPassword = plainPassword;
|
||||
CryptPassword = null;
|
||||
NewCryptPassword = null;
|
||||
|
||||
break;
|
||||
}
|
||||
case PasswordProtection.Crypt:
|
||||
{
|
||||
PlainPassword = null;
|
||||
CryptPassword = HashMD5(plainPassword);
|
||||
NewCryptPassword = null;
|
||||
|
||||
break;
|
||||
}
|
||||
default: // PasswordProtection.NewCrypt
|
||||
{
|
||||
PlainPassword = null;
|
||||
CryptPassword = null;
|
||||
NewCryptPassword = HashSHA1(Username + plainPassword);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(plainPassword);
|
||||
m_PasswordAlgorithm = AccountSecurity.AlgorithmName;
|
||||
}
|
||||
|
||||
public bool CheckPassword(string plainPassword)
|
||||
{
|
||||
bool ok;
|
||||
PasswordProtection curProt;
|
||||
bool ok = AccountSecurity.GetPasswordProtection(m_PasswordAlgorithm).ValidatePassword(Password, plainPassword);
|
||||
if (!ok)
|
||||
return false;
|
||||
|
||||
if (PlainPassword != null)
|
||||
{
|
||||
ok = PlainPassword == plainPassword;
|
||||
curProt = PasswordProtection.None;
|
||||
}
|
||||
else if (CryptPassword != null)
|
||||
{
|
||||
ok = CryptPassword == HashMD5(plainPassword);
|
||||
curProt = PasswordProtection.Crypt;
|
||||
}
|
||||
else
|
||||
{
|
||||
ok = NewCryptPassword == HashSHA1(Username + plainPassword);
|
||||
curProt = PasswordProtection.NewCrypt;
|
||||
}
|
||||
|
||||
if (ok && curProt != AccountHandler.ProtectPasswords)
|
||||
// Upgrade the password protection in case we change the algorithm
|
||||
if (m_PasswordAlgorithm != AccountSecurity.AlgorithmName)
|
||||
SetPassword(plainPassword);
|
||||
|
||||
return ok;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Timer m_YoungTimer;
|
||||
|
|
@ -539,53 +455,10 @@ namespace Server.Accounting
|
|||
{
|
||||
Username = Utility.GetText(node["username"], "empty");
|
||||
|
||||
string plainPassword = Utility.GetText(node["password"], null);
|
||||
string cryptPassword = Utility.GetText(node["cryptPassword"], null);
|
||||
string newCryptPassword = Utility.GetText(node["newCryptPassword"], null);
|
||||
|
||||
switch (AccountHandler.ProtectPasswords)
|
||||
{
|
||||
case PasswordProtection.None:
|
||||
{
|
||||
if (plainPassword != null)
|
||||
SetPassword(plainPassword);
|
||||
else if (newCryptPassword != null)
|
||||
NewCryptPassword = newCryptPassword;
|
||||
else if (cryptPassword != null)
|
||||
CryptPassword = cryptPassword;
|
||||
else
|
||||
SetPassword("empty");
|
||||
|
||||
break;
|
||||
}
|
||||
case PasswordProtection.Crypt:
|
||||
{
|
||||
if (cryptPassword != null)
|
||||
CryptPassword = cryptPassword;
|
||||
else if (plainPassword != null)
|
||||
SetPassword(plainPassword);
|
||||
else if (newCryptPassword != null)
|
||||
NewCryptPassword = newCryptPassword;
|
||||
else
|
||||
SetPassword("empty");
|
||||
|
||||
break;
|
||||
}
|
||||
default: // PasswordProtection.NewCrypt
|
||||
{
|
||||
if (newCryptPassword != null)
|
||||
NewCryptPassword = newCryptPassword;
|
||||
else if (plainPassword != null)
|
||||
SetPassword(plainPassword);
|
||||
else if (cryptPassword != null)
|
||||
CryptPassword = cryptPassword;
|
||||
else
|
||||
SetPassword("empty");
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: ModernUO doesn't support plain passwords, MD5, or SHA1.
|
||||
// TODO: Offload passwords to its own module so it can be easily written/upgraded
|
||||
Password = Utility.GetText(node["password"], null);
|
||||
Enum.TryParse(Utility.GetText(node["passwordAlgorithm"], null), true, out m_PasswordAlgorithm);
|
||||
Enum.TryParse(Utility.GetText(node["accessLevel"], "Player"), true, out m_AccessLevel);
|
||||
Flags = Utility.GetXMLInt32(Utility.GetText(node["flags"], "0"), 0);
|
||||
Created = Utility.GetXMLDateTime(Utility.GetText(node["created"], null), DateTime.UtcNow);
|
||||
|
|
@ -883,26 +756,13 @@ namespace Server.Accounting
|
|||
xml.WriteString(Username);
|
||||
xml.WriteEndElement();
|
||||
|
||||
if (PlainPassword != null)
|
||||
{
|
||||
xml.WriteStartElement("password");
|
||||
xml.WriteString(PlainPassword);
|
||||
xml.WriteEndElement();
|
||||
}
|
||||
xml.WriteStartElement("passwordAlgorithm");
|
||||
xml.WriteString(m_PasswordAlgorithm.ToString());
|
||||
xml.WriteEndElement();
|
||||
|
||||
if (CryptPassword != null)
|
||||
{
|
||||
xml.WriteStartElement("cryptPassword");
|
||||
xml.WriteString(CryptPassword);
|
||||
xml.WriteEndElement();
|
||||
}
|
||||
|
||||
if (NewCryptPassword != null)
|
||||
{
|
||||
xml.WriteStartElement("newCryptPassword");
|
||||
xml.WriteString(NewCryptPassword);
|
||||
xml.WriteEndElement();
|
||||
}
|
||||
xml.WriteStartElement("password");
|
||||
xml.WriteString(Password);
|
||||
xml.WriteEndElement();
|
||||
|
||||
if (m_AccessLevel != AccessLevel.Player)
|
||||
{
|
||||
|
|
@ -932,8 +792,6 @@ namespace Server.Accounting
|
|||
|
||||
xml.WriteStartElement("chars");
|
||||
|
||||
// xml.WriteAttributeString( "length", m_Mobiles.Length.ToString() ); //Legacy, Not used anymore
|
||||
|
||||
for (int i = 0; i < m_Mobiles.Length; ++i)
|
||||
{
|
||||
Mobile m = m_Mobiles[i];
|
||||
|
|
|
|||
|
|
@ -9,13 +9,6 @@ using Server.Regions;
|
|||
|
||||
namespace Server.Misc
|
||||
{
|
||||
public enum PasswordProtection
|
||||
{
|
||||
None,
|
||||
Crypt,
|
||||
NewCrypt
|
||||
}
|
||||
|
||||
public class AccountHandler
|
||||
{
|
||||
private static readonly int MaxAccountsPerIP = 1;
|
||||
|
|
@ -23,8 +16,6 @@ namespace Server.Misc
|
|||
private static readonly bool RestrictDeletion = !TestCenter.Enabled;
|
||||
private static readonly TimeSpan DeleteDelay = TimeSpan.FromDays(7.0);
|
||||
|
||||
public static PasswordProtection ProtectPasswords = PasswordProtection.NewCrypt;
|
||||
|
||||
private static readonly CityInfo[] StartingCities =
|
||||
{
|
||||
new CityInfo("New Haven", "New Haven Bank", 1150168, 3667, 2625, 0),
|
||||
|
|
|
|||
26
Projects/Scripts/Accounting/AccountSecurity.cs
Normal file
26
Projects/Scripts/Accounting/AccountSecurity.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
namespace Server.Accounting
|
||||
{
|
||||
public enum PasswordProtectionAlgorithm
|
||||
{
|
||||
PBKDF2
|
||||
}
|
||||
|
||||
public static class AccountSecurity
|
||||
{
|
||||
// TODO: Put it in a configuration
|
||||
public const PasswordProtectionAlgorithm AlgorithmName = PasswordProtectionAlgorithm.PBKDF2;
|
||||
|
||||
public static readonly IPasswordProtection CurrentPasswordProtection = GetPasswordProtection(AlgorithmName);
|
||||
|
||||
public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm)
|
||||
{
|
||||
var passwordProtection = algorithm switch
|
||||
{
|
||||
PasswordProtectionAlgorithm.PBKDF2 => PBKDF2PasswordProtection.Instance,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return passwordProtection;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Projects/Scripts/Accounting/IPasswordProtection.cs
Normal file
8
Projects/Scripts/Accounting/IPasswordProtection.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Server.Accounting
|
||||
{
|
||||
public interface IPasswordProtection
|
||||
{
|
||||
string EncryptPassword(string plainPassword);
|
||||
bool ValidatePassword(string encryptedPassword, string plainPassword);
|
||||
}
|
||||
}
|
||||
46
Projects/Scripts/Accounting/PBKDF2PasswordProtection.cs
Normal file
46
Projects/Scripts/Accounting/PBKDF2PasswordProtection.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using Server.Misc;
|
||||
|
||||
namespace Server.Accounting
|
||||
{
|
||||
public class PBKDF2PasswordProtection : IPasswordProtection
|
||||
{
|
||||
public static PBKDF2PasswordProtection Instance = new PBKDF2PasswordProtection();
|
||||
|
||||
private const ushort m_MinIterations = 1024;
|
||||
private const ushort m_MaxIterations = 1536;
|
||||
private static readonly HashAlgorithmName m_Algorithm = HashAlgorithmName.SHA256;
|
||||
private const int m_SaltSize = 8;
|
||||
private const int m_HashSize = 32;
|
||||
private const int m_OutputSize = 2 + m_SaltSize + m_HashSize;
|
||||
|
||||
public string EncryptPassword(string plainPassword)
|
||||
{
|
||||
Span<byte> output = stackalloc byte[m_OutputSize];
|
||||
int iterations = Utility.RandomMinMax(m_MinIterations, m_MaxIterations);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(output.Slice(0, 2), (ushort)iterations);
|
||||
|
||||
var rfc2898 = new Rfc2898DeriveBytes(plainPassword, m_SaltSize, iterations, m_Algorithm);
|
||||
rfc2898.Salt.CopyTo(output.Slice(2, m_SaltSize));
|
||||
rfc2898.GetBytes(m_HashSize).CopyTo(output.Slice(m_SaltSize + 2));
|
||||
|
||||
return HexStringConverter.GetString(output);
|
||||
}
|
||||
|
||||
public bool ValidatePassword(string encryptedPassword, string plainPassword)
|
||||
{
|
||||
Span<byte> encryptedBytes = stackalloc byte[m_OutputSize];
|
||||
HexStringConverter.GetBytes(encryptedPassword, encryptedBytes);
|
||||
|
||||
ushort iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes.Slice(0, 2));
|
||||
Span<byte> salt = encryptedBytes.Slice(2, m_SaltSize);
|
||||
|
||||
ReadOnlySpan<byte> hash =
|
||||
new Rfc2898DeriveBytes(plainPassword, salt.ToArray(), iterations, m_Algorithm).GetBytes(m_HashSize);
|
||||
|
||||
return hash.SequenceEqual(encryptedBytes.Slice(m_SaltSize + 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Projects/Scripts/Misc/HexStringConverter.cs
Normal file
54
Projects/Scripts/Misc/HexStringConverter.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System;
|
||||
|
||||
namespace Server.Misc
|
||||
{
|
||||
public class HexStringConverter
|
||||
{
|
||||
public static readonly uint[] m_Lookup32Chars = CreateLookup32Chars();
|
||||
|
||||
private static uint[] CreateLookup32Chars()
|
||||
{
|
||||
var result = new uint[256];
|
||||
for (int i = 0; i < 256; i++)
|
||||
{
|
||||
string s = i.ToString("X2");
|
||||
if (BitConverter.IsLittleEndian)
|
||||
result[i] = s[0] + ((uint)s[1] << 16);
|
||||
else
|
||||
result[i] = s[1] + ((uint)s[0] << 16);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static unsafe string GetString(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var result = new string((char)0, bytes.Length * 2);
|
||||
fixed (char* resultP = result)
|
||||
{
|
||||
uint* resultP2 = (uint*)resultP;
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
resultP2[i] = m_Lookup32Chars[bytes[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static unsafe void GetBytes(string str, Span<byte> bytes)
|
||||
{
|
||||
fixed (char* strP = str)
|
||||
{
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
while (i < str.Length)
|
||||
{
|
||||
int chr1 = strP[i++];
|
||||
int chr2 = strP[i++];
|
||||
if (BitConverter.IsLittleEndian)
|
||||
bytes[j++] = (byte)((chr1 - (chr1 >= 65 ? 55 : 48)) << 4 | (chr2 - (chr2 >= 65 ? 55 : 48)));
|
||||
else
|
||||
bytes[j++] = (byte)((chr1 - (chr1 >= 65 ? 55 : 48)) | ((chr2 - (chr2 >= 65 ? 55 : 48)) << 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue