From c7ff0d261840d35fc87d8d2a224ea3a5459883e5 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Sun, 4 Oct 2026 12:40:37 +0200 Subject: [PATCH] BCrypt.Net-Next: retarget to .NET 10, bump to 4.2.0, add rehash and compatibility tests Both projects move from net7.0 to net10.0. BCrypt.Net-Next goes from 4.0.3 to 4.2.0 and the test stack is bumped. New tests cover PasswordNeedsRehash, InterrogateHash, a PHP-generated $2y$ hash, the accepted revisions, enhanced entropy against plain Verify, the 72-byte truncation, the work factor range, and the LoginAndUpgrade method that now lives in PasswordLoginService. Program.cs hashes, verifies and upgrades a password instead of printing a placeholder. --- .../HowToSecurePasswordsWithBCryptNET.csproj | 4 +- .../PasswordLoginService.cs | 16 ++ .../Program.cs | 11 +- .../Test/BCryptTests.cs | 152 +++++++++++++++++- .../Test/Test.csproj | 16 +- 5 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/PasswordLoginService.cs diff --git a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET.csproj b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET.csproj index 6939f4cac9..eb7f91f71b 100644 --- a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET.csproj +++ b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET.csproj @@ -2,13 +2,13 @@ Exe - net7.0 + net10.0 enable enable - + diff --git a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/PasswordLoginService.cs b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/PasswordLoginService.cs new file mode 100644 index 0000000000..a9090653dc --- /dev/null +++ b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/PasswordLoginService.cs @@ -0,0 +1,16 @@ +namespace HowToSecurePasswordsWithBCryptNET; + +public class PasswordLoginService +{ + public const int CurrentWorkFactor = 11; + + public string? LoginAndUpgrade(string password, string storedHash) + { + if (!BCrypt.Net.BCrypt.Verify(password, storedHash)) + return null; + + return BCrypt.Net.BCrypt.PasswordNeedsRehash(storedHash, CurrentWorkFactor) + ? BCrypt.Net.BCrypt.HashPassword(password, CurrentWorkFactor) + : null; + } +} diff --git a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/Program.cs b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/Program.cs index 49f0e1b539..813adbf4c3 100644 --- a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/Program.cs +++ b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/HowToSecurePasswordsWithBCryptNET/Program.cs @@ -1,2 +1,9 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Please, check test project for examples."); +using HowToSecurePasswordsWithBCryptNET; + +var passwordHash = BCrypt.Net.BCrypt.HashPassword("Password123!"); +Console.WriteLine($"Hash: {passwordHash}"); +Console.WriteLine($"Verified: {BCrypt.Net.BCrypt.Verify("Password123!", passwordHash)}"); + +var oldHash = BCrypt.Net.BCrypt.HashPassword("Password123!", workFactor: 6); +var upgradedHash = new PasswordLoginService().LoginAndUpgrade("Password123!", oldHash); +Console.WriteLine($"Upgraded hash: {upgradedHash}"); diff --git a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/BCryptTests.cs b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/BCryptTests.cs index 6e3f0affc6..7ab1bf4a28 100644 --- a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/BCryptTests.cs +++ b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/BCryptTests.cs @@ -1,6 +1,7 @@ namespace Test; using BCrypt.Net; +using HowToSecurePasswordsWithBCryptNET; public class BCryptTests { @@ -57,4 +58,153 @@ public void WhenHashingPasswordWithHigherWorkFactor_ThenReturnsNotNullString() Assert.NotNull(passwordHash); } -} \ No newline at end of file + + [Fact] + public void WhenWorkFactorBelowMinimum_ThenPasswordNeedsRehashReturnsTrue() + { + var passwordHash = BCrypt.HashPassword("Password123!", workFactor: 6); + + var result = BCrypt.PasswordNeedsRehash(passwordHash, 11); + + Assert.True(result); + } + + [Fact] + public void WhenWorkFactorAtMinimum_ThenPasswordNeedsRehashReturnsFalse() + { + var passwordHash = BCrypt.HashPassword("Password123!", workFactor: 11); + + var result = BCrypt.PasswordNeedsRehash(passwordHash, 11); + + Assert.False(result); + } + + [Fact] + public void WhenInterrogatingHash_ThenReturnsVersionAndWorkFactor() + { + var passwordHash = BCrypt.HashPassword("Password123!"); + + var info = BCrypt.InterrogateHash(passwordHash); + + Assert.Equal("2a", info.Version); + Assert.Equal("11", info.WorkFactor); + Assert.Equal("$2a$11", info.Settings); + Assert.Equal(60, passwordHash.Length); + } + + [Fact] + public void WhenVerifyingPhpGenerated2yHash_ThenVerificationSucceeds() + { + // The password_verify() example from the PHP manual. + const string phpHash = "$2y$10$.vGA1O9wmRjrwAVXD98HNOgsNpDczlqm3Jq7KnEd1rVAGv3Fykk1a"; + + Assert.True(BCrypt.Verify("rasmuslerdorf", phpHash)); + Assert.False(BCrypt.Verify("Wr0ngPassword!", phpHash)); + } + + [Theory] + [InlineData('a')] + [InlineData('b')] + [InlineData('x')] + [InlineData('y')] + public void WhenUsingSupportedRevision_ThenHashVerifies(char revision) + { + var salt = BCrypt.GenerateSalt(6, revision); + var passwordHash = BCrypt.HashPassword("Password123!", salt); + + Assert.StartsWith($"$2{revision}$", passwordHash); + Assert.True(BCrypt.Verify("Password123!", passwordHash)); + } + + [Fact] + public void WhenVerifyingUnsupportedRevision_ThenThrowsSaltParseException() + { + const string hash = "$2c$10$.vGA1O9wmRjrwAVXD98HNOgsNpDczlqm3Jq7KnEd1rVAGv3Fykk1a"; + + Assert.Throws(() => BCrypt.Verify("rasmuslerdorf", hash)); + } + + [Fact] + public void WhenVerifyingEnhancedHashWithPlainVerify_ThenVerificationFails() + { + var passwordHash = BCrypt.EnhancedHashPassword("Password123!"); + + Assert.StartsWith("$2a$11$", passwordHash); + Assert.False(BCrypt.Verify("Password123!", passwordHash)); + Assert.True(BCrypt.EnhancedVerify("Password123!", passwordHash)); + Assert.False(BCrypt.EnhancedVerify("Password123!", passwordHash, HashType.SHA512)); + } + + [Fact] + public void WhenPasswordsShareFirst72Bytes_ThenEitherVerifiesAgainstEitherHash() + { + var prefix = new string('p', 72); + var firstPassword = prefix + "ONE"; + var secondPassword = prefix + "TWO"; + + var firstHash = BCrypt.HashPassword(firstPassword, workFactor: 6); + var secondHash = BCrypt.HashPassword(secondPassword, workFactor: 6); + + Assert.True(BCrypt.Verify(secondPassword, firstHash)); + Assert.True(BCrypt.Verify(firstPassword, secondHash)); + } + + [Fact] + public void WhenPasswordsShareFirst72Bytes_ThenEnhancedVerifyTellsThemApart() + { + var prefix = new string('p', 72); + var firstHash = BCrypt.EnhancedHashPassword(prefix + "ONE", workFactor: 6); + + Assert.False(BCrypt.EnhancedVerify(prefix + "TWO", firstHash)); + } + + [Theory] + [InlineData(3)] + [InlineData(32)] + public void WhenWorkFactorOutOfRange_ThenThrowsArgumentOutOfRangeException(int workFactor) + { + var exception = Assert.Throws( + () => BCrypt.HashPassword("Password123!", workFactor)); + + Assert.Equal("workFactor", exception.ParamName); + } + + [Fact] + public void WhenWorkFactorAtBounds_ThenSaltIsGenerated() + { + // Never hash at work factor 31: that is 2^31 rounds. The salt alone proves the bound. + Assert.StartsWith("$2a$04$", BCrypt.GenerateSalt(4)); + Assert.StartsWith("$2a$31$", BCrypt.GenerateSalt(31)); + } + + [Fact] + public void WhenStoredHashIsUnderCost_ThenLoginReturnsUpgradedHash() + { + var service = new PasswordLoginService(); + var storedHash = BCrypt.HashPassword("Password123!", workFactor: 6); + + var newHash = service.LoginAndUpgrade("Password123!", storedHash); + + Assert.NotNull(newHash); + Assert.StartsWith("$2a$11$", newHash); + Assert.True(BCrypt.Verify("Password123!", newHash)); + } + + [Fact] + public void WhenStoredHashIsCurrent_ThenLoginReturnsNull() + { + var service = new PasswordLoginService(); + var storedHash = BCrypt.HashPassword("Password123!", workFactor: 11); + + Assert.Null(service.LoginAndUpgrade("Password123!", storedHash)); + } + + [Fact] + public void WhenPasswordIsWrong_ThenLoginReturnsNull() + { + var service = new PasswordLoginService(); + var storedHash = BCrypt.HashPassword("Password123!", workFactor: 6); + + Assert.Null(service.LoginAndUpgrade("Wr0ngPassword!", storedHash)); + } +} diff --git a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/Test.csproj b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/Test.csproj index 2b842fdabd..b011d844ab 100644 --- a/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/Test.csproj +++ b/authorization-dotnet/HowToSecurePasswordsWithBCryptNET/Test/Test.csproj @@ -1,7 +1,7 @@ - net7.0 + net10.0 enable enable @@ -9,17 +9,21 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all + + + +