| | | 1 | | using System.Globalization; |
| | | 2 | | using ArturRios.Util.Hashing; |
| | | 3 | | |
| | | 4 | | namespace 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> |
| | | 42 | | public 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> |
| | | 62 | | 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 | | |
| | | 69 | | public PasswordHashGate(int maxConcurrent, TimeSpan maxWait) |
| | | 70 | | { |
| | | 71 | | ArgumentOutOfRangeException.ThrowIfLessThan(maxConcurrent, 1); |
| | | 72 | | |
| | | 73 | | MaxConcurrent = maxConcurrent; |
| | | 74 | | MaxWait = maxWait; |
| | | 75 | | _permits = new SemaphoreSlim(maxConcurrent, maxConcurrent); |
| | | 76 | | } |
| | | 77 | | |
| | | 78 | | /// <summary>Derivations this gate permits at once.</summary> |
| | | 79 | | public int MaxConcurrent { get; } |
| | | 80 | | |
| | | 81 | | /// <summary>How long a caller waits for a permit before being refused.</summary> |
| | | 82 | | 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> |
| | | 91 | | 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() => |
| | | 102 | | Shared = new PasswordHashGate( |
| | | 103 | | int.TryParse(Environment.GetEnvironmentVariable(MaxConcurrentVariable), out var configured) |
| | | 104 | | && configured > 0 |
| | | 105 | | ? configured |
| | | 106 | | : DefaultMaxConcurrent, |
| | | 107 | | 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) => |
| | | 111 | | 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 | | { |
| | | 116 | | byte[] salt = []; |
| | | 117 | | |
| | | 118 | | var hash = await RunDerivationAsync(() => Hash.EncodeWithRandomSalt(text, out salt)); |
| | | 119 | | |
| | | 120 | | return (hash, salt); |
| | | 121 | | } |
| | | 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 | | { |
| | | 136 | | if (!await _permits.WaitAsync(MaxWait)) |
| | | 137 | | { |
| | | 138 | | throw new PasswordHashGateSaturatedException(MaxWait); |
| | | 139 | | } |
| | | 140 | | |
| | | 141 | | try |
| | | 142 | | { |
| | | 143 | | 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. |
| | | 149 | | _permits.Release(); |
| | | 150 | | } |
| | | 151 | | } |
| | | 152 | | |
| | | 153 | | /// <inheritdoc cref="RunAsync{T}(Func{Task{T}})" /> |
| | | 154 | | public async Task RunAsync(Func<Task> work) => |
| | | 155 | | await RunAsync(async () => |
| | | 156 | | { |
| | | 157 | | await work(); |
| | | 158 | | |
| | | 159 | | return true; |
| | | 160 | | }); |
| | | 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) => |
| | | 169 | | 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> |
| | | 183 | | public sealed class PasswordHashGateSaturatedException(TimeSpan waited) |
| | 5 | 184 | | : Exception($"The password-hash gate stayed full for {waited.TotalSeconds.ToString("0.###", CultureInfo.InvariantCul |