| | | 1 | | using System.Text; |
| | | 2 | | using ArturRios.Jwt; |
| | | 3 | | |
| | | 4 | | namespace 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> |
| | | 34 | | public 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 | | { |
| | 914 | 68 | | if (string.IsNullOrWhiteSpace(secret)) |
| | | 69 | | { |
| | 3 | 70 | | throw new InvalidOperationException( |
| | 3 | 71 | | $"Environment variable '{SecretVariable}' is unset. The API cannot validate tokens " + |
| | 3 | 72 | | "without a signing secret."); |
| | | 73 | | } |
| | | 74 | | |
| | 911 | 75 | | EnsureUsable(SecretVariable, secret); |
| | | 76 | | |
| | 909 | 77 | | List<JwtKey> keys = [new(CurrentKeyId, secret)]; |
| | | 78 | | |
| | 909 | 79 | | if (IsSet(previousSecret) && previousSecret != secret) |
| | | 80 | | { |
| | 902 | 81 | | EnsureUsable(PreviousSecretVariable, previousSecret!); |
| | 900 | 82 | | keys.Add(new JwtKey(PreviousKeyId, previousSecret!)); |
| | | 83 | | } |
| | | 84 | | |
| | 907 | 85 | | 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) => |
| | 909 | 90 | | !string.IsNullOrWhiteSpace(previousSecret) && |
| | 909 | 91 | | !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. |
| | 69831 | 97 | | if (value.Any(character => character > '\u007F')) |
| | | 98 | | { |
| | 2 | 99 | | throw new InvalidOperationException( |
| | 2 | 100 | | $"Environment variable '{variable}' holds characters outside ASCII. Tokens are signed " + |
| | 2 | 101 | | "with the secret's ASCII bytes, which turn each such character into '?', so the key " + |
| | 2 | 102 | | "would be weaker than it looks. Use a random ASCII secret, e.g. 'openssl rand -base64 48'. " + |
| | 2 | 103 | | "Refusing to start."); |
| | | 104 | | } |
| | | 105 | | |
| | 1811 | 106 | | var bytes = Encoding.UTF8.GetByteCount(value); |
| | | 107 | | |
| | 1811 | 108 | | if (bytes < MinimumBytes) |
| | | 109 | | { |
| | 2 | 110 | | throw new InvalidOperationException( |
| | 2 | 111 | | $"Environment variable '{variable}' is {bytes} bytes. A signing secret must be at least " + |
| | 2 | 112 | | $"{MinimumBytes} bytes, which HMAC-SHA256 requires; generate it randomly, e.g. " + |
| | 2 | 113 | | "'openssl rand -base64 48'. Refusing to start."); |
| | | 114 | | } |
| | 1809 | 115 | | } |
| | | 116 | | } |