| | | 1 | | using System.Buffers.Text; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text; |
| | | 4 | | using Microsoft.IdentityModel.Tokens; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Jwt; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// A key and the identifier that names it, so a token can say which key signed it and a validator can |
| | | 10 | | /// accept more than one. An HS256 key is a shared secret; an ES256 key is an EC P-256 key pair, or the |
| | | 11 | | /// public half of one. |
| | | 12 | | /// </summary> |
| | | 13 | | /// <remarks> |
| | | 14 | | /// <para> |
| | | 15 | | /// The identifier exists to make key rotation possible. Signing with one key while still accepting |
| | | 16 | | /// another is what turns replacing a key from a cutover — where every token in flight becomes invalid |
| | | 17 | | /// at once — into a rotation. The identifier is written to the token's <c>kid</c> header, and it selects |
| | | 18 | | /// a key and nothing else: the signature still decides whether the token is valid. |
| | | 19 | | /// </para> |
| | | 20 | | /// <para> |
| | | 21 | | /// <b>ES256 keys</b> come from <see cref="FromEcPrivateKeyPem"/> or <see cref="FromEcPublicJwk"/>, never |
| | | 22 | | /// from the constructor. Their <see cref="Id"/> is their RFC 7638 <see cref="Thumbprint"/>, derived from |
| | | 23 | | /// the public half, so it stays attached to the same key wherever the key is published and is never |
| | | 24 | | /// configured; their <see cref="Secret"/> is empty. A <see langword="with"/> expression that changes |
| | | 25 | | /// either makes a key no published key set will match, and <see cref="JwtConfigurationValidator"/> |
| | | 26 | | /// refuses one whose id is not its thumbprint. |
| | | 27 | | /// </para> |
| | | 28 | | /// <para> |
| | | 29 | | /// An ES256 key holds its platform <see cref="ECDsa"/> for its whole life and is not |
| | | 30 | | /// <see cref="IDisposable"/>: keys are long-lived configuration, and the record must stay binary |
| | | 31 | | /// compatible with consumers built against the HS256-only one. The cost is one unmanaged handle per key |
| | | 32 | | /// object, released when it is finalized, so read a key once and keep it rather than reading it per request. |
| | | 33 | | /// </para> |
| | | 34 | | /// <para> |
| | | 35 | | /// Each key validates its own algorithm only — a secret HS256, an EC key ES256 — whatever a token's |
| | | 36 | | /// <c>alg</c> header says. That is what makes the classic confusion, an HS256 token keyed with a |
| | | 37 | | /// published public key, fail by construction. |
| | | 38 | | /// </para> |
| | | 39 | | /// </remarks> |
| | | 40 | | /// <param name="Id">Names the key. Written to the token's <c>kid</c> header, so keep it stable and free of secrets.</pa |
| | | 41 | | /// <param name="Secret">The secret used to sign and validate HS256. HMAC-SHA256 needs at least 32 bytes. Empty for an E |
| | 776 | 42 | | public record JwtKey(string Id, string Secret) |
| | | 43 | | { |
| | | 44 | | private const string NotAP256PrivateKey = |
| | | 45 | | "The PEM is not a P-256 (prime256v1) private key. Generate one with " + |
| | | 46 | | "'openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256'."; |
| | | 47 | | |
| | | 48 | | private const string NotAP256Point = |
| | | 49 | | "The coordinates are not a point on P-256: each must be the base64url encoding of 32 bytes, and " + |
| | | 50 | | "together they must satisfy the curve's equation."; |
| | | 51 | | |
| | | 52 | | private readonly ECDsa? _ecdsa; |
| | | 53 | | private readonly bool _hasPrivateKey; |
| | | 54 | | |
| | | 55 | | private JwtKey(ECDsa ecdsa, string x, string y, bool hasPrivateKey) |
| | 93 | 56 | | : this(JwkThumbprint.ForP256(x, y), string.Empty) |
| | | 57 | | { |
| | 93 | 58 | | _ecdsa = ecdsa; |
| | 93 | 59 | | _hasPrivateKey = hasPrivateKey; |
| | 93 | 60 | | PublicX = x; |
| | 93 | 61 | | PublicY = y; |
| | 93 | 62 | | } |
| | | 63 | | |
| | | 64 | | /// <summary>The one algorithm this key signs and validates: HS256 for a secret, ES256 for an EC key.</summary> |
| | 3 | 65 | | public string Algorithm => _ecdsa is null ? JwtAlgorithms.HS256 : JwtAlgorithms.ES256; |
| | | 66 | | |
| | | 67 | | /// <summary>Whether this is an ES256 key rather than an HS256 secret.</summary> |
| | 238 | 68 | | public bool IsAsymmetric => _ecdsa is not null; |
| | | 69 | | |
| | | 70 | | /// <summary> |
| | | 71 | | /// Whether this key can sign: a secret that is not blank, or an EC key holding its private half. A |
| | | 72 | | /// public key read from a key set validates but cannot sign. |
| | | 73 | | /// </summary> |
| | 43 | 74 | | public bool CanSign => _ecdsa is null ? !string.IsNullOrWhiteSpace(Secret) : _hasPrivateKey; |
| | | 75 | | |
| | | 76 | | /// <summary>The RFC 7638 thumbprint of an ES256 key's public half; <see langword="null"/> for a secret.</summary> |
| | 34 | 77 | | public string? Thumbprint => PublicX is null || PublicY is null ? null : JwkThumbprint.ForP256(PublicX, PublicY); |
| | | 78 | | |
| | | 79 | | /// <summary>The base64url x coordinate of an ES256 key's public half.</summary> |
| | 155 | 80 | | internal string? PublicX { get; } |
| | | 81 | | |
| | | 82 | | /// <summary>The base64url y coordinate of an ES256 key's public half.</summary> |
| | 152 | 83 | | internal string? PublicY { get; } |
| | | 84 | | |
| | | 85 | | /// <summary> |
| | | 86 | | /// Reads an ES256 signing key from a PEM: PKCS#8 (<c>BEGIN PRIVATE KEY</c>, what <c>openssl genpkey</c> |
| | | 87 | | /// writes) or SEC1 (<c>BEGIN EC PRIVATE KEY</c>). |
| | | 88 | | /// </summary> |
| | | 89 | | /// <param name="pem">The private key. Its contents never appear in an exception message.</param> |
| | | 90 | | /// <exception cref="ArgumentException"> |
| | | 91 | | /// The PEM is unreadable, holds no private key, or holds a key on any curve but P-256. |
| | | 92 | | /// </exception> |
| | | 93 | | public static JwtKey FromEcPrivateKeyPem(string pem) |
| | | 94 | | { |
| | 48 | 95 | | ArgumentNullException.ThrowIfNull(pem); |
| | | 96 | | |
| | | 97 | | ECParameters parameters; |
| | | 98 | | |
| | 48 | 99 | | using (var imported = ECDsa.Create()) |
| | | 100 | | { |
| | | 101 | | try |
| | | 102 | | { |
| | 48 | 103 | | imported.ImportFromPem(pem); |
| | 44 | 104 | | parameters = imported.ExportParameters(includePrivateParameters: true); |
| | 43 | 105 | | } |
| | 5 | 106 | | catch (Exception exception) when (exception is ArgumentException or CryptographicException) |
| | | 107 | | { |
| | | 108 | | // The platform's message is replaced, not wrapped: what matters is which key to supply, |
| | | 109 | | // and nothing derived from the input should travel further than this method. |
| | 5 | 110 | | throw new ArgumentException(NotAP256PrivateKey, nameof(pem)); |
| | | 111 | | } |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | try |
| | | 115 | | { |
| | 43 | 116 | | if (parameters.D is null || !P256.IsNamed(parameters.Curve) || |
| | 43 | 117 | | !P256.IsOnCurve(parameters.Q.X, parameters.Q.Y)) |
| | | 118 | | { |
| | 2 | 119 | | throw new ArgumentException(NotAP256PrivateKey, nameof(pem)); |
| | | 120 | | } |
| | | 121 | | |
| | 41 | 122 | | var ecdsa = ECDsa.Create(new ECParameters { Curve = P256.Curve, Q = parameters.Q, D = parameters.D }); |
| | | 123 | | |
| | 41 | 124 | | return new JwtKey(ecdsa, Base64Url.EncodeToString(parameters.Q.X), Base64Url.EncodeToString(parameters.Q.Y), |
| | 41 | 125 | | hasPrivateKey: true); |
| | | 126 | | } |
| | | 127 | | finally |
| | | 128 | | { |
| | 43 | 129 | | if (parameters.D is not null) |
| | | 130 | | { |
| | 43 | 131 | | CryptographicOperations.ZeroMemory(parameters.D); |
| | | 132 | | } |
| | 43 | 133 | | } |
| | 41 | 134 | | } |
| | | 135 | | |
| | | 136 | | /// <summary> |
| | | 137 | | /// Builds an ES256 verification key from a P-256 JWK's <c>x</c> and <c>y</c> members, as a JSON Web Key |
| | | 138 | | /// Set publishes them. |
| | | 139 | | /// </summary> |
| | | 140 | | /// <exception cref="ArgumentException"> |
| | | 141 | | /// A coordinate is not base64url, is not 32 bytes, or the point is not on P-256. |
| | | 142 | | /// </exception> |
| | | 143 | | public static JwtKey FromEcPublicJwk(string x, string y) |
| | | 144 | | { |
| | 56 | 145 | | ArgumentNullException.ThrowIfNull(x); |
| | 56 | 146 | | ArgumentNullException.ThrowIfNull(y); |
| | | 147 | | |
| | | 148 | | byte[] xBytes; |
| | | 149 | | byte[] yBytes; |
| | | 150 | | |
| | | 151 | | try |
| | | 152 | | { |
| | 56 | 153 | | xBytes = Base64Url.DecodeFromChars(x); |
| | 55 | 154 | | yBytes = Base64Url.DecodeFromChars(y); |
| | 55 | 155 | | } |
| | 1 | 156 | | catch (FormatException) |
| | | 157 | | { |
| | 1 | 158 | | throw new ArgumentException(NotAP256Point); |
| | | 159 | | } |
| | | 160 | | |
| | 55 | 161 | | if (!P256.IsOnCurve(xBytes, yBytes)) |
| | | 162 | | { |
| | 3 | 163 | | throw new ArgumentException(NotAP256Point); |
| | | 164 | | } |
| | | 165 | | |
| | 52 | 166 | | var ecdsa = ECDsa.Create(new ECParameters |
| | 52 | 167 | | { |
| | 52 | 168 | | Curve = P256.Curve, |
| | 52 | 169 | | Q = new ECPoint { X = xBytes, Y = yBytes } |
| | 52 | 170 | | }); |
| | | 171 | | |
| | | 172 | | // Re-encoded from the bytes, so the thumbprint is computed over the canonical encoding whatever |
| | | 173 | | // padding or spelling the source used. |
| | 52 | 174 | | return new JwtKey(ecdsa, Base64Url.EncodeToString(xBytes), Base64Url.EncodeToString(yBytes), hasPrivateKey: fals |
| | | 175 | | } |
| | | 176 | | |
| | | 177 | | /// <summary>The public half of an ES256 key, under the same id: what may be published and handed to validators.</su |
| | | 178 | | /// <exception cref="InvalidOperationException">This is an HS256 secret, which has no public half.</exception> |
| | | 179 | | public JwtKey ToPublicKey() => |
| | 30 | 180 | | PublicX is null || PublicY is null |
| | 30 | 181 | | ? throw new InvalidOperationException( |
| | 30 | 182 | | "An HS256 key is a shared secret: it has no public half, and publishing it would let anyone sign.") |
| | 30 | 183 | | : FromEcPublicJwk(PublicX, PublicY) with { Id = Id }; |
| | | 184 | | |
| | | 185 | | /// <summary> |
| | | 186 | | /// The key as Microsoft.IdentityModel uses it, carrying <see cref="Id"/> as its <c>KeyId</c>: an |
| | | 187 | | /// <see cref="ECDsaSecurityKey"/> for an ES256 key, or a <see cref="SymmetricSecurityKey"/> over the |
| | | 188 | | /// secret's ASCII bytes — the bytes <see cref="JwtHandler"/> has always signed with — for an HS256 one. |
| | | 189 | | /// </summary> |
| | | 190 | | /// <remarks> |
| | | 191 | | /// Pair it with exactly one algorithm, this key's <see cref="Algorithm"/>, as <see cref="JwtHandler"/> |
| | | 192 | | /// does: accepting both algorithms against both kinds of key is what an algorithm-confusion attack needs. |
| | | 193 | | /// </remarks> |
| | | 194 | | /// <exception cref="InvalidOperationException">An HS256 key whose secret is blank.</exception> |
| | | 195 | | public SecurityKey ToSecurityKey() |
| | | 196 | | { |
| | 29 | 197 | | if (_ecdsa is not null) |
| | | 198 | | { |
| | 28 | 199 | | return new ECDsaSecurityKey(_ecdsa) { KeyId = Id }; |
| | | 200 | | } |
| | | 201 | | |
| | 1 | 202 | | if (string.IsNullOrWhiteSpace(Secret)) |
| | | 203 | | { |
| | 1 | 204 | | throw new InvalidOperationException("A key with a blank secret signs nothing and accepts nothing."); |
| | | 205 | | } |
| | | 206 | | |
| | 0 | 207 | | return new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Secret)) { KeyId = Id }; |
| | | 208 | | } |
| | | 209 | | |
| | | 210 | | /// <summary> |
| | | 211 | | /// Equal when the id, the secret and the public key match and both can, or both cannot, sign. The |
| | | 212 | | /// platform key object behind an ES256 key is not compared, so two keys read from one PEM are equal. |
| | | 213 | | /// </summary> |
| | | 214 | | public virtual bool Equals(JwtKey? other) => |
| | 5 | 215 | | other is not null && |
| | 5 | 216 | | EqualityContract == other.EqualityContract && |
| | 5 | 217 | | Id == other.Id && |
| | 5 | 218 | | Secret == other.Secret && |
| | 5 | 219 | | PublicX == other.PublicX && |
| | 5 | 220 | | PublicY == other.PublicY && |
| | 5 | 221 | | CanSign == other.CanSign; |
| | | 222 | | |
| | | 223 | | /// <inheritdoc /> |
| | 2 | 224 | | public override int GetHashCode() => HashCode.Combine(EqualityContract, Id, Secret, PublicX, PublicY, CanSign); |
| | | 225 | | } |