| | | 1 | | using System.Text; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | |
| | | 4 | | namespace ArturRios.Jwt; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// JSON Web Key Sets (RFC 7517) of ES256 public keys: what an issuer publishes so that others can verify |
| | | 8 | | /// its tokens without holding anything that signs them, and what those others read. |
| | | 9 | | /// </summary> |
| | | 10 | | /// <remarks> |
| | | 11 | | /// Every member written is a fixed ASCII name or a base64url value (a coordinate, or an id that is a |
| | | 12 | | /// thumbprint, since <see cref="From"/> refuses any other id), so the default <see cref="Utf8JsonWriter"/> |
| | | 13 | | /// encoder, which escapes <c>+ < > & '</c> and non-ASCII characters, never changes any of them. |
| | | 14 | | /// </remarks> |
| | | 15 | | public static class JwksDocument |
| | | 16 | | { |
| | | 17 | | private const string Kty = "kty"; |
| | | 18 | | private const string Crv = "crv"; |
| | | 19 | | private const string X = "x"; |
| | | 20 | | private const string Y = "y"; |
| | | 21 | | private const string Kid = "kid"; |
| | | 22 | | private const string Use = "use"; |
| | | 23 | | private const string Alg = "alg"; |
| | | 24 | | private const string PrivateScalar = "d"; |
| | | 25 | | private const string Signature = "sig"; |
| | | 26 | | |
| | | 27 | | /// <summary> |
| | | 28 | | /// The key set publishing the public halves of <paramref name="keys"/>, in order: <c>kty</c>, |
| | | 29 | | /// <c>crv</c>, <c>x</c>, <c>y</c>, <c>kid</c> (the key's id), <c>use</c> (<c>sig</c>) and <c>alg</c> |
| | | 30 | | /// (<c>ES256</c>) for each. A private half is never written, whether the key holds one or not. |
| | | 31 | | /// </summary> |
| | | 32 | | /// <remarks> |
| | | 33 | | /// A key's id must be its RFC 7638 <see cref="JwtKey.Thumbprint"/>, as it is for every key read with |
| | | 34 | | /// <see cref="JwtKey.FromEcPrivateKeyPem"/> or <see cref="JwtKey.FromEcPublicJwk"/>. One renamed with a |
| | | 35 | | /// <see langword="with"/> expression is refused rather than published, because <see cref="Parse"/>, and |
| | | 36 | | /// any consumer that checks thumbprints, would silently drop it. |
| | | 37 | | /// </remarks> |
| | | 38 | | /// <exception cref="ArgumentException"> |
| | | 39 | | /// A key is <see langword="null"/>; a key is an HS256 secret, which has no public half to publish; or a |
| | | 40 | | /// key's id is not its thumbprint. The message never names the key. |
| | | 41 | | /// </exception> |
| | | 42 | | public static string From(IEnumerable<JwtKey> keys) |
| | | 43 | | { |
| | 13 | 44 | | ArgumentNullException.ThrowIfNull(keys); |
| | | 45 | | |
| | 13 | 46 | | var published = keys.ToList(); |
| | | 47 | | |
| | 31 | 48 | | if (published.Any(key => key is null)) |
| | | 49 | | { |
| | 1 | 50 | | throw new ArgumentException("The keys to publish include a null key.", nameof(keys)); |
| | | 51 | | } |
| | | 52 | | |
| | 28 | 53 | | if (published.Any(key => !key.IsAsymmetric)) |
| | | 54 | | { |
| | 1 | 55 | | throw new ArgumentException( |
| | 1 | 56 | | $"An {JwtAlgorithms.HS256} key is a shared secret with no public half: a key set publishes {JwtAlgorithm |
| | 1 | 57 | | nameof(keys)); |
| | | 58 | | } |
| | | 59 | | |
| | 26 | 60 | | if (published.Any(key => key.Id != key.Thumbprint)) |
| | | 61 | | { |
| | 1 | 62 | | throw new ArgumentException( |
| | 1 | 63 | | "A key's id is not its RFC 7638 thumbprint, so a key set reader would drop it. Publish keys as " + |
| | 1 | 64 | | $"{nameof(JwtKey.FromEcPrivateKeyPem)} or {nameof(JwtKey.FromEcPublicJwk)} name them.", |
| | 1 | 65 | | nameof(keys)); |
| | | 66 | | } |
| | | 67 | | |
| | 10 | 68 | | using var buffer = new MemoryStream(); |
| | | 69 | | |
| | 10 | 70 | | using (var writer = new Utf8JsonWriter(buffer)) |
| | | 71 | | { |
| | 10 | 72 | | writer.WriteStartObject(); |
| | 10 | 73 | | writer.WriteStartArray("keys"); |
| | | 74 | | |
| | 46 | 75 | | foreach (var key in published) |
| | | 76 | | { |
| | 13 | 77 | | writer.WriteStartObject(); |
| | | 78 | | |
| | 13 | 79 | | JwkThumbprint.WriteMembers(writer, new KeyValuePair<string, string>[] |
| | 13 | 80 | | { |
| | 13 | 81 | | new(Kty, "EC"), |
| | 13 | 82 | | new(Crv, P256.JwkCurveName), |
| | 13 | 83 | | new(X, key.PublicX!), |
| | 13 | 84 | | new(Y, key.PublicY!), |
| | 13 | 85 | | new(Kid, key.Id), |
| | 13 | 86 | | new(Use, Signature), |
| | 13 | 87 | | new(Alg, JwtAlgorithms.ES256) |
| | 13 | 88 | | }); |
| | | 89 | | |
| | 13 | 90 | | writer.WriteEndObject(); |
| | | 91 | | } |
| | | 92 | | |
| | 10 | 93 | | writer.WriteEndArray(); |
| | 10 | 94 | | writer.WriteEndObject(); |
| | 10 | 95 | | } |
| | | 96 | | |
| | 10 | 97 | | return Encoding.UTF8.GetString(buffer.ToArray()); |
| | 10 | 98 | | } |
| | | 99 | | |
| | | 100 | | /// <summary> |
| | | 101 | | /// Reads the ES256 verification keys a key set publishes. |
| | | 102 | | /// </summary> |
| | | 103 | | /// <remarks> |
| | | 104 | | /// <para> |
| | | 105 | | /// An entry is read when it is an EC key on P-256 whose <c>use</c>, if present, is <c>sig</c>, whose |
| | | 106 | | /// <c>alg</c>, if present, is <c>ES256</c>, whose point is on the curve, and whose <c>kid</c>, if |
| | | 107 | | /// present, is its RFC 7638 thumbprint. Any other entry is skipped, not an error: a key set may |
| | | 108 | | /// publish keys for other uses, and a key that cannot be used safely must not validate anything. |
| | | 109 | | /// </para> |
| | | 110 | | /// <para> |
| | | 111 | | /// An entry that carries a <c>d</c> member is skipped too: a published private part means the key is |
| | | 112 | | /// compromised, and trusting its public half would accept anyone's forgeries. Each key is returned |
| | | 113 | | /// once; a later entry with the same id (the thumbprint, so the same key) is skipped. A parsed key |
| | | 114 | | /// can verify but never sign. |
| | | 115 | | /// </para> |
| | | 116 | | /// <para> |
| | | 117 | | /// Text that JSON accepts but cannot be decoded, such as a lone surrogate escape in a name or value, |
| | | 118 | | /// skips its entry, or is a <see cref="FormatException"/> when it is in the key set's own members. |
| | | 119 | | /// </para> |
| | | 120 | | /// </remarks> |
| | | 121 | | /// <exception cref="FormatException">The text is not JSON, or not an object with a <c>keys</c> array.</exception> |
| | | 122 | | public static IReadOnlyList<JwtKey> Parse(string json) |
| | | 123 | | { |
| | 25 | 124 | | ArgumentNullException.ThrowIfNull(json); |
| | | 125 | | |
| | | 126 | | JsonDocument document; |
| | | 127 | | |
| | | 128 | | try |
| | | 129 | | { |
| | 25 | 130 | | document = JsonDocument.Parse(json); |
| | 24 | 131 | | } |
| | 1 | 132 | | catch (JsonException) |
| | | 133 | | { |
| | 1 | 134 | | throw new FormatException("The key set is not JSON."); |
| | | 135 | | } |
| | | 136 | | |
| | 24 | 137 | | using (document) |
| | | 138 | | { |
| | 24 | 139 | | var root = document.RootElement; |
| | | 140 | | |
| | 24 | 141 | | var entries = default(JsonElement); |
| | 24 | 142 | | var isKeySet = false; |
| | | 143 | | |
| | | 144 | | try |
| | | 145 | | { |
| | 24 | 146 | | isKeySet = root.ValueKind == JsonValueKind.Object && |
| | 24 | 147 | | root.TryGetProperty("keys", out entries) && |
| | 24 | 148 | | entries.ValueKind == JsonValueKind.Array; |
| | 23 | 149 | | } |
| | 1 | 150 | | catch (InvalidOperationException) |
| | | 151 | | { |
| | 1 | 152 | | throw new FormatException("The key set has a member name that is not valid text."); |
| | | 153 | | } |
| | | 154 | | |
| | 23 | 155 | | if (!isKeySet) |
| | | 156 | | { |
| | 3 | 157 | | throw new FormatException("A key set is a JSON object with a \"keys\" array."); |
| | | 158 | | } |
| | | 159 | | |
| | 20 | 160 | | var keys = new List<JwtKey>(); |
| | | 161 | | |
| | 100 | 162 | | foreach (var entry in entries.EnumerateArray()) |
| | | 163 | | { |
| | 34 | 164 | | if (TryRead(entry) is { } key && keys.All(read => read.Id != key.Id)) |
| | | 165 | | { |
| | 17 | 166 | | keys.Add(key); |
| | | 167 | | } |
| | | 168 | | } |
| | | 169 | | |
| | 20 | 170 | | return keys; |
| | | 171 | | } |
| | 20 | 172 | | } |
| | | 173 | | |
| | | 174 | | /// <summary>The verification key an entry describes, or <see langword="null"/> when it is not one to use.</summary> |
| | | 175 | | private static JwtKey? TryRead(JsonElement entry) |
| | | 176 | | { |
| | | 177 | | try |
| | | 178 | | { |
| | 30 | 179 | | return ReadEntry(entry); |
| | | 180 | | } |
| | 3 | 181 | | catch (InvalidOperationException) |
| | | 182 | | { |
| | 3 | 183 | | return null; |
| | | 184 | | } |
| | 30 | 185 | | } |
| | | 186 | | |
| | | 187 | | private static JwtKey? ReadEntry(JsonElement entry) |
| | | 188 | | { |
| | 30 | 189 | | if (entry.ValueKind != JsonValueKind.Object || |
| | 30 | 190 | | entry.TryGetProperty(PrivateScalar, out _) || |
| | 30 | 191 | | Required(entry, Kty) != "EC" || |
| | 30 | 192 | | Required(entry, Crv) != P256.JwkCurveName || |
| | 30 | 193 | | !OptionalIs(entry, Use, Signature) || |
| | 30 | 194 | | !OptionalIs(entry, Alg, JwtAlgorithms.ES256) || |
| | 30 | 195 | | Required(entry, X) is not { } x || |
| | 30 | 196 | | Required(entry, Y) is not { } y) |
| | | 197 | | { |
| | 7 | 198 | | return null; |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | JwtKey key; |
| | | 202 | | |
| | | 203 | | try |
| | | 204 | | { |
| | 20 | 205 | | key = JwtKey.FromEcPublicJwk(x, y); |
| | 19 | 206 | | } |
| | 1 | 207 | | catch (ArgumentException) |
| | | 208 | | { |
| | 1 | 209 | | return null; |
| | | 210 | | } |
| | | 211 | | |
| | 19 | 212 | | return OptionalIs(entry, Kid, key.Id) ? key : null; |
| | 1 | 213 | | } |
| | | 214 | | |
| | | 215 | | /// <summary>A member's string value, or <see langword="null"/> when it is absent or not a string.</summary> |
| | | 216 | | private static string? Required(JsonElement entry, string name) => |
| | 90 | 217 | | entry.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; |
| | | 218 | | |
| | | 219 | | /// <summary>Whether a member is absent, or present as exactly <paramref name="expected"/>.</summary> |
| | | 220 | | private static bool OptionalIs(JsonElement entry, string name, string expected) => |
| | 63 | 221 | | !entry.TryGetProperty(name, out var value) || |
| | 63 | 222 | | (value.ValueKind == JsonValueKind.String && value.GetString() == expected); |
| | | 223 | | } |