diff --git a/src/BCrypt.Net.MainPackage/BCrypt.Net.Package.csproj b/src/BCrypt.Net.MainPackage/BCrypt.Net.Package.csproj index 4abbbac..171fe34 100644 --- a/src/BCrypt.Net.MainPackage/BCrypt.Net.Package.csproj +++ b/src/BCrypt.Net.MainPackage/BCrypt.Net.Package.csproj @@ -71,4 +71,8 @@ bin\$(Configuration)\$(TargetFramework)\BCrypt.Net-Next.xml + + true + + diff --git a/src/BCrypt.Net.StrongName/BCrypt.Net.StrongName.csproj b/src/BCrypt.Net.StrongName/BCrypt.Net.StrongName.csproj index ffee818..cfa7d4e 100644 --- a/src/BCrypt.Net.StrongName/BCrypt.Net.StrongName.csproj +++ b/src/BCrypt.Net.StrongName/BCrypt.Net.StrongName.csproj @@ -71,4 +71,8 @@ bin\$(Configuration)\$(TargetFramework)\BCrypt.Net-Next.xml + + true + + diff --git a/src/BCrypt.Net.UnitTests/BCrypt.Net.UnitTests.csproj b/src/BCrypt.Net.UnitTests/BCrypt.Net.UnitTests.csproj index 89eecae..e3301a4 100644 --- a/src/BCrypt.Net.UnitTests/BCrypt.Net.UnitTests.csproj +++ b/src/BCrypt.Net.UnitTests/BCrypt.Net.UnitTests.csproj @@ -10,6 +10,10 @@ 1701;1702;CS1591 + + true + + diff --git a/src/BCrypt.Net.UnitTests/BCryptTests.cs b/src/BCrypt.Net.UnitTests/BCryptTests.cs index 33e02a0..802d162 100644 --- a/src/BCrypt.Net.UnitTests/BCryptTests.cs +++ b/src/BCrypt.Net.UnitTests/BCryptTests.cs @@ -19,6 +19,7 @@ IN THE SOFTWARE. using System; using System.Diagnostics; +using System.Security; using System.Security.Cryptography; using System.Text; using Xunit; @@ -145,6 +146,62 @@ public void TestHashPassword() } + private SecureString AsSecureString(string text) + { + var result = new SecureString(); + foreach (var c in text) result.AppendChar(c); + result.MakeReadOnly(); + return result; + } + + + /** + * Test method for 'BCrypt.HashPassword(SecureString, string)' + */ + [Fact()] + public void TestSecureHashPassword() + { + Trace.Write("BCrypt.HashPassword()[Secure]: "); + var sw = Stopwatch.StartNew(); + for (var r = 0; r < _revisions.Length; r++) + { + for (int i = 0; i < _testVectors.Length / 3; i++) + { + var plain = AsSecureString(_testVectors[i, 0]); + string salt; + string expected; + if (r > 0) + { + //Check hash that goes in one end comes out the next the same + salt = _testVectors[i, 1].Replace("2a", "2" + _revisions[r]); + + string hashed = BCrypt.HashPassword(plain, salt); + + + var d = hashed.StartsWith("$2" + _revisions[r]); + Assert.True(d); + Trace.WriteLine(hashed); + } + else + { + salt = _testVectors[i, 1]; + expected = _testVectors[i, 2]; + + string hashed = BCrypt.HashPassword(plain, salt); + var d = hashed == expected; + Assert.Equal(hashed, expected); + } + + + Trace.Write("."); + } + } + + Trace.WriteLine(sw.ElapsedMilliseconds); + Trace.WriteLine(""); + } + + /** * Test method for 'BCrypt.HashPassword(string, string)' */ @@ -182,6 +239,42 @@ public void TestHashPasswordEnhanced() Trace.WriteLine(""); } + [Fact()] + public void TestSecureHashPasswordEnhanced() + { + Trace.Write("BCrypt.HashPassword()[Secure]: "); + var sw = Stopwatch.StartNew(); + for (var r = 0; r < _revisions.Length; r++) + { + for (int i = 0; i < _testVectors.Length / 3; i++) + { + var plain = _testVectors[i, 0]; + string salt; + + //Check hash that goes in one end comes out the next the same + salt = _testVectors[i, 1].Replace("2a", "2" + _revisions[r]); + + string hashed = BCrypt.HashPassword(plain, salt, enhancedEntropy: true); + string secureHashed = BCrypt.HashPassword(AsSecureString(plain), salt, enhancedEntropy: true); + + var revCheck = hashed.StartsWith("$2" + _revisions[r]); + + Assert.True(revCheck); + Assert.Equal(hashed, secureHashed); + + var validateHashCheck = BCrypt.EnhancedVerify(AsSecureString(plain), hashed); + Assert.True(validateHashCheck); + + Trace.WriteLine(hashed); + + Trace.Write("."); + } + } + + Trace.WriteLine(sw.ElapsedMilliseconds); + Trace.WriteLine(""); + } + [Fact()] public void TestHashPasswordEnhancedWithHashType() { @@ -216,6 +309,42 @@ public void TestHashPasswordEnhancedWithHashType() Trace.WriteLine(""); } + [Fact()] + public void TestSecureHashPasswordEnhancedWithHashType() + { + Trace.Write("BCrypt.HashPassword()[Secure]: "); + var sw = Stopwatch.StartNew(); + for (var r = 0; r < _revisions.Length; r++) + { + for (int i = 0; i < _testVectors.Length / 3; i++) + { + var plain = _testVectors[i, 0]; + string salt; + + //Check hash that goes in one end comes out the next the same + salt = _testVectors[i, 1].Replace("2a", "2" + _revisions[r]); + + string hashed = BCrypt.HashPassword(plain, salt, true, HashType.SHA256); + string secureHashed = BCrypt.HashPassword(AsSecureString(plain), salt, true, HashType.SHA256); + + var revCheck = hashed.StartsWith("$2" + _revisions[r]); + + Assert.True(revCheck); + Assert.Equal(hashed, secureHashed); + + var validateHashCheck = BCrypt.EnhancedVerify(AsSecureString(plain), hashed, HashType.SHA256); + Assert.True(validateHashCheck); + + Trace.WriteLine(hashed); + + Trace.Write("."); + } + } + + Trace.WriteLine(sw.ElapsedMilliseconds); + Trace.WriteLine(""); + } + [Fact()] public void TestValidateAndReplace() { @@ -240,6 +369,30 @@ public void TestValidateAndReplace() } + [Fact()] + public void TestSecureValidateAndReplace() + { + for (int i = 0; i < _testVectors.Length / 3; i++) + { + var currentKey = AsSecureString(_testVectors[i, 0]); + string salt = _testVectors[i, 1]; + string currentHash = _testVectors[i, 2]; + + var newPassword = AsSecureString("my new password"); + string hashed = BCrypt.HashPassword(currentKey, salt); + var d = hashed == currentHash; + + var newHash = BCrypt.ValidateAndReplacePassword(currentKey, currentHash, newPassword); + + var newPassValid = BCrypt.Verify(newPassword, newHash); + + Assert.True(newPassValid); + + Trace.Write("."); + } + + } + [Theory()] [InlineData("\u2605\u2605\u2605\u2605\u2605\u2605\u2605\u2605")] diff --git a/src/BCrypt.Net/BCrypt.Net.csproj b/src/BCrypt.Net/BCrypt.Net.csproj index 99958b0..4227c33 100644 --- a/src/BCrypt.Net/BCrypt.Net.csproj +++ b/src/BCrypt.Net/BCrypt.Net.csproj @@ -51,6 +51,10 @@ bin\$(Configuration)\$(TargetFramework)\BCrypt.Net-Next.xml + + true + + diff --git a/src/BCrypt.Net/BCrypt.cs b/src/BCrypt.Net/BCrypt.cs index cb44ad1..9aaaa9a 100644 --- a/src/BCrypt.Net/BCrypt.cs +++ b/src/BCrypt.Net/BCrypt.cs @@ -19,6 +19,8 @@ IN THE SOFTWARE. using System; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -407,6 +409,296 @@ public sealed class BCrypt private uint[] _p; private uint[] _s; + // SecureString support for inputKey + // + // + // SecureString is an arguably poorly implemented idea as .Net created it and sometimes requires us to use it but lacks + // support in man places where it would be useful, and it can be useful; like moving passwords around. + // So, what we are supposed to know is that moving the clear text version of the SecureString into a managed string opens + // up the possibility of putting a copy into memory out of our control - and we pretty much have to put it in a managed + // string to do anything with it! So, the trick is to pin a managed string to take it away from the GC so as to not inadvertently + // makes copies, use it, then wipe it out before letting GC have it back. Basically narrow the clear text window. + // + // + // Look for the "SecureString COMMENT"s where I make some observations. + // + // Oh, and this whole thing isn't supposed to be super fast, so, there's that. + // + // + // + + #region SecureString support for inputKey + + /// + /// Verifies that the hash of the given matches the provided + /// ; the string will undergo SHA384 hashing to maintain the enhanced entropy work done during hashing + /// + /// The text to verify. + /// The previously-hashed password. + /// HashType used (default SHA384) + /// true if the passwords match, false otherwise. + public static bool EnhancedVerify(SecureString text, string hash, HashType hashType = DefaultEnhancedHashType) => Verify(text, hash, true, hashType); + + /// + /// Verifies that the hash of the given matches the provided + /// + /// + /// The text to verify. + /// The previously-hashed password. + /// Set to true,the string will undergo SHA384 hashing to make use of available entropy prior to bcrypt hashing + /// HashType used (default SHA384) + /// true if the passwords match, false otherwise. + /// Thrown when one or more arguments have unsupported or illegal values. + /// Thrown when the salt could not be parsed. + public static bool Verify(SecureString text, string hash, bool enhancedEntropy = false, HashType hashType = DefaultEnhancedHashType) + => VerifyHashes(hash, HashPassword(text, hash, enhancedEntropy, hashType)); + + + /// + /// Validate existing hash and password, + /// + /// Current password / string + /// Current hash to validate password against + /// NEW password / string to be hashed + /// The log2 of the number of rounds of hashing to apply - the work + /// factor therefore increases as 2^workFactor. Default is 11 + /// By default this method will not accept a work factor lower + /// than the one set in the current hash and will set the new work-factor to match. + /// returned if the users hash and current pass doesn't validate + /// returned if the salt is invalid in any way + /// returned if the hash is invalid + /// returned if the user hash is null + /// New hash of new password + public static string ValidateAndReplacePassword(SecureString currentKey, string currentHash, SecureString newKey, int workFactor = DefaultRounds, bool forceWorkFactor = false) => + ValidateAndReplacePassword(currentKey, currentHash, false, HashType.None, newKey, false, HashType.None, workFactor, forceWorkFactor); + + + /// + /// Validate existing hash and password, + /// + /// Current password / string + /// Current hash to validate password against + /// Set to true,the string will undergo SHA384 hashing to make + /// use of available entropy prior to bcrypt hashing + /// HashType used (default SHA384) + /// + /// NEW password / string to be hashed + /// Set to true,the string will undergo SHA384 hashing to make + /// use of available entropy prior to bcrypt hashing + /// HashType to use (default SHA384) + /// The log2 of the number of rounds of hashing to apply - the work + /// factor therefore increases as 2^workFactor. Default is 11 + /// By default this method will not accept a work factor lower + /// than the one set in the current hash and will set the new work-factor to match. + /// returned if the users hash and current pass doesn't validate + /// returned if the salt is invalid in any way + /// returned if the hash is invalid + /// returned if the user hash is null + /// New hash of new password + public static string ValidateAndReplacePassword(SecureString currentKey, string currentHash, bool currentKeyEnhancedEntropy, HashType oldHashType, + SecureString newKey, bool newKeyEnhancedEntropy = false, HashType newHashType = DefaultEnhancedHashType, int workFactor = DefaultRounds, bool forceWorkFactor = false) + { + return SandboxSecureString( + currentKey, + currentClear => + { + return SandboxSecureString( + newKey, + newClear => + { + return ValidateAndReplacePassword( + currentClear, + currentHash, + currentKeyEnhancedEntropy, + oldHashType, + newClear, + newKeyEnhancedEntropy, + newHashType, + workFactor, + forceWorkFactor); + }); + }); + } + + /// + /// Hash a password using the OpenBSD BCrypt scheme and a salt generated by . + /// + /// The password to hash. + /// The hashed password. + /// Thrown when the salt could not be parsed. + public static string HashPassword(SecureString inputKey) => HashPassword(inputKey, GenerateSalt()); + + /// + /// Pre-hash a password with SHA384 then using the OpenBSD BCrypt scheme and a salt generated by . + /// + /// The password to hash. + /// The hashed password. + /// Thrown when the salt could not be parsed. + public static string EnhancedHashPassword(SecureString inputKey) => HashPassword(inputKey, GenerateSalt(), true); + + /// + /// Pre-hash a password with SHA384 then using the OpenBSD BCrypt scheme and a salt generated by . + /// + /// The password to hash. + /// + /// The hashed password. + /// Thrown when the salt could not be parsed. + public static string EnhancedHashPassword(SecureString inputKey, int workFactor) => HashPassword(inputKey, GenerateSalt(workFactor), true); + + /// + /// Pre-hash a password with SHA384 then using the OpenBSD BCrypt scheme and a salt generated by . + /// + /// The password to hash. + /// + /// Configurable hash type for enhanced entropy + /// The hashed password. + /// Thrown when the salt could not be parsed. + public static string EnhancedHashPassword(SecureString inputKey, int workFactor, HashType hashType) => HashPassword(inputKey, GenerateSalt(workFactor), true, hashType); + + /// + /// Pre-hash a password with SHA384 then using the OpenBSD BCrypt scheme and a salt generated by . + /// + /// The password to hash. + /// Defaults to 11 + /// Configurable hash type for enhanced entropy + /// The hashed password. + /// Thrown when the salt could not be parsed. + public static string EnhancedHashPassword(SecureString inputKey, HashType hashType, int workFactor = DefaultRounds) => HashPassword(inputKey, GenerateSalt(workFactor), true, hashType); + + /// + /// Hash a password using the OpenBSD BCrypt scheme and a salt generated by using the given . + /// + /// The password to hash. + /// The log2 of the number of rounds of hashing to apply - the work + /// factor therefore increases as 2^workFactor. Default is 11 + /// Set to true,the string will undergo SHA384 hashing to make use of available entropy prior to bcrypt hashing + /// The hashed password. + /// Thrown when the salt could not be parsed. + public static string HashPassword(SecureString inputKey, int workFactor, bool enhancedEntropy = false) => HashPassword(inputKey, GenerateSalt(workFactor), enhancedEntropy); + + /// Hash a password using the OpenBSD BCrypt scheme. + /// Thrown when one or more arguments have unsupported or illegal values. + /// The password or string to hash. + /// the salt to hash with (best generated using ). + /// The hashed password + /// Thrown when the could not be parsed. + public static string HashPassword(SecureString inputKey, string salt) => HashPassword(inputKey, salt, false); + + /// Hash a password using the OpenBSD BCrypt scheme. + /// + /// based on an answer by sclarke81 in https://stackoverflow.com/questions/818704/how-to-convert-securestring-to-system-string + /// Yes, the SecureString becomes a managed string, but we are managing its lifetime in memory. + /// + /// Thrown when one or more arguments have unsupported or illegal values. + /// The password or string to hash. + /// the salt to hash with (best generated using ). + /// Set to true,the string will undergo hashing (defaults to SHA384 then base64 encoding) to make use of available entropy prior to bcrypt hashing + /// Configurable hash type for enhanced entropy + /// The hashed password + /// Thrown when the is null. + /// Thrown when the could not be parsed. + public static string HashPassword(SecureString inputKey, string salt, bool enhancedEntropy, HashType hashType = DefaultEnhancedHashType) + { + return SandboxSecureString( + inputKey, + clearText => + { + return HashPassword(clearText, salt, enhancedEntropy, hashType); + }); + } + + // .net 2.0 safe - basically Func + private delegate string UseSandboxedSecureString(string inputKey); + + private static string SandboxSecureString(SecureString inputKey, UseSandboxedSecureString func) + { + if (inputKey == null) + { + throw new ArgumentNullException(nameof(inputKey)); + } + + int length = inputKey.Length; + IntPtr sourceStringPointer = IntPtr.Zero; + + // Create an empty string of the correct size and pin it so that the GC can't move it around. + string insecureString = new string('\0', length); + var insecureStringHandler = GCHandle.Alloc(insecureString, GCHandleType.Pinned); + + IntPtr insecureStringPointer = insecureStringHandler.AddrOfPinnedObject(); + + try + { + // Create an unmanaged copy of the secure string. + sourceStringPointer = Marshal.SecureStringToBSTR(inputKey); + + // Use the pointers to copy from the unmanaged to managed string. + for (int i = 0; i < inputKey.Length; i++) + { + short unicodeChar = Marshal.ReadInt16(sourceStringPointer, i * 2); + Marshal.WriteInt16(insecureStringPointer, i * 2, unicodeChar); + } + + return func(insecureString); + } + finally + { + // Zero the managed string so that the string is erased. Then unpin it to allow the + // GC to take over. + Marshal.Copy(new byte[length * 2], 0, insecureStringPointer, length * 2); + insecureStringHandler.Free(); + + // Zero and free the unmanaged string. + Marshal.ZeroFreeBSTR(sourceStringPointer); + } + } + + // .net 2.0 safe - basically Func + private delegate string PerformHashDelegate(byte[] inputBytes); + + /// Do our own managing of a utf8 copy of the password text. + /// The password or string to hash. + /// added to the end of inputKey + /// function called with pinned utf8 + /// func result + private unsafe static string SandboxUTF8Copy(string inputKey, string addendum, PerformHashDelegate func) + { + var maxBytes = SafeUTF8.GetMaxByteCount(inputKey.Length + addendum.Length); + var utf8Buffer = Marshal.AllocHGlobal(maxBytes); + var utf8Pointer = (byte*)utf8Buffer.ToPointer(); + var inputKeyHandler = GCHandle.Alloc(inputKey, GCHandleType.Pinned); + var inputKeyPointer = (char*)inputKeyHandler.AddrOfPinnedObject().ToPointer(); + var addendumHandler = GCHandle.Alloc(addendum, GCHandleType.Pinned); + var addendumPointer = (char*)addendumHandler.AddrOfPinnedObject().ToPointer(); + + try + { + var utf8Bytes = Encoding.UTF8.GetBytes(inputKeyPointer, inputKey.Length, utf8Pointer, maxBytes); + utf8Bytes += Encoding.UTF8.GetBytes(addendumPointer, addendum.Length, utf8Pointer + utf8Bytes, maxBytes - utf8Bytes); + + var result = new byte[utf8Bytes]; + var utf8BytesPin = GCHandle.Alloc(result, GCHandleType.Pinned); + try + { + Marshal.Copy(utf8Buffer, result, 0, utf8Bytes); + Marshal.Copy(new byte[maxBytes], 0, utf8Buffer, maxBytes); + + return func(result); + } + finally + { + Marshal.Copy(new byte[utf8Bytes], 0, utf8BytesPin.AddrOfPinnedObject(), utf8Bytes); + utf8BytesPin.Free(); + } + } + finally + { + inputKeyHandler.Free(); + addendumHandler.Free(); + Marshal.FreeHGlobal(utf8Buffer); + } + } + + #endregion /// /// Validate existing hash and password, @@ -599,6 +891,10 @@ public static string ValidateAndReplacePassword(string currentKey, string curren /// Thrown when the could not be parsed. public static string HashPassword(string inputKey, string salt, bool enhancedEntropy, HashType hashType = DefaultEnhancedHashType) { + // SecureString COMMENT + // DO NOT DO ANYTHING WITH inputKey lest thee drop a copy in managed memory + // Let our use of it happen in the sandboxed utf8 scope + if (inputKey == null) { throw new ArgumentNullException(nameof(inputKey)); @@ -652,26 +948,33 @@ public static string HashPassword(string inputKey, string salt, bool enhancedEnt string extractedSalt = salt.Substring(startingOffset + 3, 22); - byte[] inputBytes = SafeUTF8.GetBytes(inputKey + (minor >= 'a' ? Nul : EmptyString)); + // originally this - which makes a managed string copy... + //byte[] inputBytes = SafeUTF8.GetBytes(inputKey + (minor >= 'a' ? Nul : EmptyString)); - if (enhancedEntropy) - { - inputBytes = EnhancedHash(inputBytes, hashType); - } + return SandboxUTF8Copy( + inputKey, + minor >= 'a' ? Nul : EmptyString, + inputBytes => // SecureString COMMENT - this is a reference to the pinned byte[] + { + if (enhancedEntropy) + { + inputBytes = EnhancedHash(inputBytes, hashType); // SecureString COMMENT - changes the reference to a managed hash byte[] + } - byte[] saltBytes = DecodeBase64(extractedSalt, BCryptSaltLen); + byte[] saltBytes = DecodeBase64(extractedSalt, BCryptSaltLen); - BCrypt bCrypt = new BCrypt(); + BCrypt bCrypt = new BCrypt(); - byte[] hashed = bCrypt.CryptRaw(inputBytes, saltBytes, workFactor); + byte[] hashed = bCrypt.CryptRaw(inputBytes, saltBytes, workFactor); // SecureString COMMENT - I did not find any buffer copying from here - be careful - // Generate result string - StringBuilder result = new StringBuilder(); - result.AppendFormat("$2{1}${0:00}$", workFactor, minor); - result.Append(EncodeBase64(saltBytes, saltBytes.Length)); - result.Append(EncodeBase64(hashed, (BfCryptCiphertext.Length * 4) - 1)); + // Generate result string + StringBuilder result = new StringBuilder(); + result.AppendFormat("$2{1}${0:00}$", workFactor, minor); + result.Append(EncodeBase64(saltBytes, saltBytes.Length)); + result.Append(EncodeBase64(hashed, (BfCryptCiphertext.Length * 4) - 1)); - return result.ToString(); + return result.ToString(); + }); } /// @@ -682,6 +985,13 @@ public static string HashPassword(string inputKey, string salt, bool enhancedEnt /// private static byte[] EnhancedHash(byte[] inputBytes, HashType hashType) { + + // SecureString COMMENT + // We have to trust that these hash functions don't make a copy of inputBytes. I checked the \referencesource on github + // and the managed version of SHA256, SHA384, and SHA512 do not. I figured the managed version would be worst case. + // Now, keep in mind that the hash they produce will be in managed memory and possibly leaky. Without implementing + // them ourself we'll just have to accept this compromise. + switch (hashType) { case HashType.SHA256: @@ -834,8 +1144,17 @@ public static string GenerateSalt() /// Thrown when one or more arguments have unsupported or illegal values. /// Thrown when the salt could not be parsed. public static bool Verify(string text, string hash, bool enhancedEntropy = false, HashType hashType = DefaultEnhancedHashType) + => VerifyHashes(hash, HashPassword(text, hash, enhancedEntropy, hashType)); + + /// + /// Verifies same hash values + /// + /// a hashed password + /// another hashed password + /// true if hashes are equal + private static bool VerifyHashes(string hashA, string hashB) { - return SecureEquals(SafeUTF8.GetBytes(hash), SafeUTF8.GetBytes(HashPassword(text, hash, enhancedEntropy, hashType))); + return SecureEquals(SafeUTF8.GetBytes(hashA), SafeUTF8.GetBytes(hashB)); } // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimised.