< Summary

Information
Class: Anichron.API.Security.Argon2PasswordHasher
Assembly: Anichron.API
File(s): /home/runner/work/anichron/anichron/src/Anichron.API/Security/PasswordHasher.cs
Tag: 228_36821334185
Line coverage
100%
Covered lines: 38
Uncovered lines: 0
Coverable lines: 38
Total lines: 85
Line coverage: 100%
Branch coverage
100%
Covered branches: 2
Total branches: 2
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Hash(...)100%11100%
Verify(...)100%22100%
RunArgon2idSecure(...)100%11100%
RunArgon2id(...)100%11100%

File(s)

/home/runner/work/anichron/anichron/src/Anichron.API/Security/PasswordHasher.cs

#LineLine coverage
 1using Anichron.API.Settings;
 2using Konscious.Security.Cryptography;
 3using System.Security.Cryptography;
 4using System.Text;
 5
 6namespace Anichron.API.Security;
 7
 8public interface IPasswordHasher
 9{
 10    string Hash(string password);
 11
 12    // storedHash is null when no such account exists. A null hash still costs a full Argon2
 13    // pass, so verification itself takes the same time either way. That removes the HASHING
 14    // asymmetry only — it does not make a caller's whole code path constant-time.
 15    bool Verify(string password, string? storedHash);
 16}
 17
 18public sealed class Argon2PasswordHasher : IPasswordHasher
 19{
 20    public string Hash(string password)
 321    {
 322        var salt = RandomNumberGenerator.GetBytes(AppDefaults.Argon2.SaltLength);
 323        var hash = RunArgon2idSecure(password, salt);
 24
 325        var combined = new byte[salt.Length + hash.Length];
 326        salt.CopyTo(combined, 0);
 327        hash.CopyTo(combined, salt.Length);
 328        return Convert.ToBase64String(combined);
 329    }
 30
 31    public bool Verify(string password, string? storedHash)
 832    {
 33        // ⛔ Equal work, not an early return. Verifying a missing account must cost the same
 34        // Argon2 pass as verifying a real one, so this method leaks nothing about account
 35        // existence. ⚠️ Whether the CALLER leaks it is the caller's problem — AuthService.Login
 36        // still does a database write for a known user that it skips for an unknown one.
 37        //
 38        // The invariant lives here because this class owns the cost. It used to live in an
 39        // AuthService field initializer, which — AuthService being Scoped — burned 64 MiB and
 40        // three Argon2 passes on every request that resolved IAuthService, including ones that
 41        // never read the value. See #173.
 42        //
 43        // ⚠️ `return false` unconditionally, rather than comparing against random bytes:
 44        // correctness must not rest on two random values differing. The FixedTimeEquals of the
 45        // real path is deliberately not mirrored here — it is microseconds against ~100 ms of
 46        // Argon2, so adding it back would buy nothing and reintroduce that dependency.
 847        if (storedHash is null)
 248        {
 249            var throwawaySalt = RandomNumberGenerator.GetBytes(AppDefaults.Argon2.SaltLength);
 250            CryptographicOperations.ZeroMemory(RunArgon2idSecure(password, throwawaySalt));
 251            return false;
 52        }
 53
 654        var combined = Convert.FromBase64String(storedHash);
 555        var salt = combined[..AppDefaults.Argon2.SaltLength];
 456        var expected = combined[AppDefaults.Argon2.SaltLength..];
 457        var actual = RunArgon2idSecure(password, salt);
 458        return CryptographicOperations.FixedTimeEquals(actual, expected);
 659    }
 60
 61    private static byte[] RunArgon2idSecure(string password, byte[] salt)
 962    {
 963        var passwordBytes = Encoding.UTF8.GetBytes(password);
 64        try
 965        {
 966            return RunArgon2id(passwordBytes, salt);
 67        }
 68        finally
 969        {
 970            CryptographicOperations.ZeroMemory(passwordBytes);
 971        }
 972    }
 73
 74    private static byte[] RunArgon2id(byte[] password, byte[] salt)
 975    {
 976        using var argon2 = new Argon2id(password)
 977        {
 978            Salt = salt,
 979            DegreeOfParallelism = AppDefaults.Argon2.Parallelism,
 980            Iterations = AppDefaults.Argon2.Iterations,
 981            MemorySize = AppDefaults.Argon2.MemoryKiB,
 982        };
 983        return argon2.GetBytes(AppDefaults.Argon2.HashLength);
 984    }
 85}