< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.TrustedCallerOptions
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/TrustedCallerOptions.cs
Line coverage
100%
Covered lines: 24
Uncovered lines: 0
Coverable lines: 24
Total lines: 109
Line coverage: 100%
Branch coverage
100%
Covered branches: 12
Total branches: 12
Branch coverage: 100%
Method coverage
100%
Covered methods: 7
Fully covered methods: 7
Total methods: 7
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_KeyDigests()100%11100%
get_Count()100%11100%
get_Configured()100%11100%
FromEnvironment()100%11100%
Parse(...)100%66100%
Matches(...)100%66100%
Digest(...)100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Presentation/ArturRios.Heimdall.WebApi/Security/TrustedCallerOptions.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3
 4namespace 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>
 34public 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.
 389644    private byte[][] KeyDigests { get; init; } = [];
 45
 46    /// <summary>How many keys are configured. The only thing about them that may be logged.</summary>
 186447    public int Count => KeyDigests.Length;
 48
 49    /// <summary>Whether any caller is trusted.</summary>
 95850    public bool Configured => Count > 0;
 51
 89952    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    {
 93161        var entries = (value ?? string.Empty)
 93162            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 63
 375464        for (var index = 0; index < entries.Length; index++)
 65        {
 94866            if (entries[index].Length < MinimumKeyLength)
 67            {
 268                throw new InvalidOperationException(
 269                    $"Entry {index + 1} of {KeysVariable} is {entries[index].Length} characters. Caller keys are " +
 270                    $"secrets and must be at least {MinimumKeyLength} characters; generate them randomly. " +
 271                    "Refusing to start.");
 72            }
 73        }
 74
 92975        return new TrustedCallerOptions
 92976        {
 92977            KeyDigests = entries.Select(Digest).ToArray()
 92978        };
 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    {
 18091        if (!Configured || string.IsNullOrEmpty(presented))
 92        {
 693            return false;
 94        }
 95
 17496        var presentedDigest = Digest(presented);
 17497        var matched = false;
 98
 74099        foreach (var key in KeyDigests)
 100        {
 101            // Bitwise, not short-circuiting: the loop must visit every key.
 196102            matched |= CryptographicOperations.FixedTimeEquals(key, presentedDigest);
 103        }
 104
 174105        return matched;
 106    }
 107
 1119108    private static byte[] Digest(string value) => SHA256.HashData(Encoding.UTF8.GetBytes(value));
 109}