< Summary

Information
Class: ArturRios.Jwt.JwtKey
Assembly: ArturRios.Jwt
File(s): /home/runner/work/dotnet-jwt/dotnet-jwt/src/JwtKey.cs
Line coverage
98%
Covered lines: 61
Uncovered lines: 1
Coverable lines: 62
Total lines: 225
Line coverage: 98.3%
Branch coverage
81%
Covered branches: 31
Total branches: 38
Branch coverage: 81.5%
Method coverage
100%
Covered methods: 14
Fully covered methods: 13
Total methods: 14
Method coverage: 100%
Full method coverage: 92.8%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Id()100%11100%
.ctor(...)100%11100%
get_Algorithm()100%22100%
get_IsAsymmetric()100%11100%
get_CanSign()100%22100%
get_Thumbprint()100%44100%
get_PublicX()100%11100%
get_PublicY()100%11100%
FromEcPrivateKeyPem(...)100%88100%
FromEcPublicJwk(...)100%22100%
ToPublicKey()100%44100%
ToSecurityKey()75%4480%
Equals(...)50%1212100%
GetHashCode()100%11100%

File(s)

/home/runner/work/dotnet-jwt/dotnet-jwt/src/JwtKey.cs

#LineLine coverage
 1using System.Buffers.Text;
 2using System.Security.Cryptography;
 3using System.Text;
 4using Microsoft.IdentityModel.Tokens;
 5
 6namespace 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
 77642public 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)
 9356        : this(JwkThumbprint.ForP256(x, y), string.Empty)
 57    {
 9358        _ecdsa = ecdsa;
 9359        _hasPrivateKey = hasPrivateKey;
 9360        PublicX = x;
 9361        PublicY = y;
 9362    }
 63
 64    /// <summary>The one algorithm this key signs and validates: HS256 for a secret, ES256 for an EC key.</summary>
 365    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>
 23868    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>
 4374    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>
 3477    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>
 15580    internal string? PublicX { get; }
 81
 82    /// <summary>The base64url y coordinate of an ES256 key's public half.</summary>
 15283    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    {
 4895        ArgumentNullException.ThrowIfNull(pem);
 96
 97        ECParameters parameters;
 98
 4899        using (var imported = ECDsa.Create())
 100        {
 101            try
 102            {
 48103                imported.ImportFromPem(pem);
 44104                parameters = imported.ExportParameters(includePrivateParameters: true);
 43105            }
 5106            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.
 5110                throw new ArgumentException(NotAP256PrivateKey, nameof(pem));
 111            }
 112        }
 113
 114        try
 115        {
 43116            if (parameters.D is null || !P256.IsNamed(parameters.Curve) ||
 43117                !P256.IsOnCurve(parameters.Q.X, parameters.Q.Y))
 118            {
 2119                throw new ArgumentException(NotAP256PrivateKey, nameof(pem));
 120            }
 121
 41122            var ecdsa = ECDsa.Create(new ECParameters { Curve = P256.Curve, Q = parameters.Q, D = parameters.D });
 123
 41124            return new JwtKey(ecdsa, Base64Url.EncodeToString(parameters.Q.X), Base64Url.EncodeToString(parameters.Q.Y),
 41125                hasPrivateKey: true);
 126        }
 127        finally
 128        {
 43129            if (parameters.D is not null)
 130            {
 43131                CryptographicOperations.ZeroMemory(parameters.D);
 132            }
 43133        }
 41134    }
 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    {
 56145        ArgumentNullException.ThrowIfNull(x);
 56146        ArgumentNullException.ThrowIfNull(y);
 147
 148        byte[] xBytes;
 149        byte[] yBytes;
 150
 151        try
 152        {
 56153            xBytes = Base64Url.DecodeFromChars(x);
 55154            yBytes = Base64Url.DecodeFromChars(y);
 55155        }
 1156        catch (FormatException)
 157        {
 1158            throw new ArgumentException(NotAP256Point);
 159        }
 160
 55161        if (!P256.IsOnCurve(xBytes, yBytes))
 162        {
 3163            throw new ArgumentException(NotAP256Point);
 164        }
 165
 52166        var ecdsa = ECDsa.Create(new ECParameters
 52167        {
 52168            Curve = P256.Curve,
 52169            Q = new ECPoint { X = xBytes, Y = yBytes }
 52170        });
 171
 172        // Re-encoded from the bytes, so the thumbprint is computed over the canonical encoding whatever
 173        // padding or spelling the source used.
 52174        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() =>
 30180        PublicX is null || PublicY is null
 30181            ? throw new InvalidOperationException(
 30182                "An HS256 key is a shared secret: it has no public half, and publishing it would let anyone sign.")
 30183            : 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    {
 29197        if (_ecdsa is not null)
 198        {
 28199            return new ECDsaSecurityKey(_ecdsa) { KeyId = Id };
 200        }
 201
 1202        if (string.IsNullOrWhiteSpace(Secret))
 203        {
 1204            throw new InvalidOperationException("A key with a blank secret signs nothing and accepts nothing.");
 205        }
 206
 0207        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) =>
 5215        other is not null &&
 5216        EqualityContract == other.EqualityContract &&
 5217        Id == other.Id &&
 5218        Secret == other.Secret &&
 5219        PublicX == other.PublicX &&
 5220        PublicY == other.PublicY &&
 5221        CanSign == other.CanSign;
 222
 223    /// <inheritdoc />
 2224    public override int GetHashCode() => HashCode.Combine(EqualityContract, Id, Secret, PublicX, PublicY, CanSign);
 225}