From a69cfad2968eb0c3c4bdd04fa921120a3b1b6cff Mon Sep 17 00:00:00 2001 From: "@royashbrook" Date: Fri, 1 May 2026 21:12:00 -0400 Subject: [PATCH 1/2] bump to net10; lighter Identity.Core dep (drops xml vuln) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TargetFramework: net7.0 -> net10.0 - Microsoft.AspNetCore.Cryptography.KeyDerivation: 3.1.5 -> 10.* - Microsoft.AspNetCore.Identity 2.2.0 -> Microsoft.Extensions.Identity.Core 10.* Microsoft.AspNetCore.Identity pulls in System.Security.Cryptography.Xml (known vulnerability NU1903 / GHSA-37gx-xxp4-5rgx + GHSA-w3x6-4m5h-cxqf) that we don't actually use. Microsoft.Extensions.Identity.Core gives us PasswordHasherOptions / PasswordHasherCompatibilityMode (which is all the vendored PasswordHasher.cs needs from Identity), without the heavy transitive graph. KeyDerivation handles the PBKDF2 itself. Built clean (0 warnings, 0 errors) and verified roundtrip: $ h test123 # -> AQAAAAEAACcQ... $ h test123 # -> True $ h wrongpass # -> False Vendored PasswordHasher behavior unchanged (still v3 SHA256/10000) — this is a build/runtime bump only, no crypto changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- h.csproj | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/h.csproj b/h.csproj index 8be2e43..a7170dd 100644 --- a/h.csproj +++ b/h.csproj @@ -2,12 +2,18 @@ Exe - net7.0 + net10.0 + enable - - + + + + + + + From 8bd535b38c5917c27f247e13f50374d06a73189b Mon Sep 17 00:00:00 2001 From: "@royashbrook" Date: Fri, 1 May 2026 21:32:45 -0400 Subject: [PATCH 2/2] add xUnit test project + CI workflow; remove broken stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the disabled `test/Tests.cs.txt` (which asserted deterministic hashes from a non-deterministic function — couldn't have ever passed) with a real test/h.Tests project that exercises: - format/shape: 84-char base64, v3 marker prefix, varying salts - roundtrip: hash + verify across ASCII / symbols / unicode - known-good regression fixtures (verifying tamper-resistance) - tampered-hash failure cases - cross-tool compat: pwsh-generated hashes (matching format spec) must verify against this build — pins the format as our public contract for any external generator (e.g. frtl/frtl-bc's Get-PasswordHash.ps1) Test project compiles the vendored PasswordHasher sources directly rather than ProjectReference-ing the Exe — exercises the same code, avoids the awkwardness of taking a reference on an Exe project. Plus a minimal CI workflow (.github/workflows/ci.yml) runs `dotnet test` on push to main and every PR, so the next contributor gets a green/red signal automatically. $ dotnet test test/h.Tests Passed! - Failed: 0, Passed: 21, Skipped: 0 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 20 +++++ test/Tests.cs.txt | 24 ------ test/h.Tests/PasswordHasherTests.cs | 125 ++++++++++++++++++++++++++++ test/h.Tests/h.Tests.csproj | 28 +++++++ 4 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 test/Tests.cs.txt create mode 100644 test/h.Tests/PasswordHasherTests.cs create mode 100644 test/h.Tests/h.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25130f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +# Run xUnit tests on every push to main and every PR. +# Keeps the door open for "next submitter has something to pass". + +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + - name: dotnet test + run: dotnet test test/h.Tests --logger "console;verbosity=normal" diff --git a/test/Tests.cs.txt b/test/Tests.cs.txt deleted file mode 100644 index 1aed40a..0000000 --- a/test/Tests.cs.txt +++ /dev/null @@ -1,24 +0,0 @@ -using Xunit; -using Microsoft.AspNetCore.Identity; - -namespace CLI_PasswordHasher.Tests -{ - public class Tests - { - private readonly PasswordHasher _hasher = new PasswordHasher(); - - [Theory] - [InlineData("hi", "AQAAAAEAACcQAAAAEJR3fvNllNdONXc2EKkLnB3oIQvGXVISeu+Bz3ng8XOeGroNKXmgn3sDHt/s//Hoag==")] - [InlineData("hello", "AQAAAAEAACcQAAAAEKiv0T9vTMrPweYZgdVnTHjvMs5Ud1yXsH+WNXS++HqwrUKrISOgS1DHct0D4irr4Q==")] - [InlineData("Password123", "AQAAAAEAACcQAAAAEKnmf3ANd9NRuio6XM0APsaOvRfh/GC6xj3LPHeX7meBfFXwZ9bmfSjbZUSDxVWekQ==")] - [InlineData("alongertestpassword", "AQAAAAEAACcQAAAAEBJOcxUBL47kXxH/YdFD5j1zfkxu3U9sYFpTVPITLxq8LdfcLT4NlGtpJnLRPL8m7A==")] - public void TestPasswordHashing(string value, string expectedOutput) - { - // act - var result = _hasher.HashPassword(null, value); - - // assert - Assert.Equal(expectedOutput, result); - } - } -} diff --git a/test/h.Tests/PasswordHasherTests.cs b/test/h.Tests/PasswordHasherTests.cs new file mode 100644 index 0000000..1a66b3d --- /dev/null +++ b/test/h.Tests/PasswordHasherTests.cs @@ -0,0 +1,125 @@ +using Xunit; +using Microsoft.AspNetCore.Identity; + +namespace h.Tests; + +public class PasswordHasherTests +{ + private readonly PasswordHasher _hasher = new(); + + // -- Format / shape ------------------------------------------------------ + + [Theory] + [InlineData("hi")] + [InlineData("hello")] + [InlineData("Password123")] + [InlineData("alongertestpassword")] + [InlineData("p@ssw0rd!#$%^&*()")] + [InlineData("a")] // single char + public void HashPassword_ProducesV3FormatString(string password) + { + var hash = _hasher.HashPassword(password); + + Assert.NotNull(hash); + Assert.NotEmpty(hash); + // v3 layout: [marker(1) + prf(4) + iter(4) + saltLen(4) + salt(16) + subkey(32)] = 61 bytes + // base64: 4 * ceil(61/3) = 84 chars (no padding stripped here) + Assert.Equal(84, hash.Length); + // version 3 marker is 0x01 — base64 of bytes starting with 0x01 always begins with 'A' + Assert.StartsWith("AQ", hash); + } + + [Fact] + public void HashPassword_SameInput_ProducesDifferentOutputs() + { + // Salts must be random, so consecutive hashes of the same password + // must differ. If they're equal something is badly wrong. + var hash1 = _hasher.HashPassword("test"); + var hash2 = _hasher.HashPassword("test"); + + Assert.NotEqual(hash1, hash2); + } + + // -- Roundtrip ----------------------------------------------------------- + + [Theory] + [InlineData("hi")] + [InlineData("hello")] + [InlineData("Password123")] + [InlineData("alongertestpassword")] + [InlineData("p@ssw0rd!#$%^&*()")] + [InlineData("éèê")] // unicode (é è ê) + public void HashThenVerify_Succeeds(string password) + { + var hash = _hasher.HashPassword(password); + var result = _hasher.VerifyHashedPassword(hash, password); + + Assert.Equal(PasswordVerificationResult.Success, result); + } + + [Fact] + public void Verify_WrongPassword_Fails() + { + var hash = _hasher.HashPassword("correctpw"); + var result = _hasher.VerifyHashedPassword(hash, "wrongpw"); + + Assert.Equal(PasswordVerificationResult.Failed, result); + } + + [Fact] + public void Verify_EmptyProvidedPassword_Fails() + { + var hash = _hasher.HashPassword("correctpw"); + var result = _hasher.VerifyHashedPassword(hash, ""); + + Assert.Equal(PasswordVerificationResult.Failed, result); + } + + // -- Known-good fixtures (regression) ------------------------------------ + // + // These are concrete (password, hash) pairs that Verify must always + // accept. If the format ever drifts (e.g. an attempted "modernize the + // crypto defaults" PR that breaks backcompat), these break first. + // Hashes were minted by `dotnet run --project ../h -- `. + + [Theory] + [InlineData("hello", + "AQAAAAEAACcQAAAAEKiv0T9vTMrPweYZgdVnTHjvMs5Ud1yXsH+WNXS++HqwrUKrISOgS1DHct0D4irr4Q==")] + [InlineData("Password123", + "AQAAAAEAACcQAAAAEKnmf3ANd9NRuio6XM0APsaOvRfh/GC6xj3LPHeX7meBfFXwZ9bmfSjbZUSDxVWekQ==")] + [InlineData("alongertestpassword", + "AQAAAAEAACcQAAAAEBJOcxUBL47kXxH/YdFD5j1zfkxu3U9sYFpTVPITLxq8LdfcLT4NlGtpJnLRPL8m7A==")] + public void Verify_KnownGoodHashes_Succeed(string password, string hash) + { + var result = _hasher.VerifyHashedPassword(hash, password); + Assert.Equal(PasswordVerificationResult.Success, result); + } + + [Theory] + [InlineData("hello", + "AQAAAAEAACcQAAAAEKiv0T9vTMrPweYZgdVnTHjvMs5Ud1yXsH+WNXS++HqwrUKrISOgS1DHcBADBADBAD==")] + [InlineData("Password123", + "AQAAAAEAACcQAAAAEKnmf3ANd9NRuio6XM0APsaOvRfh/GC6xj3LPHeX7meBfFXwZ9bmfSjbZBADBADBAD==")] + public void Verify_TamperedHashes_Fail(string password, string tamperedHash) + { + var result = _hasher.VerifyHashedPassword(tamperedHash, password); + Assert.Equal(PasswordVerificationResult.Failed, result); + } + + // -- Cross-tool compat --------------------------------------------------- + // + // Hashes minted by Get-PasswordHash.ps1 (the pwsh script in + // frtl/frtl-bc) MUST verify here. This test fixes the v3 byte format + // as our public contract — anything generating an Identity v3 hash + // (matching prf=HMACSHA256, iter=10000, salt=16, subkey=32) must + // verify against the same `h` build. + + [Theory] + [InlineData("test123", + "AQAAAAEAACcQAAAAEAzkO99J3wHwgIJGX1bRz7Hd2vRMx/wiAttZEOGJHv0/k6qi/ZqVjHKx+tkWl4i4xg==")] + public void Verify_PwshGeneratedHash_Succeeds(string password, string pwshHash) + { + var result = _hasher.VerifyHashedPassword(pwshHash, password); + Assert.Equal(PasswordVerificationResult.Success, result); + } +} diff --git a/test/h.Tests/h.Tests.csproj b/test/h.Tests/h.Tests.csproj new file mode 100644 index 0000000..debfd07 --- /dev/null +++ b/test/h.Tests/h.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + false + + + + + + + + + + + + + + + + + + + + + +