< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.PasswordHashGate
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/PasswordHashGate.cs
Line coverage
100%
Covered lines: 33
Uncovered lines: 0
Coverable lines: 33
Total lines: 184
Line coverage: 100%
Branch coverage
100%
Covered branches: 6
Total branches: 6
Branch coverage: 100%
Method coverage
100%
Covered methods: 12
Fully covered methods: 12
Total methods: 12
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
get_MaxConcurrent()100%11100%
get_MaxWait()100%11100%
get_Shared()100%11100%
ConfigureSharedFromEnvironment()100%44100%
TextMatchesAsync(...)100%11100%
EncodeWithRandomSaltAsync()100%11100%
RunAsync()100%22100%
RunAsync()100%11100%
<RunAsync()100%11100%
RunDerivationAsync(...)100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/PasswordHashGate.cs

#LineLine coverage
 1using System.Globalization;
 2using ArturRios.Util.Hashing;
 3
 4namespace ArturRios.Heimdall.Command.Services;
 5
 6/// <summary>
 7///     Bounds how many Argon2id derivations run at once, and refuses rather than queueing without
 8///     limit when that bound is reached (Threat Model TH-03).
 9/// </summary>
 10/// <remarks>
 11///     <para>
 12///         Argon2id is configured for 600 MB and 16 threads, which is deliberate: it is what makes an
 13///         offline attack on a stolen hash expensive (NFR-18). It also means concurrency, not CPU, is
 14///         what decides whether this API stays up. Ten derivations at once want six gigabytes of
 15///         working set, and until this existed nothing in the process knew that — the only thing
 16///         between a caller and unbounded memory demand was the per-IP rate limiter, which is per
 17///         instance and per address, so callers spread across addresses multiplied that budget rather
 18///         than sharing it.
 19///     </para>
 20///     <para>
 21///         An instance holding its own semaphore, with one <see cref="Shared" /> gate that every call
 22///         site uses. The bound is a process-wide property, so a single shared instance is what
 23///         expresses it — but making the class itself static put a mutable semaphore in global state,
 24///         and the suite found two faults in that within one run: a permit released into a semaphore
 25///         it had not been taken from, because reconfiguring replaced the instance under callers
 26///         already inside it; and unrelated handler tests refused, because another test class running
 27///         in parallel had set the bound to one. A gate's semaphore is now fixed at construction and
 28///         tests build their own.
 29///     </para>
 30///     <para>
 31///         The bound covers every derivation on a request path, not only login. A limit covering the
 32///         anonymous endpoints alone would leave an authenticated caller creating persons in a loop
 33///         to exhaust the same memory, and a bound with a way around it is not a bound.
 34///     </para>
 35///     <para>
 36///         What it deliberately does not cover: the decoy hash computed once at type initialisation,
 37///         and the master user the seeder writes at start-up. Neither is on a request path, both run
 38///         before the API serves anything, and gating start-up work against a limit meant for
 39///         concurrent requests would only risk a start-up that waits on itself.
 40///     </para>
 41/// </remarks>
 42public sealed class PasswordHashGate
 43{
 44    /// <summary>
 45    ///     Derivations permitted at once. Four is roughly 2.4 GB of Argon2id working set, which fits
 46    ///     a small container with room left for everything else the process does.
 47    /// </summary>
 48    /// <remarks>
 49    ///     Chosen to be survivable rather than fast, and below the ten a single address can already
 50    ///     demand through the rate limiter — so a burst from one caller queues instead of arriving at
 51    ///     once. That is the behaviour being bought, and it is not free: see <see cref="MaxWait" />.
 52    /// </remarks>
 53    public const int DefaultMaxConcurrent = 4;
 54
 55    /// <summary>The default wait for a permit before the request is refused.</summary>
 56    /// <remarks>
 57    ///     Long enough to absorb a burst — a few derivations ahead of it, each a fraction of a second
 58    ///     — and short enough that a saturated process answers rather than holding connections open
 59    ///     until something else times out. Refusing is the point: an unbounded queue is the condition
 60    ///     this class exists to prevent, moved from memory into the thread pool.
 61    /// </remarks>
 662    public static readonly TimeSpan DefaultMaxWait = TimeSpan.FromSeconds(10);
 63
 64    /// <summary>The environment variable overriding <see cref="DefaultMaxConcurrent" />.</summary>
 65    public const string MaxConcurrentVariable = "HEIMDALL_AUTH_MAX_CONCURRENT_PASSWORD_HASHES";
 66
 67    private readonly SemaphoreSlim _permits;
 68
 93969    public PasswordHashGate(int maxConcurrent, TimeSpan maxWait)
 70    {
 93971        ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrent, 1);
 72
 93773        MaxConcurrent = maxConcurrent;
 93774        MaxWait = maxWait;
 93775        _permits = new SemaphoreSlim(maxConcurrent, maxConcurrent);
 93776    }
 77
 78    /// <summary>Derivations this gate permits at once.</summary>
 2079    public int MaxConcurrent { get; }
 80
 81    /// <summary>How long a caller waits for a permit before being refused.</summary>
 60082    public TimeSpan MaxWait { get; }
 83
 84    /// <summary>The gate every call site uses, and the one the process-wide bound belongs to.</summary>
 85    /// <remarks>
 86    ///     Settable so start-up can apply the configured bound, and so the functional suite can prove
 87    ///     a saturated gate answers 503 without waiting out the real ten seconds. Replaced rather than
 88    ///     reconfigured: a gate's semaphore never changes after construction, so a derivation already
 89    ///     running finishes against the gate it entered rather than releasing into a different one.
 90    /// </remarks>
 152991    public static PasswordHashGate Shared { get; set; } = new(DefaultMaxConcurrent, DefaultMaxWait);
 92
 93    /// <summary>
 94    ///     Reads <see cref="MaxConcurrentVariable" /> and replaces <see cref="Shared" />, falling back
 95    ///     to <see cref="DefaultMaxConcurrent" /> when it is unset, unparseable, or not positive.
 96    /// </summary>
 97    /// <remarks>
 98    ///     A bad value falls back rather than failing start-up. The variable tunes a safety margin;
 99    ///     refusing to boot over a typo in it would turn a conservative default into an outage.
 100    /// </remarks>
 101    public static void ConfigureSharedFromEnvironment() =>
 905102        Shared = new PasswordHashGate(
 905103            int.TryParse(Environment.GetEnvironmentVariable(MaxConcurrentVariable), out var configured)
 905104            && configured > 0
 905105                ? configured
 905106                : DefaultMaxConcurrent,
 905107            DefaultMaxWait);
 108
 109    /// <summary>Verifies <paramref name="text" /> against a stored hash, under the bound.</summary>
 110    public Task<bool> TextMatchesAsync(string text, byte[] hash, byte[] salt) =>
 781111        RunDerivationAsync(() => Hash.TextMatches(text, hash, salt));
 112
 113    /// <summary>Derives a hash for <paramref name="text" /> under a fresh salt, under the bound.</summary>
 114    public async Task<(byte[] Hash, byte[] Salt)> EncodeWithRandomSaltAsync(string text)
 115    {
 171116        byte[] salt = [];
 117
 342118        var hash = await RunDerivationAsync(() => Hash.EncodeWithRandomSalt(text, out salt));
 119
 171120        return (hash, salt);
 171121    }
 122
 123    /// <summary>
 124    ///     Runs <paramref name="work" /> once a permit is free, releasing it afterwards however
 125    ///     <paramref name="work" /> ends.
 126    /// </summary>
 127    /// <exception cref="PasswordHashGateSaturatedException">No permit became free in time.</exception>
 128    /// <remarks>
 129    ///     The primitive the two derivation helpers are written in terms of, and public because the
 130    ///     bound is about total concurrent Argon2id cost rather than about which helper incurs it: a
 131    ///     future caller with its own derivation should enter this gate rather than add a second one
 132    ///     beside it.
 133    /// </remarks>
 134    public async Task<T> RunAsync<T>(Func<Task<T>> work)
 135    {
 593136        if (!await _permits.WaitAsync(MaxWait))
 137        {
 5138            throw new PasswordHashGateSaturatedException(MaxWait);
 139        }
 140
 141        try
 142        {
 588143            return await work();
 144        }
 145        finally
 146        {
 147            // In a finally so a derivation that throws does not leak its permit. A leaked permit
 148            // closes the gate a little further each time, until the protection becomes the outage.
 588149            _permits.Release();
 150        }
 587151    }
 152
 153    /// <inheritdoc cref="RunAsync{T}(Func{Task{T}})" />
 154    public async Task RunAsync(Func<Task> work) =>
 29155        await RunAsync(async () =>
 29156        {
 27157            await work();
 29158
 27159            return true;
 56160        });
 161
 162    /// <summary>Runs a synchronous derivation under the bound.</summary>
 163    /// <remarks>
 164    ///     Inline rather than on a worker thread. The derivation is CPU-bound and already
 165    ///     multi-threaded internally, and the permit count is what bounds how many run at once —
 166    ///     handing it to the thread pool as well would add a scheduling hop and bound nothing.
 167    /// </remarks>
 168    private Task<T> RunDerivationAsync<T>(Func<T> derivation) =>
 1123169        RunAsync(() => Task.FromResult(derivation()));
 170}
 171
 172/// <summary>
 173///     Thrown when a password derivation could not start because the gate was already running
 174///     <see cref="PasswordHashGate.MaxConcurrent" /> of them and none finished in time.
 175/// </summary>
 176/// <remarks>
 177///     A load condition rather than a fault: nothing is wrong with the request, and the caller should
 178///     retry. <c>PasswordHashSaturationFilter</c> turns it into the canonical
 179///     <c>AuthenticationTemporarilyUnavailable</c> message and <c>503</c>; this exception's own message
 180///     is never sent to a client, so it names the gate and the wait for whoever reads a log or a failing
 181///     test.
 182/// </remarks>
 183public sealed class PasswordHashGateSaturatedException(TimeSpan waited)
 184    : Exception($"The password-hash gate stayed full for {waited.TotalSeconds.ToString("0.###", CultureInfo.InvariantCul