| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | using ArturRios.Jwt; |
| | | 4 | | |
| | | 5 | | namespace 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> |
| | | 34 | | public 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 | | |
| | 1066 | 51 | | private TokenSigningKeys(JwtKey current, JwtKey? previous) |
| | | 52 | | { |
| | 1066 | 53 | | Current = current; |
| | 1066 | 54 | | Previous = previous; |
| | 1066 | 55 | | ConfigurationKeys = previous is null ? [current] : [current, previous]; |
| | 1066 | 56 | | JwksJson = JwksDocument.From(ConfigurationKeys); |
| | 1066 | 57 | | } |
| | | 58 | | |
| | | 59 | | /// <summary>The key new tokens are signed with, holding its private half.</summary> |
| | 2896 | 60 | | 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> |
| | 15 | 66 | | public JwtKey? Previous { get; } |
| | | 67 | | |
| | | 68 | | /// <summary>The keys tokens are accepted with: the current one first, then the previous one.</summary> |
| | 3866 | 69 | | public IReadOnlyList<JwtKey> ConfigurationKeys { get; } |
| | | 70 | | |
| | | 71 | | /// <summary>The key set publishing <see cref="ConfigurationKeys" />' public halves, built once.</summary> |
| | 34 | 72 | | 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() => |
| | 973 | 77 | | Parse(Environment.GetEnvironmentVariable(SigningKeyVariable), |
| | 973 | 78 | | 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 | | { |
| | 1093 | 87 | | var current = IsSet(signingKey) |
| | 1093 | 88 | | ? Read(SigningKeyVariable, signingKey!.Trim()) |
| | 1093 | 89 | | : throw new InvalidOperationException( |
| | 1093 | 90 | | $"Environment variable '{SigningKeyVariable}' is unset. Heimdall signs its tokens with an " + |
| | 1093 | 91 | | $"ES256 key: generate one with {GenerateCommand} and set the variable to the output of " + |
| | 1093 | 92 | | "'base64 -w0 signing.pem'. Refusing to start."); |
| | | 93 | | |
| | 1074 | 94 | | var previous = IsSet(previousSigningKey) |
| | 1074 | 95 | | ? Read(PreviousSigningKeyVariable, previousSigningKey!.Trim()).ToPublicKey() |
| | 1074 | 96 | | : null; |
| | | 97 | | |
| | 1066 | 98 | | 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) => |
| | 1826 | 106 | | baseConfiguration with |
| | 1826 | 107 | | { |
| | 1826 | 108 | | Algorithm = JwtAlgorithms.ES256, |
| | 1826 | 109 | | Keys = ConfigurationKeys, |
| | 1826 | 110 | | SigningKeyId = Current.Id, |
| | 1826 | 111 | | Secret = "" |
| | 1826 | 112 | | }; |
| | | 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) => |
| | 2167 | 116 | | !string.IsNullOrWhiteSpace(value) && |
| | 2167 | 117 | | !string.Equals(value.Trim(), EmptyQuotedValue, StringComparison.Ordinal); |
| | | 118 | | |
| | | 119 | | private static JwtKey Read(string variable, string value) |
| | | 120 | | { |
| | 2071 | 121 | | if (value.Contains(PemBoundary, StringComparison.Ordinal)) |
| | | 122 | | { |
| | 3 | 123 | | throw new InvalidOperationException( |
| | 3 | 124 | | $"Environment variable '{variable}' holds a PEM itself. Set it to the base64 of the PEM, on " + |
| | 3 | 125 | | "one line: 'base64 -w0 signing.pem'. Refusing to start."); |
| | | 126 | | } |
| | | 127 | | |
| | 2068 | 128 | | var decoded = new byte[value.Length]; |
| | | 129 | | |
| | | 130 | | try |
| | | 131 | | { |
| | 2068 | 132 | | if (!Convert.TryFromBase64String(value, decoded, out var length)) |
| | | 133 | | { |
| | 4 | 134 | | throw NotBase64OfAPem(variable); |
| | | 135 | | } |
| | | 136 | | |
| | 2064 | 137 | | var pem = Encoding.UTF8.GetString(decoded, 0, length); |
| | | 138 | | |
| | 2064 | 139 | | if (!pem.Contains(PemBoundary, StringComparison.Ordinal)) |
| | | 140 | | { |
| | 5 | 141 | | throw NotBase64OfAPem(variable); |
| | | 142 | | } |
| | | 143 | | |
| | | 144 | | try |
| | | 145 | | { |
| | 2059 | 146 | | return JwtKey.FromEcPrivateKeyPem(pem); |
| | | 147 | | } |
| | 9 | 148 | | 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. |
| | 9 | 153 | | throw new InvalidOperationException( |
| | 9 | 154 | | $"Environment variable '{variable}' is not a P-256 private key. Generate one with " + |
| | 9 | 155 | | $"{GenerateCommand}. Refusing to start."); |
| | | 156 | | } |
| | | 157 | | } |
| | | 158 | | finally |
| | | 159 | | { |
| | 2068 | 160 | | CryptographicOperations.ZeroMemory(decoded); |
| | 2068 | 161 | | } |
| | 2050 | 162 | | } |
| | | 163 | | |
| | | 164 | | private static InvalidOperationException NotBase64OfAPem(string variable) => |
| | 9 | 165 | | new($"Environment variable '{variable}' is not the base64 of a PEM. Set it to the output of " + |
| | 9 | 166 | | "'base64 -w0 signing.pem'. Refusing to start."); |
| | | 167 | | } |