using System.Net; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Xunit; namespace Azaion.E2E.Tests; // AZ-536 — Argon2id password hashing + lazy migration of legacy SHA-384 hashes. [Collection("E2E")] public sealed class PasswordHashingTests { private static readonly JsonSerializerOptions ResponseJsonOptions = new() { PropertyNameCaseInsensitive = true }; private sealed record ErrorResponse(int ErrorCode, string Message); private sealed record LoginOkResponse(string Token); private readonly TestFixture _fixture; public PasswordHashingTests(TestFixture fixture) => _fixture = fixture; [Fact] public async Task AC1_New_user_password_hash_uses_argon2id_phc_format() { // Arrange var email = $"pwd-{Guid.NewGuid():N}@hashtest.example.com"; const string password = "FreshPassword123!"; try { using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); using var register = await admin.PostAsync("/users", new { email, password, role = 10 }); register.IsSuccessStatusCode.Should().BeTrue(); // Act var stored = await _fixture.Db.GetPasswordHash(email); // Assert stored.Should().NotBeNullOrEmpty(); stored!.Should().StartWith("$argon2id$v=19$m="); // Parse PHC params and verify they meet RFC 9106 conservative bounds. var paramSegment = stored!.Split('$')[3].Split(','); int.Parse(paramSegment[0][2..]).Should().BeGreaterOrEqualTo(65536, "memory ≥ 64 MiB"); int.Parse(paramSegment[1][2..]).Should().BeGreaterOrEqualTo(3, "iterations ≥ 3"); int.Parse(paramSegment[2][2..]).Should().BeGreaterOrEqualTo(1, "parallelism ≥ 1"); } finally { await _fixture.Db.DeleteUser(email); } } [Fact] public async Task AC2_AC3_Legacy_sha384_hash_validates_then_transparently_rehashes() { // Arrange var email = $"legacy-{Guid.NewGuid():N}@hashtest.example.com"; const string password = "LegacyUserPwd99!"; var sha384Hash = Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(password))); try { await _fixture.Db.SeedLegacyShaUser(Guid.NewGuid(), email, sha384Hash); (await _fixture.Db.GetPasswordHash(email)).Should().Be(sha384Hash, "preconditions"); using var client = _fixture.CreateApiClient(); // Act using var login = await client.PostAsync("/login", new { email, password }); // Assert AC-2 — login succeeds against the legacy hash login.StatusCode.Should().Be(HttpStatusCode.OK); var body = await login.Content.ReadFromJsonAsync(ResponseJsonOptions); body!.Token.Should().NotBeNullOrWhiteSpace(); // Assert AC-3 — the row is now stored as Argon2id var afterLogin = await _fixture.Db.GetPasswordHash(email); afterLogin.Should().StartWith("$argon2id$v=19$m="); // The same plaintext keeps validating after the rehash using var login2 = await client.PostAsync("/login", new { email, password }); login2.StatusCode.Should().Be(HttpStatusCode.OK); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } [Fact] public async Task AC4_Wrong_password_fails_for_both_hash_formats() { // Arrange var legacyEmail = $"legacy-wrong-{Guid.NewGuid():N}@hashtest.example.com"; var argon2Email = $"argon2-wrong-{Guid.NewGuid():N}@hashtest.example.com"; const string correct = "CorrectPwd123!"; const string wrong = "WrongPwd456?"; var sha384Hash = Convert.ToBase64String(SHA384.HashData(Encoding.UTF8.GetBytes(correct))); try { await _fixture.Db.SeedLegacyShaUser(Guid.NewGuid(), legacyEmail, sha384Hash); using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); using var register = await admin.PostAsync("/users", new { email = argon2Email, password = correct, role = 10 }); register.IsSuccessStatusCode.Should().BeTrue(); using var client = _fixture.CreateApiClient(); // Act using var legacyResp = await client.PostAsync("/login", new { email = legacyEmail, password = wrong }); using var argon2Resp = await client.PostAsync("/login", new { email = argon2Email, password = wrong }); // Assert foreach (var resp in new[] { legacyResp, argon2Resp }) { resp.StatusCode.Should().Be(HttpStatusCode.Conflict); var err = await resp.Content.ReadFromJsonAsync(ResponseJsonOptions); err!.ErrorCode.Should().Be(30, "WrongPassword == 30"); } } finally { await _fixture.Db.DeleteAuditEventsFor(legacyEmail); await _fixture.Db.DeleteAuditEventsFor(argon2Email); await _fixture.Db.DeleteUser(legacyEmail); await _fixture.Db.DeleteUser(argon2Email); } } [Fact] public async Task AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak() { // Arrange — register a user with a known Argon2id hash, then time many wrong // attempts of varying lengths. Argon2's per-call cost (≈ 50-200 ms with m=64MiB) // dominates any byte-compare timing, which is exactly what the constant-time // verify guarantees. We assert the spread is a small fraction of the mean — // a true plaintext-prefix oracle would show wildly different means per length. var email = $"timing-{Guid.NewGuid():N}@hashtest.example.com"; const string correct = "TimingProbe2026!"; try { using var admin = _fixture.CreateAuthenticatedClient(_fixture.AdminToken); (await admin.PostAsync("/users", new { email, password = correct, role = 10 })) .IsSuccessStatusCode.Should().BeTrue(); using var client = _fixture.CreateApiClient(); // Warmup so JIT + connection pool don't skew the first measurements. // Reset failure state after each warmup so the per-account rate limit // (5 fails / 5 min) doesn't fire during the timed loop below. for (var i = 0; i < 2; i++) { await client.PostAsync("/login", new { email, password = "warmup-bad" }); await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 0); await _fixture.Db.DeleteAuditEventsFor(email); } var samples = new List<(int Len, double Ms)>(); int[] lengths = [1, 8, 32]; foreach (var len in lengths) { var pwd = new string('x', len); for (var i = 0; i < 3; i++) { // Per-account rate limit counts failed-login audit events; clearing // them between samples keeps the limiter from short-circuiting the // login path (which would skew the timing measurement we care about). await _fixture.Db.SetLockoutUntil(email, lockoutUntilUtc: null, failedCount: 0); await _fixture.Db.DeleteAuditEventsFor(email); var sw = System.Diagnostics.Stopwatch.StartNew(); using var r = await client.PostAsync("/login", new { email, password = pwd }); sw.Stop(); r.StatusCode.Should().Be(HttpStatusCode.Conflict, "wrong password"); samples.Add((len, sw.Elapsed.TotalMilliseconds)); } } // Assert var perLengthMean = samples .GroupBy(s => s.Len) .Select(g => new { Len = g.Key, Mean = g.Average(s => s.Ms) }) .ToList(); var overallMean = samples.Average(s => s.Ms); // Spread between per-length means must be < 50% of overall mean. Argon2's // dominant cost is constant per call, so all means cluster tightly. var max = perLengthMean.Max(p => p.Mean); var min = perLengthMean.Min(p => p.Mean); (max - min).Should().BeLessThan(overallMean * 0.5, $"per-length means {string.Join(", ", perLengthMean.Select(p => $"len={p.Len}:{p.Mean:F0}ms"))} should not vary with password length (overall mean {overallMean:F0}ms)"); } finally { await _fixture.Db.DeleteAuditEventsFor(email); await _fixture.Db.DeleteUser(email); } } }