diff --git a/src/Microsoft.AspNet.Identity/Crypto.cs b/src/Microsoft.AspNet.Identity/Crypto.cs deleted file mode 100644 index 57913e08ae..0000000000 --- a/src/Microsoft.AspNet.Identity/Crypto.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Runtime.CompilerServices; -using System.Security.Cryptography; - -namespace Microsoft.AspNet.Identity -{ - internal static class Crypto - { - private const int Pbkdf2IterCount = 1000; // default for Rfc2898DeriveBytes - private const int Pbkdf2SubkeyLength = 256 / 8; // 256 bits - private const int SaltSize = 128 / 8; // 128 bits - - /* ======================= - * HASHED PASSWORD FORMATS - * ======================= - * - * Version 0: - * PBKDF2 with HMAC-SHA1, 128-bit salt, 256-bit subkey, 1000 iterations. - * (See also: SDL crypto guidelines v5.1, Part III) - * Format: { 0x00, salt, subkey } - */ - - public static string HashPassword(string password) - { - if (password == null) - { - throw new ArgumentNullException("password"); - } - - // Produce a version 0 (see comment above) text hash. - byte[] salt; - byte[] subkey; - using (var deriveBytes = new Rfc2898DeriveBytes(password, SaltSize, Pbkdf2IterCount)) - { - salt = deriveBytes.Salt; - subkey = deriveBytes.GetBytes(Pbkdf2SubkeyLength); - } - - var outputBytes = new byte[1 + SaltSize + Pbkdf2SubkeyLength]; - Buffer.BlockCopy(salt, 0, outputBytes, 1, SaltSize); - Buffer.BlockCopy(subkey, 0, outputBytes, 1 + SaltSize, Pbkdf2SubkeyLength); - return Convert.ToBase64String(outputBytes); - } - - // hashedPassword must be of the format of HashWithPassword (salt + Hash(salt+input) - public static bool VerifyHashedPassword(string hashedPassword, string password) - { - if (hashedPassword == null) - { - return false; - } - if (password == null) - { - throw new ArgumentNullException("password"); - } - - // Verify a version 0 (see comment above) text hash. - var hashedPasswordBytes = Convert.FromBase64String(hashedPassword); - if (hashedPasswordBytes.Length != (1 + SaltSize + Pbkdf2SubkeyLength) || hashedPasswordBytes[0] != 0x00) - { - // Wrong length or version header. - return false; - } - var salt = new byte[SaltSize]; - Buffer.BlockCopy(hashedPasswordBytes, 1, salt, 0, SaltSize); - var storedSubkey = new byte[Pbkdf2SubkeyLength]; - Buffer.BlockCopy(hashedPasswordBytes, 1 + SaltSize, storedSubkey, 0, Pbkdf2SubkeyLength); - - byte[] generatedSubkey; - using (var deriveBytes = new Rfc2898DeriveBytes(password, salt, Pbkdf2IterCount)) - { - generatedSubkey = deriveBytes.GetBytes(Pbkdf2SubkeyLength); - } - return ByteArraysEqual(storedSubkey, generatedSubkey); - } - - // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized. - [MethodImpl(MethodImplOptions.NoOptimization)] - private static bool ByteArraysEqual(byte[] a, byte[] b) - { - if (ReferenceEquals(a, b)) - { - return true; - } - if (a == null || b == null || a.Length != b.Length) - { - return false; - } - var areSame = true; - for (var i = 0; i < a.Length; i++) - { - areSame &= (a[i] == b[i]); - } - return areSame; - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/PasswordHasher.cs b/src/Microsoft.AspNet.Identity/PasswordHasher.cs index e5726ade4f..bebcc63614 100644 --- a/src/Microsoft.AspNet.Identity/PasswordHasher.cs +++ b/src/Microsoft.AspNet.Identity/PasswordHasher.cs @@ -1,6 +1,12 @@ // Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using Microsoft.AspNet.Security.DataProtection; +using Microsoft.Framework.OptionsModel; + namespace Microsoft.AspNet.Identity { /// @@ -8,6 +14,64 @@ namespace Microsoft.AspNet.Identity /// public class PasswordHasher : IPasswordHasher where TUser : class { + /* ======================= + * HASHED PASSWORD FORMATS + * ======================= + * + * Version 2: + * PBKDF2 with HMAC-SHA1, 128-bit salt, 256-bit subkey, 1000 iterations. + * (See also: SDL crypto guidelines v5.1, Part III) + * Format: { 0x00, salt, subkey } + * + * Version 3: + * PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations. + * Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey } + * (All UInt32s are stored big-endian.) + */ + + private readonly PasswordHasherCompatibilityMode _compatibilityMode; + private readonly RandomNumberGenerator _rng; + + /// + /// Constructs a PasswordHasher using the specified options + /// + /// + public PasswordHasher(IOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _compatibilityMode = options.Options.CompatibilityMode; + if (!IsValidCompatibilityMode(_compatibilityMode)) + { + throw new InvalidOperationException(Resources.InvalidPasswordHasherCompatibilityMode); + } + + _rng = options.Options.Rng; + } + + // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized. + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static bool ByteArraysEqual(byte[] a, byte[] b) + { + if (a == null && b == null) + { + return true; + } + if (a == null || b == null || a.Length != b.Length) + { + return false; + } + var areSame = true; + for (var i = 0; i < a.Length; i++) + { + areSame &= (a[i] == b[i]); + } + return areSame; + } + /// /// Hash a password /// @@ -16,7 +80,77 @@ namespace Microsoft.AspNet.Identity /// public virtual string HashPassword(TUser user, string password) { - return Crypto.HashPassword(password); + if (password == null) + { + throw new ArgumentNullException(nameof(password)); + } + + if (_compatibilityMode == PasswordHasherCompatibilityMode.IdentityV2) + { + return Convert.ToBase64String(HashPasswordV2(password, _rng)); + } + else + { + return Convert.ToBase64String(HashPasswordV3(password, _rng)); + } + } + + private static byte[] HashPasswordV2(string password, RandomNumberGenerator rng) + { + const KeyDerivationPrf Pbkdf2Prf = KeyDerivationPrf.Sha1; // default for Rfc2898DeriveBytes + const int Pbkdf2IterCount = 1000; // default for Rfc2898DeriveBytes + const int Pbkdf2SubkeyLength = 256 / 8; // 256 bits + const int SaltSize = 128 / 8; // 128 bits + + // Produce a version 2 (see comment above) text hash. + byte[] salt = new byte[SaltSize]; + rng.GetBytes(salt); + byte[] subkey = KeyDerivation.Pbkdf2(password, salt, Pbkdf2Prf, Pbkdf2IterCount, Pbkdf2SubkeyLength); + + var outputBytes = new byte[1 + SaltSize + Pbkdf2SubkeyLength]; + outputBytes[0] = 0x00; // format marker + Buffer.BlockCopy(salt, 0, outputBytes, 1, SaltSize); + Buffer.BlockCopy(subkey, 0, outputBytes, 1 + SaltSize, Pbkdf2SubkeyLength); + return outputBytes; + } + + private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng) + { + return HashPasswordV3(password, rng, + prf: KeyDerivationPrf.Sha256, + iterCount: 10000, + saltSize: 128 / 8, + numBytesRequested: 256 / 8); + } + + private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng, KeyDerivationPrf prf, int iterCount, int saltSize, int numBytesRequested) + { + // Produce a version 3 (see comment above) text hash. + byte[] salt = new byte[saltSize]; + rng.GetBytes(salt); + byte[] subkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, numBytesRequested); + + var outputBytes = new byte[13 + salt.Length + subkey.Length]; + outputBytes[0] = 0x01; // format marker + WriteNetworkByteOrder(outputBytes, 1, (uint)prf); + WriteNetworkByteOrder(outputBytes, 5, (uint)iterCount); + WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize); + Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length); + Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length); + return outputBytes; + } + + private static bool IsValidCompatibilityMode(PasswordHasherCompatibilityMode compatibilityMode) + { + return (compatibilityMode == PasswordHasherCompatibilityMode.IdentityV2 || compatibilityMode == PasswordHasherCompatibilityMode.IdentityV3); + } + + private static uint ReadNetworkByteOrder(byte[] buffer, int offset) + { + return ((uint)(buffer[offset + 0]) << 24) + | ((uint)(buffer[offset + 1]) << 16) + | ((uint)(buffer[offset + 2]) << 8) + | ((uint)(buffer[offset + 3])); } /// @@ -28,9 +162,116 @@ namespace Microsoft.AspNet.Identity /// public virtual PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword, string providedPassword) { - return Crypto.VerifyHashedPassword(hashedPassword, providedPassword) - ? PasswordVerificationResult.Success - : PasswordVerificationResult.Failed; + if (hashedPassword == null) + { + throw new ArgumentNullException(nameof(hashedPassword)); + } + if (providedPassword == null) + { + throw new ArgumentNullException(nameof(providedPassword)); + } + + byte[] decodedHashedPassword = Convert.FromBase64String(hashedPassword); + + // read the format marker from the hashed password + if (decodedHashedPassword.Length == 0) + { + return PasswordVerificationResult.Failed; + } + switch (decodedHashedPassword[0]) + { + case 0x00: + if (VerifyHashedPasswordV2(decodedHashedPassword, providedPassword)) + { + // This is an old password hash format - the caller needs to rehash if we're not running in an older compat mode. + return (_compatibilityMode == PasswordHasherCompatibilityMode.IdentityV3) + ? PasswordVerificationResult.SuccessRehashNeeded + : PasswordVerificationResult.Success; + } + else + { + return PasswordVerificationResult.Failed; + } + + case 0x01: + return VerifyHashedPasswordV3(decodedHashedPassword, providedPassword) + ? PasswordVerificationResult.Success + : PasswordVerificationResult.Failed; + + default: + return PasswordVerificationResult.Failed; // unknown format marker + } + } + + private static bool VerifyHashedPasswordV2(byte[] hashedPassword, string password) + { + const KeyDerivationPrf Pbkdf2Prf = KeyDerivationPrf.Sha1; // default for Rfc2898DeriveBytes + const int Pbkdf2IterCount = 1000; // default for Rfc2898DeriveBytes + const int Pbkdf2SubkeyLength = 256 / 8; // 256 bits + const int SaltSize = 128 / 8; // 128 bits + + // We know ahead of time the exact length of a valid hashed password payload. + if (hashedPassword.Length != 1 + SaltSize + Pbkdf2SubkeyLength) + { + return false; // bad size + } + + byte[] salt = new byte[SaltSize]; + Buffer.BlockCopy(hashedPassword, 1, salt, 0, salt.Length); + + byte[] expectedSubkey = new byte[Pbkdf2SubkeyLength]; + Buffer.BlockCopy(hashedPassword, 1 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); + + // Hash the incoming password and verify it + byte[] actualSubkey = KeyDerivation.Pbkdf2(password, salt, Pbkdf2Prf, Pbkdf2IterCount, Pbkdf2SubkeyLength); + return ByteArraysEqual(actualSubkey, expectedSubkey); + } + + private static bool VerifyHashedPasswordV3(byte[] hashedPassword, string password) + { + try + { + // Read header information + KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(hashedPassword, 1); + int iterCount = (int)ReadNetworkByteOrder(hashedPassword, 5); + int saltLength = (int)ReadNetworkByteOrder(hashedPassword, 9); + + // Read the salt: must be >= 128 bits + if (saltLength < 128 / 8) + { + return false; + } + byte[] salt = new byte[saltLength]; + Buffer.BlockCopy(hashedPassword, 13, salt, 0, salt.Length); + + // Read the subkey (the rest of the payload): must be >= 128 bits + int subkeyLength = hashedPassword.Length - 13 - salt.Length; + if (subkeyLength < 128 / 8) + { + return false; + } + byte[] expectedSubkey = new byte[subkeyLength]; + Buffer.BlockCopy(hashedPassword, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); + + // Hash the incoming password and verify it + byte[] actualSubkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, subkeyLength); + return ByteArraysEqual(actualSubkey, expectedSubkey); + } + catch + { + // This should never occur except in the case of a malformed payload, where + // we might go off the end of the array. Regardless, a malformed payload + // implies verification failed. + return false; + } + } + + private static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value) + { + buffer[offset + 0] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)(value >> 0); } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/PasswordHasherCompatibilityMode.cs b/src/Microsoft.AspNet.Identity/PasswordHasherCompatibilityMode.cs new file mode 100644 index 0000000000..4dade7f693 --- /dev/null +++ b/src/Microsoft.AspNet.Identity/PasswordHasherCompatibilityMode.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Identity +{ + /// + /// An enum that describes the format used for hashing passwords. + /// + public enum PasswordHasherCompatibilityMode + { + /// + /// Hashes passwords in a way that is compatible with ASP.NET Identity versions 1 and 2. + /// + IdentityV2, + + /// + /// Hashes passwords in a way that is compatible with ASP.NET Identity version 3. + /// + IdentityV3 + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/PasswordHasherOptions.cs b/src/Microsoft.AspNet.Identity/PasswordHasherOptions.cs new file mode 100644 index 0000000000..5dccb93196 --- /dev/null +++ b/src/Microsoft.AspNet.Identity/PasswordHasherOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Cryptography; + +namespace Microsoft.AspNet.Identity +{ + /// + /// Allows configuring how passwords are hashed. + /// + public class PasswordHasherOptions + { + private static readonly RandomNumberGenerator _defaultRng = RandomNumberGenerator.Create(); // secure PRNG + + /// + /// Specifies the compatibility mode to use when hashing passwords. + /// + /// + /// The default compatibility mode is 'ASP.NET Identity version 3'. + /// + public PasswordHasherCompatibilityMode CompatibilityMode { get; set; } = PasswordHasherCompatibilityMode.IdentityV3; + + // for unit testing + internal RandomNumberGenerator Rng { get; set; } = _defaultRng; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Identity/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Identity/Properties/Resources.Designer.cs index 4bf5983229..26e7ca7330 100644 --- a/src/Microsoft.AspNet.Identity/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Identity/Properties/Resources.Designer.cs @@ -186,6 +186,22 @@ namespace Microsoft.AspNet.Identity return string.Format(CultureInfo.CurrentCulture, GetString("InvalidEmail"), p0); } + /// + /// The provided PasswordHasherCompatibilityMode is invalid. + /// + internal static string InvalidPasswordHasherCompatibilityMode + { + get { return GetString("InvalidPasswordHasherCompatibilityMode"); } + } + + /// + /// The provided PasswordHasherCompatibilityMode is invalid. + /// + internal static string FormatInvalidPasswordHasherCompatibilityMode() + { + return GetString("InvalidPasswordHasherCompatibilityMode"); + } + /// /// Invalid token. /// diff --git a/src/Microsoft.AspNet.Identity/Resources.resx b/src/Microsoft.AspNet.Identity/Resources.resx index 56f3d202d2..aad5e2d1e8 100644 --- a/src/Microsoft.AspNet.Identity/Resources.resx +++ b/src/Microsoft.AspNet.Identity/Resources.resx @@ -161,6 +161,10 @@ Email '{0}' is invalid. invalid email + + The provided PasswordHasherCompatibilityMode is invalid. + Error when the password hasher doesn't understand the format it's being asked to produce. + Invalid token. Error when a token is not recognized diff --git a/src/Microsoft.AspNet.Identity/project.json b/src/Microsoft.AspNet.Identity/project.json index 0e8a569a52..0ef1603874 100644 --- a/src/Microsoft.AspNet.Identity/project.json +++ b/src/Microsoft.AspNet.Identity/project.json @@ -29,9 +29,9 @@ "System.Runtime": "4.0.20-beta-*", "System.Runtime.Extensions": "4.0.10-beta-*", "System.Security.Claims": "4.0.0-beta-*", - "System.Security.Cryptography.DeriveBytes": "4.0.0-beta-*", "System.Security.Cryptography.Hashing": "4.0.0-beta-*", "System.Security.Cryptography.Hashing.Algorithms": "4.0.0-beta-*", + "System.Security.Cryptography.RandomNumberGenerator": "4.0.0-beta-*", "System.Security.Principal": "4.0.0-beta-*", "System.Text.Encoding": "4.0.10-beta-*", "System.Text.Encoding.Extensions": "4.0.10-beta-*", diff --git a/test/Microsoft.AspNet.Identity.Test/IdentityBuilderTest.cs b/test/Microsoft.AspNet.Identity.Test/IdentityBuilderTest.cs index 5ce7b7ce57..c04b73efd6 100644 --- a/test/Microsoft.AspNet.Identity.Test/IdentityBuilderTest.cs +++ b/test/Microsoft.AspNet.Identity.Test/IdentityBuilderTest.cs @@ -4,6 +4,7 @@ using System.Security.Claims; using Microsoft.Framework.DependencyInjection; using Microsoft.Framework.DependencyInjection.Fallback; +using Microsoft.Framework.OptionsModel; using Xunit; namespace Microsoft.AspNet.Identity.Test @@ -31,7 +32,7 @@ namespace Microsoft.AspNet.Identity.Test [Fact] public void CanSpecifyPasswordHasherInstance() { - CanOverride>(new PasswordHasher()); + CanOverride>(new PasswordHasher(new PasswordHasherOptionsAccessor())); } [Fact] @@ -39,6 +40,7 @@ namespace Microsoft.AspNet.Identity.Test { var services = new ServiceCollection(); services.AddIdentity(); + services.Add(OptionsServices.GetDefaultServices()); var provider = services.BuildServiceProvider(); var userValidator = provider.GetRequiredService>() as UserValidator; diff --git a/test/Microsoft.AspNet.Identity.Test/PasswordHasherTest.cs b/test/Microsoft.AspNet.Identity.Test/PasswordHasherTest.cs new file mode 100644 index 0000000000..fa86312974 --- /dev/null +++ b/test/Microsoft.AspNet.Identity.Test/PasswordHasherTest.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Security.Cryptography; +using Microsoft.Framework.OptionsModel; +using Xunit; + +namespace Microsoft.AspNet.Identity.Test +{ + public class PasswordHasherTest + { + [Fact] + public void Ctor_InvalidCompatMode_Throws() + { + // Act & assert + var ex = Assert.Throws(() => + { + new PasswordHasher(compatMode: (PasswordHasherCompatibilityMode)(-1)); + }); + Assert.Equal("The provided PasswordHasherCompatibilityMode is invalid.", ex.Message); + } + + [Theory] + [InlineData(PasswordHasherCompatibilityMode.IdentityV2)] + [InlineData(PasswordHasherCompatibilityMode.IdentityV3)] + public void FullRoundTrip(PasswordHasherCompatibilityMode compatMode) + { + // Arrange + var hasher = new PasswordHasher(compatMode: compatMode); + + // Act & assert - success case + var hashedPassword = hasher.HashPassword(null, "password 1"); + var successResult = hasher.VerifyHashedPassword(null, hashedPassword, "password 1"); + Assert.Equal(PasswordVerificationResult.Success, successResult); + + // Act & assert - failure case + var failedResult = hasher.VerifyHashedPassword(null, hashedPassword, "password 2"); + Assert.Equal(PasswordVerificationResult.Failed, failedResult); + } + + [Fact] + public void HashPassword_DefaultsToVersion3() + { + // Arrange + var hasher = new PasswordHasher(compatMode: null); + + // Act + string retVal = hasher.HashPassword(null, "my password"); + + // Assert + Assert.Equal("AQAAAAEAACcQAAAAEAABAgMEBQYHCAkKCwwNDg+yWU7rLgUwPZb1Itsmra7cbxw2EFpwpVFIEtP+JIuUEw==", retVal); + } + + [Fact] + public void HashPassword_Version2() + { + // Arrange + var hasher = new PasswordHasher(compatMode: PasswordHasherCompatibilityMode.IdentityV2); + + // Act + string retVal = hasher.HashPassword(null, "my password"); + + // Assert + Assert.Equal("AAABAgMEBQYHCAkKCwwNDg+ukCEMDf0yyQ29NYubggHIVY0sdEUfdyeM+E1LtH1uJg==", retVal); + } + + [Fact] + public void HashPassword_Version3() + { + // Arrange + var hasher = new PasswordHasher(compatMode: PasswordHasherCompatibilityMode.IdentityV3); + + // Act + string retVal = hasher.HashPassword(null, "my password"); + + // Assert + Assert.Equal("AQAAAAEAACcQAAAAEAABAgMEBQYHCAkKCwwNDg+yWU7rLgUwPZb1Itsmra7cbxw2EFpwpVFIEtP+JIuUEw==", retVal); + } + + [Theory] + // Version 2 payloads + [InlineData("AAABAgMEBQYHCAkKCwwNDg+uAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALtH1uJg==")] // incorrect password + [InlineData("AAABAgMEBQYHCAkKCwwNDg+ukCEMDf0yyQ29NYubggE=")] // too short + [InlineData("AAABAgMEBQYHCAkKCwwNDg+ukCEMDf0yyQ29NYubggHIVY0sdEUfdyeM+E1LtH1uJgAAAAAAAAAAAAA=")] // extra data at end + // Version 3 payloads + [InlineData("AQAAAAAAAAD6AAAAEAhftMyfTJyAAAAAAAAAAAAAAAAAAAih5WsjXaR3PA9M")] // incorrect password + [InlineData("AQAAAAIAAAAyAAAAEOMwvh3+FZxqkdMBz2ekgGhwQ4A=")] // too short + [InlineData("AQAAAAIAAAAyAAAAEOMwvh3+FZxqkdMBz2ekgGhwQ4B6pZWND6zgESBuWiHwAAAAAAAAAAAA")] // extra data at end + public void VerifyHashedPassword_FailureCases(string hashedPassword) + { + // Arrange + var hasher = new PasswordHasher(); + + // Act + var result = hasher.VerifyHashedPassword(null, hashedPassword, "my password"); + + // Assert + Assert.Equal(PasswordVerificationResult.Failed, result); + } + + [Theory] + // Version 2 payloads + [InlineData("ANXrDknc7fGPpigibZXXZFMX4aoqz44JveK6jQuwY3eH/UyPhvr5xTPeGYEckLxz9A==")] // SHA1, 1000 iterations, 128-bit salt, 256-bit subkey + // Version 3 payloads + [InlineData("AQAAAAIAAAAyAAAAEOMwvh3+FZxqkdMBz2ekgGhwQ4B6pZWND6zgESBuWiHw")] // SHA512, 50 iterations, 128-bit salt, 128-bit subkey + [InlineData("AQAAAAIAAAD6AAAAIJbVi5wbMR+htSfFp8fTw8N8GOS/Sje+S/4YZcgBfU7EQuqv4OkVYmc4VJl9AGZzmRTxSkP7LtVi9IWyUxX8IAAfZ8v+ZfhjCcudtC1YERSqE1OEdXLW9VukPuJWBBjLuw==")] // SHA512, 250 iterations, 256-bit salt, 512-bit subkey + [InlineData("AQAAAAAAAAD6AAAAEAhftMyfTJylOlZT+eEotFXd1elee8ih5WsjXaR3PA9M")] // SHA1, 250 iterations, 128-bit salt, 128-bit subkey + [InlineData("AQAAAAEAA9CQAAAAIESkQuj2Du8Y+kbc5lcN/W/3NiAZFEm11P27nrSN5/tId+bR1SwV8CO1Jd72r4C08OLvplNlCDc3oQZ8efcW+jQ=")] // SHA256, 250000 iterations, 256-bit salt, 256-bit subkey + public void VerifyHashedPassword_Version2CompatMode_SuccessCases(string hashedPassword) + { + // Arrange + var hasher = new PasswordHasher(compatMode: PasswordHasherCompatibilityMode.IdentityV2); + + // Act + var result = hasher.VerifyHashedPassword(null, hashedPassword, "my password"); + + // Assert + Assert.Equal(PasswordVerificationResult.Success, result); + } + + [Theory] + // Version 2 payloads + [InlineData("ANXrDknc7fGPpigibZXXZFMX4aoqz44JveK6jQuwY3eH/UyPhvr5xTPeGYEckLxz9A==", PasswordVerificationResult.SuccessRehashNeeded)] // SHA1, 1000 iterations, 128-bit salt, 256-bit subkey + // Version 3 payloads + [InlineData("AQAAAAIAAAAyAAAAEOMwvh3+FZxqkdMBz2ekgGhwQ4B6pZWND6zgESBuWiHw", PasswordVerificationResult.Success)] // SHA512, 50 iterations, 128-bit salt, 128-bit subkey + [InlineData("AQAAAAIAAAD6AAAAIJbVi5wbMR+htSfFp8fTw8N8GOS/Sje+S/4YZcgBfU7EQuqv4OkVYmc4VJl9AGZzmRTxSkP7LtVi9IWyUxX8IAAfZ8v+ZfhjCcudtC1YERSqE1OEdXLW9VukPuJWBBjLuw==", PasswordVerificationResult.Success)] // SHA512, 250 iterations, 256-bit salt, 512-bit subkey + [InlineData("AQAAAAAAAAD6AAAAEAhftMyfTJylOlZT+eEotFXd1elee8ih5WsjXaR3PA9M", PasswordVerificationResult.Success)] // SHA1, 250 iterations, 128-bit salt, 128-bit subkey + [InlineData("AQAAAAEAA9CQAAAAIESkQuj2Du8Y+kbc5lcN/W/3NiAZFEm11P27nrSN5/tId+bR1SwV8CO1Jd72r4C08OLvplNlCDc3oQZ8efcW+jQ=", PasswordVerificationResult.Success)] // SHA256, 250000 iterations, 256-bit salt, 256-bit subkey + public void VerifyHashedPassword_Version3CompatMode_SuccessCases(string hashedPassword, PasswordVerificationResult expectedResult) + { + // Arrange + var hasher = new PasswordHasher(compatMode: PasswordHasherCompatibilityMode.IdentityV3); + + // Act + var actualResult = hasher.VerifyHashedPassword(null, hashedPassword, "my password"); + + // Assert + Assert.Equal(expectedResult, actualResult); + } + + private sealed class PasswordHasher : PasswordHasher + { + public PasswordHasher(PasswordHasherCompatibilityMode? compatMode = null) + : base(BuildOptions(compatMode)) + { + } + + private static IOptions BuildOptions(PasswordHasherCompatibilityMode? compatMode) + { + var options = new PasswordHasherOptionsAccessor(); + if (compatMode != null) + { + options.Options.CompatibilityMode = (PasswordHasherCompatibilityMode)compatMode; + } + Assert.NotNull(options.Options.Rng); // should have a default value + options.Options.Rng = new SequentialRandomNumberGenerator(); + return options; + } + } + + private sealed class SequentialRandomNumberGenerator : RandomNumberGenerator + { + private byte _value; + + public override void GetBytes(byte[] data) + { + for (int i = 0; i < data.Length; i++) + { + data[i] = _value++; + } + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs b/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs index 7bf2214d3f..35ae3c1cac 100644 --- a/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs +++ b/test/Microsoft.AspNet.Identity.Test/UserManagerTest.cs @@ -518,7 +518,7 @@ namespace Microsoft.AspNet.Identity.Test { var store = new NotImplementedStore(); var optionsAccessor = new OptionsManager(null); - var passwordHasher = new PasswordHasher(); + var passwordHasher = new PasswordHasher(new PasswordHasherOptionsAccessor()); var userValidator = new UserValidator(); var passwordValidator = new PasswordValidator(); diff --git a/test/Shared/MockHelpers.cs b/test/Shared/MockHelpers.cs index 01d5acc89c..ff52d292ae 100644 --- a/test/Shared/MockHelpers.cs +++ b/test/Shared/MockHelpers.cs @@ -37,7 +37,7 @@ namespace Microsoft.AspNet.Identity.Test return new Mock>( store.Object, options, - new PasswordHasher(), + new PasswordHasher(new PasswordHasherOptionsAccessor()), new UserValidator(), new PasswordValidator(), new UpperInvariantUserNameNormalizer(), @@ -59,7 +59,7 @@ namespace Microsoft.AspNet.Identity.Test { var options = new OptionsManager(null); var validator = new Mock>(); - var userManager = new UserManager(store, options, new PasswordHasher(), + var userManager = new UserManager(store, options, new PasswordHasher(new PasswordHasherOptionsAccessor()), validator.Object, new PasswordValidator(), new UpperInvariantUserNameNormalizer(), null); validator.Setup(v => v.ValidateAsync(userManager, It.IsAny(), CancellationToken.None)) .Returns(Task.FromResult(IdentityResult.Success)).Verifiable(); diff --git a/test/Shared/PasswordHasherOptionsAccessor.cs b/test/Shared/PasswordHasherOptionsAccessor.cs new file mode 100644 index 0000000000..b147ba25aa --- /dev/null +++ b/test/Shared/PasswordHasherOptionsAccessor.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Framework.OptionsModel; + +namespace Microsoft.AspNet.Identity.Test +{ + internal class PasswordHasherOptionsAccessor : IOptions + { + public PasswordHasherOptions Options { get; } = new PasswordHasherOptions(); + + public PasswordHasherOptions GetNamedOptions(string name) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file