Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 9 additions & 3 deletions h.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="3.1.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
<!-- KeyDerivation gives us PBKDF2; Identity.Core gives us -->
<!-- PasswordHasherOptions/CompatibilityMode used by the vendored -->
<!-- PasswordHasher. The full Microsoft.AspNetCore.Identity package -->
<!-- pulls a much heavier transitive graph (incl. a vulnerable -->
<!-- System.Security.Cryptography.Xml) we don't need. -->
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.*" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.*" />
</ItemGroup>

</Project>
24 changes: 0 additions & 24 deletions test/Tests.cs.txt

This file was deleted.

125 changes: 125 additions & 0 deletions test/h.Tests/PasswordHasherTests.cs
Original file line number Diff line number Diff line change
@@ -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 -- <password>`.

[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);
}
}
28 changes: 28 additions & 0 deletions test/h.Tests/h.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
</ItemGroup>

<!-- Compile the vendored PasswordHasher sources directly into the test -->
<!-- assembly. Avoids the awkwardness of taking a ProjectReference on an -->
<!-- Exe project, and means the tests exercise the exact same code. -->
<ItemGroup>
<Compile Include="..\..\PasswordHasher.cs" />
<Compile Include="..\..\PasswordHasherOptions.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="10.*" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.*" />
</ItemGroup>

</Project>