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; } private static KeyDerivationPrf KeyType { get; } = KeyDerivationPrf.HMACSHA512; private static int KeySize { get; } = 512 / 8; private static int SaltSize { get; } = 128 / 8; //128 bit salt public static string SectionName { get; } = "PasswordSettings"; private static int MinPasswordLength { get; set; } private static int MaxPasswordLength { get; set; } private static int Iterations { get; set; } //Application enforced absolutes that can not fall short of or be exceeded by the configuartion settings of this application. private static int AbsoluteMinPasswordLength { get; } = 16; private static int AbsoluteMaxPasswordLength { get; } = 128; private static int AbsoluteMinPepperLength { get; } = 32; private static int AbsoluteMinWorkFactor { get; set; } = 10000; public static void InitializeSettings() { if (!string.IsNullOrEmpty(Pepper)) throw new InvalidOperationException("The PasswordManager's settings have already been initialized. Operation aborted."); 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; } MinPasswordLength = minPasswordLength; MaxPasswordLength = maxPasswordLength; //Now read in the pepper. A pepper being a string of characters at least 32 characters long that is NOT stored in the database and is used in conjunction with hashing sensitive user data. if (AppSettingsManager.TryGetSettingString($"{SectionName}.Pepper", out string pepper)) { //As noted here https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html a pepper should be at least 32 bytes in size. if (pepper.Length < AbsoluteMinPepperLength) throw new Exception("A pepper must be at least 32 characters long for security reasons."); } else throw new Exception($"A pepper, generated by a cryptographically secure random number generator, that is at least 32 characters long, must be supplied in the appsettings.json file (Key path: {SectionName}.Pepper)."); Pepper = pepper; //Finally, the work factor (A.K.A. iterations) for the hashing algorithm. 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 < AbsoluteMinWorkFactor) iterations = AbsoluteMinWorkFactor; } //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; Iterations = iterations; } 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 (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 = "") { _ = Array.Empty(); 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) { return Convert.ToBase64String(KeyDerivation.Pbkdf2($"{Pepper}{password}", salt, KeyType, Iterations, KeySize)); } } }