| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | |
| | | 4 | | namespace ArturRios.Heimdall.WebApi.Security; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// The keys a client system may present to have its end users' own addresses counted by the |
| | | 8 | | /// anonymous-auth rate limiter, read once at start-up from <c>HEIMDALL_TRUSTED_CALLER_KEYS</c>: a |
| | | 9 | | /// comma-separated list of secrets. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <remarks> |
| | | 12 | | /// <para> |
| | | 13 | | /// A client system such as Fortuna's API reaches Heimdall from one address for every one of |
| | | 14 | | /// its users, so the connection address — even the real one, behind |
| | | 15 | | /// <see cref="TrustedProxyOptions" /> — puts them all in the same rate-limit partition, and |
| | | 16 | | /// ten sign-in attempts a minute from anyone lock out everybody. The client system knows |
| | | 17 | | /// who its caller is; this lets it say so. A request carrying a key listed here, in |
| | | 18 | | /// <c>X-Heimdall-Caller-Key</c>, is counted under the address in |
| | | 19 | | /// <c>X-Heimdall-Client-Address</c> instead (see <see cref="AuthRateLimitPartition" />). |
| | | 20 | | /// </para> |
| | | 21 | | /// <para> |
| | | 22 | | /// That is all a key grants. It authenticates nobody and authorizes nothing: a wrong or |
| | | 23 | | /// missing key, or an unusable address, falls back silently to the connection address. A |
| | | 24 | | /// leaked key lets its holder choose their own bucket, which only spreads guesses over |
| | | 25 | | /// invented addresses; the per-account lockouts still count them (Threat Model TB-1). |
| | | 26 | | /// </para> |
| | | 27 | | /// <para> |
| | | 28 | | /// Several keys may be listed so one can be rotated in before the other is withdrawn. Keys |
| | | 29 | | /// are secrets, so one shorter than <see cref="MinimumKeyLength" /> is refused at start-up |
| | | 30 | | /// rather than trusted — and the refusal names its position, never its value. Unset, no |
| | | 31 | | /// caller is trusted. |
| | | 32 | | /// </para> |
| | | 33 | | /// </remarks> |
| | | 34 | | public sealed class TrustedCallerOptions |
| | | 35 | | { |
| | | 36 | | /// <summary>Comma-separated secrets a client system may present.</summary> |
| | | 37 | | public const string KeysVariable = "HEIMDALL_TRUSTED_CALLER_KEYS"; |
| | | 38 | | |
| | | 39 | | /// <summary>The shortest key accepted, in characters.</summary> |
| | | 40 | | public const int MinimumKeyLength = 32; |
| | | 41 | | |
| | | 42 | | // SHA-256 of each configured key, computed once. Only digests are kept: the keys themselves are |
| | | 43 | | // not exposed and cannot be logged by accident. |
| | 3896 | 44 | | private byte[][] KeyDigests { get; init; } = []; |
| | | 45 | | |
| | | 46 | | /// <summary>How many keys are configured. The only thing about them that may be logged.</summary> |
| | 1864 | 47 | | public int Count => KeyDigests.Length; |
| | | 48 | | |
| | | 49 | | /// <summary>Whether any caller is trusted.</summary> |
| | 958 | 50 | | public bool Configured => Count > 0; |
| | | 51 | | |
| | 899 | 52 | | public static TrustedCallerOptions FromEnvironment() => Parse(Environment.GetEnvironmentVariable(KeysVariable)); |
| | | 53 | | |
| | | 54 | | /// <summary> |
| | | 55 | | /// Interprets a <see cref="KeysVariable" /> value. Split from <see cref="FromEnvironment" /> so |
| | | 56 | | /// it can be tested without mutating the process environment the functional suite's hosts read. |
| | | 57 | | /// </summary> |
| | | 58 | | /// <exception cref="InvalidOperationException">An entry is shorter than <see cref="MinimumKeyLength" />.</exception |
| | | 59 | | public static TrustedCallerOptions Parse(string? value) |
| | | 60 | | { |
| | 931 | 61 | | var entries = (value ?? string.Empty) |
| | 931 | 62 | | .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| | | 63 | | |
| | 3754 | 64 | | for (var index = 0; index < entries.Length; index++) |
| | | 65 | | { |
| | 948 | 66 | | if (entries[index].Length < MinimumKeyLength) |
| | | 67 | | { |
| | 2 | 68 | | throw new InvalidOperationException( |
| | 2 | 69 | | $"Entry {index + 1} of {KeysVariable} is {entries[index].Length} characters. Caller keys are " + |
| | 2 | 70 | | $"secrets and must be at least {MinimumKeyLength} characters; generate them randomly. " + |
| | 2 | 71 | | "Refusing to start."); |
| | | 72 | | } |
| | | 73 | | } |
| | | 74 | | |
| | 929 | 75 | | return new TrustedCallerOptions |
| | 929 | 76 | | { |
| | 929 | 77 | | KeyDigests = entries.Select(Digest).ToArray() |
| | 929 | 78 | | }; |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | /// <summary> |
| | | 82 | | /// Whether <paramref name="presented" /> is one of the configured keys. The presented value and |
| | | 83 | | /// each key are reduced to a SHA-256 digest of their UTF-8 bytes, and the 32-byte digests are |
| | | 84 | | /// compared with <see cref="CryptographicOperations.FixedTimeEquals" /> against every configured |
| | | 85 | | /// key whatever the earlier ones gave. The work is the same whatever the length of what was |
| | | 86 | | /// presented, and how long the answer takes says nothing about which key, or how much of one, |
| | | 87 | | /// was right. |
| | | 88 | | /// </summary> |
| | | 89 | | public bool Matches(string? presented) |
| | | 90 | | { |
| | 180 | 91 | | if (!Configured || string.IsNullOrEmpty(presented)) |
| | | 92 | | { |
| | 6 | 93 | | return false; |
| | | 94 | | } |
| | | 95 | | |
| | 174 | 96 | | var presentedDigest = Digest(presented); |
| | 174 | 97 | | var matched = false; |
| | | 98 | | |
| | 740 | 99 | | foreach (var key in KeyDigests) |
| | | 100 | | { |
| | | 101 | | // Bitwise, not short-circuiting: the loop must visit every key. |
| | 196 | 102 | | matched |= CryptographicOperations.FixedTimeEquals(key, presentedDigest); |
| | | 103 | | } |
| | | 104 | | |
| | 174 | 105 | | return matched; |
| | | 106 | | } |
| | | 107 | | |
| | 1119 | 108 | | private static byte[] Digest(string value) => SHA256.HashData(Encoding.UTF8.GetBytes(value)); |
| | | 109 | | } |