using System; using System.Data; using System.Data.SqlClient; using Microsoft.AspNetCore.Cryptography.KeyDerivation; namespace SecureCore.Authentication { public static class PasswordManager { private static string Pepper { get; set; } public static string PasswordPepper { get { return Pepper; } set { //As noted here https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html a pepper should be at least //32 bytes in size. if (value.Length < 32) throw new Exception("A pepper must be at least 32 characters long for security reasons."); if (string.IsNullOrEmpty(Pepper)) Pepper = value; else throw new InvalidOperationException("Pepper can only be set at the startup of the application."); } } private static KeyDerivationPrf KeyType { get; } = KeyDerivationPrf.HMACSHA512; private static int KeySize { get; } = 512 / 8; private static int SaltSize { get; } = 128 / 8; //128 bit salt //As noted here: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#maximum-password-lengths //allowing passwords that are too long can result in a denial-of-service attack. So we must enforce password length limits. //The recommended length is between 64 and 128, so I decided to go for the upper bounds. public static string SectionName { get; } = "PasswordSettings"; private static int AbsoluteMinPasswordLength { get; } = 16; private static int AbsoluteMaxPasswordLength { get; } = 128; public static (string Hash, string Salt) HashPassword(string password) { var salt = new byte[SaltSize]; ByteGenerator.GetRandomBytes(ref salt); return (GetHash(password, salt), Convert.ToBase64String(salt)); } public static (bool IsValid, string Message) IsPasswordValid(string password) { if (string.IsNullOrEmpty(password)) return (false, "No password has been supplied, and thus is not valid."); if (AppSettingsManager.TryGetSettingInt(SectionName, "MaxLength", out int maxPasswordLength)) { //As noted in this article https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#maximum-password-lengths //allowing passwords that are too long can result in a denial-of-service attack. So we must enforce an upper bound //on the length of passwords. The recommended length is between 64 and 128, so if the length is set long than //128 characters, just default to 128 as that's a safe upper limit. if (maxPasswordLength > AbsoluteMaxPasswordLength || maxPasswordLength < AbsoluteMinPasswordLength) maxPasswordLength = AbsoluteMaxPasswordLength; } else maxPasswordLength = AbsoluteMaxPasswordLength; if (AppSettingsManager.TryGetSettingInt(SectionName, "MinLength", out int minPasswordLength)) { //There was no mention of a min password length in the above article, so I've chosen on a whim that 16 should //be a safe enough min on a password's length. So as usual, just ignore settings that are out of bounds and //instead stick to safe values that are in bounds. if (minPasswordLength < AbsoluteMinPasswordLength || minPasswordLength > AbsoluteMaxPasswordLength) minPasswordLength = AbsoluteMinPasswordLength; } else minPasswordLength = AbsoluteMinPasswordLength; // Validate the settings further. if(minPasswordLength == maxPasswordLength || minPasswordLength > maxPasswordLength) { minPasswordLength = AbsoluteMinPasswordLength; maxPasswordLength = AbsoluteMaxPasswordLength; } if (password.Length < minPasswordLength) return (false, $"Your password is too short, it must be at least {minPasswordLength} characters long and not exceed {maxPasswordLength} characters."); if (password.Length > maxPasswordLength) return (false, $"Your password is too long, it must not exceed {maxPasswordLength} characters and must contain at least {minPasswordLength} characters."); return (true, string.Empty); } public static bool IsPasswordAMatch(string password, string salt, string passwordHash) { var saltBytes = Convert.FromBase64String(salt); return passwordHash == GetHash(password, saltBytes); } public static void InsertPasswordResetRequest(string email, string sessionToken, DateTime expirationDate, string userAgent, string ipAddress, string connectionString) { using var connection = new SqlConnection(connectionString); using var command = new SqlCommand("LogPasswordResetRequest", connection) { CommandType = CommandType.StoredProcedure }; command.Parameters.AddWithValue("SessionToken", sessionToken); command.Parameters.AddWithValue("ExpirationDate", expirationDate); command.Parameters.AddWithValue("Email", email); command.Parameters.AddWithValue("UserAgent", userAgent); command.Parameters.AddWithValue("IpAddress", ipAddress); connection.Open(); command.ExecuteNonQuery(); } public static void ResetPassword(string passwordHash, string salt, string sessionToken, string connectionString) { using var connection = new SqlConnection(connectionString); using var command = new SqlCommand("ResetPassword", connection) { CommandType = CommandType.StoredProcedure }; command.Parameters.AddWithValue("PasswordHash", passwordHash); command.Parameters.AddWithValue("Salt", salt); command.Parameters.AddWithValue("SessionToken", sessionToken); connection.Open(); command.ExecuteNonQuery(); } public static (string PasswordHash, string Salt) GetPasswordHashAndSalt(string username, string connectionString) { using var connection = new SqlConnection(connectionString); using var command = new SqlCommand("SELECT [Password Hash], [Salt] FROM dbo.GetUserPasswordHashAndSalt(@Username)", connection); command.Parameters.AddWithValue("Username", username); connection.Open(); var reader = command.ExecuteReader(); reader.Read(); if (!reader.HasRows) return (string.Empty, string.Empty); return (reader["Password Hash"].ToString(), reader["Salt"].ToString()); } public static string HashStringData(string data, string salt = "") { _ = new byte[0]; byte[] saltBytes; if (!string.IsNullOrEmpty(salt)) saltBytes = Convert.FromBase64String(salt); else { saltBytes = new byte[SaltSize]; ByteGenerator.GetRandomBytes(ref saltBytes); } return GetHash(data, saltBytes); } private static string GetHash(string password, byte[] salt) { if (AppSettingsManager.TryGetSettingInt(SectionName, "Iterations", out int iterations)) { //The work factor must be of a certain strength and if it fails this check then we will be forced to ignore it and use the recommended work factor //as stated here: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 //As stated in the above link, 10,000 iterations is the lowest we should ever go. So for security's sake, that's the lower bounds that will be allowed. if (iterations < 10000) iterations = 10000; } //By default, we'll go with the highest security setting if one isn't provided by an admin. //To quote the above link: //"The work factor for PBKDF2 is implemented through the iteration count, which should be at least 10,000 //(although values of up to 100,000 may be appropriate in higher security environments)." else iterations = 100000; return Convert.ToBase64String(KeyDerivation.Pbkdf2($"{password}{PasswordPepper}", salt, KeyType, iterations, KeySize)); } } }