< Summary - Heimdall API

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

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Parse(...)100%66100%
IsSet(...)100%22100%
EnsureUsable(...)100%44100%

File(s)

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

#LineLine coverage
 1using System.Text;
 2using ArturRios.Jwt;
 3
 4namespace ArturRios.Heimdall.WebApi.Security;
 5
 6/// <summary>
 7///     The signing secret, and the previous one during a rotation (Threat Model TH-22), read once at
 8///     start-up from <c>HEIMDALL_AUTH_TOKEN_SECRET</c> and <c>HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS</c>
 9///     and turned into the keys tokens are signed and validated with.
 10/// </summary>
 11/// <remarks>
 12///     <para>
 13///         <b>Why a secret is refused rather than used.</b> HMAC-SHA256 needs a key at least as long
 14///         as its 256-bit output, so a secret shorter than <see cref="MinimumBytes" /> bytes is
 15///         refused. <c>ArturRios.Jwt</c> reads a secret's ASCII bytes, and ASCII replaces every
 16///         character above U+007F with <c>?</c>, so a secret holding any such character would sign
 17///         with far less entropy than it appears to carry, and two different secrets could become
 18///         the same key. Such a secret is refused too. Either refusal stops start-up, naming the
 19///         variable and never its value, as every other unusable security setting does.
 20///     </para>
 21///     <para>
 22///         <b>An empty previous secret is no previous secret.</b> Unset, blank, or two double quotes
 23///         (<c>""</c>, what an env file's empty quoted value becomes when nothing strips the quotes)
 24///         all mean "no rotation in progress", so nothing is passed to the library as a key. A
 25///         previous secret equal to the current one is ignored as well: the same value under two ids
 26///         says nothing has been rotated yet. Any other previous secret is held to the same rules as
 27///         the current one.
 28///     </para>
 29///     <para>
 30///         The keys carry ids because <see cref="JwtKey" /> requires them, but no <c>kid</c> is
 31///         written to the tokens; see <c>Startup.BuildJwtConfiguration</c> for why.
 32///     </para>
 33/// </remarks>
 34public static class TokenSigningSecrets
 35{
 36    /// <summary>The variable carrying the secret new tokens are signed with. Required.</summary>
 37    public const string SecretVariable = "HEIMDALL_AUTH_TOKEN_SECRET";
 38
 39    /// <summary>The variable carrying the secret a rotation has moved off but not yet withdrawn. Optional.</summary>
 40    public const string PreviousSecretVariable = "HEIMDALL_AUTH_TOKEN_SECRET_PREVIOUS";
 41
 42    /// <summary>The shortest secret accepted, in bytes: the 256-bit output of HMAC-SHA256.</summary>
 43    public const int MinimumBytes = 32;
 44
 45    /// <summary>The id of the key new tokens are signed with.</summary>
 46    public const string CurrentKeyId = "current";
 47
 48    /// <summary>The id of the key a rotation still accepts.</summary>
 49    public const string PreviousKeyId = "previous";
 50
 51    /// <summary>
 52    ///     A previous secret's value that means "none": two double quotes, left behind when an env
 53    ///     file's empty quoted value reaches the process with its quotes intact.
 54    /// </summary>
 55    private const string EmptyQuotedValue = "\"\"";
 56
 57    /// <summary>
 58    ///     Builds the keys from raw values: the current secret first, then the previous one when a
 59    ///     rotation is in progress.
 60    /// </summary>
 61    /// <exception cref="InvalidOperationException">
 62    ///     <paramref name="secret" /> is blank, either secret is shorter than <see cref="MinimumBytes" />
 63    ///     bytes, or either holds a character outside ASCII. The message names the variable and never
 64    ///     the value.
 65    /// </exception>
 66    public static IReadOnlyList<JwtKey> Parse(string? secret, string? previousSecret)
 67    {
 91468        if (string.IsNullOrWhiteSpace(secret))
 69        {
 370            throw new InvalidOperationException(
 371                $"Environment variable '{SecretVariable}' is unset. The API cannot validate tokens " +
 372                "without a signing secret.");
 73        }
 74
 91175        EnsureUsable(SecretVariable, secret);
 76
 90977        List<JwtKey> keys = [new(CurrentKeyId, secret)];
 78
 90979        if (IsSet(previousSecret) && previousSecret != secret)
 80        {
 90281            EnsureUsable(PreviousSecretVariable, previousSecret!);
 90082            keys.Add(new JwtKey(PreviousKeyId, previousSecret!));
 83        }
 84
 90785        return keys;
 86    }
 87
 88    /// <summary>Whether a previous secret is configured at all: neither blank nor an empty quoted value.</summary>
 89    private static bool IsSet(string? previousSecret) =>
 90990        !string.IsNullOrWhiteSpace(previousSecret) &&
 90991        !string.Equals(previousSecret.Trim(), EmptyQuotedValue, StringComparison.Ordinal);
 92
 93    private static void EnsureUsable(string variable, string value)
 94    {
 95        // Checked first: for a secret outside ASCII the byte count below would describe bytes the
 96        // library never signs with.
 6983197        if (value.Any(character => character > '\u007F'))
 98        {
 299            throw new InvalidOperationException(
 2100                $"Environment variable '{variable}' holds characters outside ASCII. Tokens are signed " +
 2101                "with the secret's ASCII bytes, which turn each such character into '?', so the key " +
 2102                "would be weaker than it looks. Use a random ASCII secret, e.g. 'openssl rand -base64 48'. " +
 2103                "Refusing to start.");
 104        }
 105
 1811106        var bytes = Encoding.UTF8.GetByteCount(value);
 107
 1811108        if (bytes < MinimumBytes)
 109        {
 2110            throw new InvalidOperationException(
 2111                $"Environment variable '{variable}' is {bytes} bytes. A signing secret must be at least " +
 2112                $"{MinimumBytes} bytes, which HMAC-SHA256 requires; generate it randomly, e.g. " +
 2113                "'openssl rand -base64 48'. Refusing to start.");
 114        }
 1809115    }
 116}