< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Security.TokenSigningKeys
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Security/TokenSigningKeys.cs
Line coverage
100%
Covered lines: 51
Uncovered lines: 0
Coverable lines: 51
Total lines: 167
Line coverage: 100%
Branch coverage
100%
Covered branches: 18
Total branches: 18
Branch coverage: 100%
Method coverage
100%
Covered methods: 11
Fully covered methods: 11
Total methods: 11
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
get_Current()100%11100%
get_Previous()100%11100%
get_ConfigurationKeys()100%11100%
get_JwksJson()100%11100%
FromEnvironment()100%11100%
Parse(...)100%88100%
ApplyTo(...)100%11100%
IsSet(...)100%22100%
Read(...)100%66100%
NotBase64OfAPem(...)100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Security/TokenSigningKeys.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using ArturRios.Jwt;
 4
 5namespace ArturRios.Heimdall.WebApi.Security;
 6
 7/// <summary>
 8///     The ES256 key every token is signed with, and the previous one during a rotation, read once at
 9///     start-up from <c>HEIMDALL_AUTH_SIGNING_KEY</c> and <c>HEIMDALL_AUTH_SIGNING_KEY_PREVIOUS</c>, with
 10///     the key set (JWKS) that publishes their public halves.
 11/// </summary>
 12/// <remarks>
 13///     <para>
 14///         <b>The format.</b> Each variable holds the base64 (standard alphabet, one line) of a PKCS#8
 15///         PEM P-256 private key: what <c>base64 -w0 signing.pem</c> prints for the file
 16///         <c>openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256</c> writes. The PEM is
 17///         base64-encoded because its line breaks do not survive every env file and secret store. A
 18///         value that is not that, holds the PEM itself, or holds any key but a P-256 private one stops
 19///         start-up, naming the variable and never any part of its value.
 20///     </para>
 21///     <para>
 22///         <b>The <c>kid</c>.</b> A key's id is its RFC 7638 thumbprint (<see cref="JwtKey.Id" />), derived
 23///         from the public half and never configured, so it names the same key in every token and in the
 24///         published key set.
 25///     </para>
 26///     <para>
 27///         <b>An empty previous key is no previous key.</b> Unset, blank, or two double quotes
 28///         (<c>""</c>, an env file's empty quoted value with its quotes intact) all mean "no rotation in
 29///         progress". A previous key equal to the current one is ignored too. Any other previous key is
 30///         held to the same rules as the current one, and only its public half is kept: it verifies the
 31///         tokens it signed before the rotation and never signs another.
 32///     </para>
 33/// </remarks>
 34public sealed class TokenSigningKeys
 35{
 36    /// <summary>The variable carrying the key new tokens are signed with. Required.</summary>
 37    public const string SigningKeyVariable = "HEIMDALL_AUTH_SIGNING_KEY";
 38
 39    /// <summary>The variable carrying the key a rotation has moved off but not yet withdrawn. Optional.</summary>
 40    public const string PreviousSigningKeyVariable = "HEIMDALL_AUTH_SIGNING_KEY_PREVIOUS";
 41
 42    /// <summary>The value that means "none": an env file's empty quoted value with its quotes intact.</summary>
 43    private const string EmptyQuotedValue = "\"\"";
 44
 45    /// <summary>What every PEM begins with, and what no refusal message ever contains.</summary>
 46    private const string PemBoundary = "-----BEGIN";
 47
 48    private const string GenerateCommand =
 49        "'openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out signing.pem'";
 50
 106651    private TokenSigningKeys(JwtKey current, JwtKey? previous)
 52    {
 106653        Current = current;
 106654        Previous = previous;
 106655        ConfigurationKeys = previous is null ? [current] : [current, previous];
 106656        JwksJson = JwksDocument.From(ConfigurationKeys);
 106657    }
 58
 59    /// <summary>The key new tokens are signed with, holding its private half.</summary>
 289660    public JwtKey Current { get; }
 61
 62    /// <summary>
 63    ///     The public half of the previous key, which verifies but never signs; <see langword="null" />
 64    ///     when no rotation is in progress.
 65    /// </summary>
 1566    public JwtKey? Previous { get; }
 67
 68    /// <summary>The keys tokens are accepted with: the current one first, then the previous one.</summary>
 386669    public IReadOnlyList<JwtKey> ConfigurationKeys { get; }
 70
 71    /// <summary>The key set publishing <see cref="ConfigurationKeys" />' public halves, built once.</summary>
 3472    public string JwksJson { get; }
 73
 74    /// <summary>Reads the keys from the process environment.</summary>
 75    /// <exception cref="InvalidOperationException">As <see cref="Parse" />.</exception>
 76    public static TokenSigningKeys FromEnvironment() =>
 97377        Parse(Environment.GetEnvironmentVariable(SigningKeyVariable),
 97378            Environment.GetEnvironmentVariable(PreviousSigningKeyVariable));
 79
 80    /// <summary>Reads the keys from raw variable values.</summary>
 81    /// <exception cref="InvalidOperationException">
 82    ///     <paramref name="signingKey" /> is unset, or either value is not the base64 of a P-256 private
 83    ///     key's PEM. The message names the variable and never the value.
 84    /// </exception>
 85    public static TokenSigningKeys Parse(string? signingKey, string? previousSigningKey)
 86    {
 109387        var current = IsSet(signingKey)
 109388            ? Read(SigningKeyVariable, signingKey!.Trim())
 109389            : throw new InvalidOperationException(
 109390                $"Environment variable '{SigningKeyVariable}' is unset. Heimdall signs its tokens with an " +
 109391                $"ES256 key: generate one with {GenerateCommand} and set the variable to the output of " +
 109392                "'base64 -w0 signing.pem'. Refusing to start.");
 93
 107494        var previous = IsSet(previousSigningKey)
 107495            ? Read(PreviousSigningKeyVariable, previousSigningKey!.Trim()).ToPublicKey()
 107496            : null;
 97
 106698        return new TokenSigningKeys(current, previous?.Id == current.Id ? null : previous);
 99    }
 100
 101    /// <summary>
 102    ///     <paramref name="baseConfiguration" /> signing ES256 with <see cref="Current" /> and accepting
 103    ///     <see cref="ConfigurationKeys" />, with no secret.
 104    /// </summary>
 105    public JwtConfiguration ApplyTo(JwtConfiguration baseConfiguration) =>
 1826106        baseConfiguration with
 1826107        {
 1826108            Algorithm = JwtAlgorithms.ES256,
 1826109            Keys = ConfigurationKeys,
 1826110            SigningKeyId = Current.Id,
 1826111            Secret = ""
 1826112        };
 113
 114    /// <summary>Whether a value is configured at all: neither blank nor an empty quoted value.</summary>
 115    private static bool IsSet(string? value) =>
 2167116        !string.IsNullOrWhiteSpace(value) &&
 2167117        !string.Equals(value.Trim(), EmptyQuotedValue, StringComparison.Ordinal);
 118
 119    private static JwtKey Read(string variable, string value)
 120    {
 2071121        if (value.Contains(PemBoundary, StringComparison.Ordinal))
 122        {
 3123            throw new InvalidOperationException(
 3124                $"Environment variable '{variable}' holds a PEM itself. Set it to the base64 of the PEM, on " +
 3125                "one line: 'base64 -w0 signing.pem'. Refusing to start.");
 126        }
 127
 2068128        var decoded = new byte[value.Length];
 129
 130        try
 131        {
 2068132            if (!Convert.TryFromBase64String(value, decoded, out var length))
 133            {
 4134                throw NotBase64OfAPem(variable);
 135            }
 136
 2064137            var pem = Encoding.UTF8.GetString(decoded, 0, length);
 138
 2064139            if (!pem.Contains(PemBoundary, StringComparison.Ordinal))
 140            {
 5141                throw NotBase64OfAPem(variable);
 142            }
 143
 144            try
 145            {
 2059146                return JwtKey.FromEcPrivateKeyPem(pem);
 147            }
 9148            catch (Exception exception) when (exception is ArgumentException or CryptographicException)
 149            {
 150                // The library's message names no part of the key either, but this one names the
 151                // variable, which is what an operator needs. A platform CryptographicException the
 152                // library lets through is refused the same way rather than escaping unnamed.
 9153                throw new InvalidOperationException(
 9154                    $"Environment variable '{variable}' is not a P-256 private key. Generate one with " +
 9155                    $"{GenerateCommand}. Refusing to start.");
 156            }
 157        }
 158        finally
 159        {
 2068160            CryptographicOperations.ZeroMemory(decoded);
 2068161        }
 2050162    }
 163
 164    private static InvalidOperationException NotBase64OfAPem(string variable) =>
 9165        new($"Environment variable '{variable}' is not the base64 of a PEM. Set it to the output of " +
 9166            "'base64 -w0 signing.pem'. Refusing to start.");
 167}