diff --git a/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs b/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs index f0f415d8ed..c8cb6b7312 100644 --- a/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs +++ b/src/Microsoft.IdentityModel.Dpop/DPoPConstants.cs @@ -26,4 +26,32 @@ public static class DPoPConstants /// The DPoP nonce HTTP header name. /// public const string DPoPNonceHeaderName = "DPoP-Nonce"; + + /// + /// The default maximum DPoP proof lifetime in seconds, measured from the iat claim (5 minutes). + /// + public const int DefaultMaxLifetimeInSeconds = 300; + + /// + /// The default clock skew tolerance in seconds applied to DPoP proof timestamp validation (5 minutes). + /// + public const int DefaultClockSkewInSeconds = 300; + + /// + /// The default maximum DPoP proof JWT size in bytes (8 KiB). + /// Realistic proofs are well under 2 KiB; the cap bounds parse work for malformed input. + /// + public const int DefaultMaxProofTokenSizeInBytes = 8 * 1024; + + /// + /// The default maximum RSA modulus size in bits permitted for DPoP proof signing keys. + /// Bounds the cost of verifying client-controlled keys (verification cost is super-linear in modulus size). + /// + public const int DefaultMaxRsaKeySizeInBits = 4096; + + /// + /// The default minimum RSA modulus size in bits permitted for DPoP proof signing keys. + /// Matches NIST SP 800-131A guidance for asymmetric signature verification beyond 2030. + /// + public const int DefaultMinRsaKeySizeInBits = 2048; } diff --git a/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs b/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs index 142ab35a76..e06de559bb 100644 --- a/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs +++ b/src/Microsoft.IdentityModel.Dpop/DPoPProofValidator.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Buffers; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -59,6 +60,9 @@ public virtual async Task ValidateAsync( if (string.IsNullOrWhiteSpace(dpopProofJwt)) return DPoPValidationResult.Failed("DPoP proof is empty."); + if (dpopProofJwt.Length > options.MaxProofTokenSizeInBytes) + return DPoPValidationResult.Failed("DPoP proof exceeds the maximum allowed size."); + if (string.IsNullOrWhiteSpace(accessToken)) return DPoPValidationResult.Failed("Access token is empty."); @@ -92,9 +96,23 @@ internal static string ComputeAccessTokenHash(string accessToken) { _ = accessToken ?? throw new ArgumentNullException(nameof(accessToken)); - // Encoding.ASCII is safe here — access tokens are JWTs (base64url-encoded segments - // separated by dots), so every character is guaranteed ASCII. - var tokenBytes = Encoding.ASCII.GetBytes(accessToken); +#if NET6_0_OR_GREATER + // Typical access tokens (1-2 KB) hash without any heap allocation on the input side; + // larger tokens fall through to the heap path below. 1 KB cap keeps the stack budget + // safe for use under deep ASP.NET pipelines. + const int StackAllocThreshold = 1024; + int maxBytes = Encoding.UTF8.GetMaxByteCount(accessToken.Length); + if (maxBytes <= StackAllocThreshold) + { + Span tokenSpan = stackalloc byte[StackAllocThreshold]; + int written = Encoding.UTF8.GetBytes(accessToken, tokenSpan); + Span hashSpan = stackalloc byte[32]; + SHA256.HashData(tokenSpan.Slice(0, written), hashSpan); + return Base64UrlEncoder.Encode(hashSpan.ToArray()); + } +#endif + + var tokenBytes = Encoding.UTF8.GetBytes(accessToken); #if NET6_0_OR_GREATER var hash = SHA256.HashData(tokenBytes); #else @@ -128,6 +146,142 @@ internal static bool ContainsPrivateKeyMaterial(JsonWebKey jwk) !string.IsNullOrEmpty(jwk.QI); } + /// + /// Converts a to an asymmetric from its + /// public key parameters (n/e for RSA, crv/x/y for EC). + /// + internal static bool TryConvertToAsymmetricKeyFromBareParameters(JsonWebKey jwk, out SecurityKey key) + { + _ = jwk ?? throw new ArgumentNullException(nameof(jwk)); + + key = null; + + if (JsonWebAlgorithmsKeyTypes.RSA.Equals(jwk.Kty)) + { + return JsonWebKeyConverter.TryCreateToRsaSecurityKey(jwk, out key); + } + else if (JsonWebAlgorithmsKeyTypes.EllipticCurve.Equals(jwk.Kty)) + { + return JsonWebKeyConverter.TryConvertToECDsaSecurityKey(jwk, out key); + } + + return false; + } + + /// + /// Verifies the DPoP proof signature using a pooled buffer for the signing input. + /// + private static bool VerifyProofSignature(JsonWebToken proofToken, SignatureProvider signatureProvider) + { + string encodedToken = proofToken.EncodedToken; + string encodedSignature = proofToken.EncodedSignature; + // JWS signing input is "header.payload"; subtract the signature length plus 1 for the '.' separator that precedes it. + int signingInputLength = encodedToken.Length - encodedSignature.Length - 1; + if (signingInputLength <= 0) + { + // Defensive: JsonWebToken parsing should already guarantee header.payload.signature shape, + // but bail out before Rent(negative) would throw if a malformed token ever reached us. + return false; + } + + byte[] messageBuffer = ArrayPool.Shared.Rent(signingInputLength); + try + { + Encoding.ASCII.GetBytes(encodedToken, 0, signingInputLength, messageBuffer, 0); + byte[] signatureBytes = Base64UrlEncoder.DecodeBytes(encodedSignature); + return signatureProvider.Verify(messageBuffer, 0, signingInputLength, signatureBytes, 0, signatureBytes.Length); + } + finally + { + ArrayPool.Shared.Return(messageBuffer); + } + } + + /// + /// Constant-time UTF-8 comparison of two strings. On TFMs that support span-based encoding the + /// UTF-8 bytes are produced into stack-allocated buffers so the hot comparison paths + /// (ath, nonce, cnf.jkt) avoid heap allocations entirely. Larger inputs and older TFMs fall back + /// to the allocating helper. + /// + private static bool AreEqualUtf8(string a, string b) + { + if (a == null || b == null) + { + return false; + } + +#if NET6_0_OR_GREATER + // 256 bytes covers ath (~43), nonce (typically ≤ 128), and cnf.jkt (~43) with headroom. + const int StackAllocThreshold = 256; + int maxBytesA = Encoding.UTF8.GetMaxByteCount(a.Length); + int maxBytesB = Encoding.UTF8.GetMaxByteCount(b.Length); + if (maxBytesA <= StackAllocThreshold && maxBytesB <= StackAllocThreshold) + { + Span bufA = stackalloc byte[StackAllocThreshold]; + Span bufB = stackalloc byte[StackAllocThreshold]; + int lenA = Encoding.UTF8.GetBytes(a, bufA); + int lenB = Encoding.UTF8.GetBytes(b, bufB); + return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(bufA.Slice(0, lenA), bufB.Slice(0, lenB)); + } +#endif + return Utility.AreEqual(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b)); + } + + /// + /// Validates that the JWK is acceptable for the given algorithm — kty/alg/crv consistency + /// plus configured RSA modulus bounds. Returns a human-readable reason on rejection, or null on success. + /// + private static string ValidateJwkForAlgorithm(string alg, JsonWebKey jwk, DPoPValidationOptions options) + { + if (jwk.Kty != JsonWebAlgorithmsKeyTypes.RSA && jwk.Kty != JsonWebAlgorithmsKeyTypes.EllipticCurve) + return $"DPoP proof JWK 'kty' '{jwk.Kty}' is not supported; expected 'RSA' or 'EC'."; + + if (!jwk.IsSupportedAlgorithm(alg)) + return $"DPoP proof algorithm '{alg}' is not supported by the JWK (kty '{jwk.Kty}')."; + + if (jwk.Kty == JsonWebAlgorithmsKeyTypes.RSA) + { + if (jwk.N == null) + return "DPoP proof RSA JWK is missing the 'n' parameter."; + + // Bound the RSA modulus size without decoding base64url. Each base64url character carries + // 6 bits of payload, so N.Length * 6 is an upper bound on the encoded modulus bit length + // (the true value is within 8 bits of this ceiling because the final base64url char may + // contribute fewer than 6 significant bits and the decoded byte string can include a leading + // 0x00). We add 8 to the maximum to avoid rejecting keys at the boundary, and compare the + // ceiling directly to the minimum so we only reject when even the most generous interpretation + // is below the floor. Runs before key import to bound DoS cost on client-controlled keys. + int encodedBitCeiling = jwk.N.Length * 6; + if (encodedBitCeiling > options.MaxRsaKeySizeInBits + 8) + return "DPoP proof RSA key exceeds the maximum allowed size."; + + if (encodedBitCeiling < options.MinRsaKeySizeInBits) + return "DPoP proof RSA key is below the minimum allowed size."; + + return null; + } + + // EC: pin curve to alg. + string expectedCrv = alg switch + { + SecurityAlgorithms.EcdsaSha256 or SecurityAlgorithms.EcdsaSha256Signature => JsonWebKeyECTypes.P256, + SecurityAlgorithms.EcdsaSha384 or SecurityAlgorithms.EcdsaSha384Signature => JsonWebKeyECTypes.P384, + SecurityAlgorithms.EcdsaSha512 or SecurityAlgorithms.EcdsaSha512Signature => JsonWebKeyECTypes.P521, + _ => null, + }; + + if (expectedCrv == null) + return $"DPoP proof algorithm '{alg}' has no curve binding defined for EC keys."; + + bool crvMatches = jwk.Crv == expectedCrv + || (expectedCrv == JsonWebKeyECTypes.P521 && jwk.Crv == JsonWebKeyECTypes.P512); + + if (!crvMatches) + return $"DPoP proof algorithm '{alg}' requires curve '{expectedCrv}' but JWK 'crv' is '{jwk.Crv}'."; + + return null; + } + /// /// Computes the base64url-encoded SHA-256 JWK thumbprint per RFC 7638. /// @@ -207,23 +361,37 @@ private static async Task ValidateCoreAsync( return DPoPValidationResult.Failed("DPoP proof JWK must not contain private key material."); } - // Validate signature using extracted JWK - var validationParams = new TokenValidationParameters + string jwkRejectReason = ValidateJwkForAlgorithm(alg, jwk, options); + if (jwkRejectReason != null) { - ValidateIssuer = false, - ValidateAudience = false, - ValidateLifetime = false, - IssuerSigningKey = jwk, - ValidAlgorithms = new string[] { alg }, - }; + return DPoPValidationResult.Failed(jwkRejectReason); + } - var signatureResult = await s_tokenHandler - .ValidateTokenAsync(proofToken, validationParams, cancellationToken) - .ConfigureAwait(false); + // Convert the JWK to a SecurityKey from its public key parameters. + if (!TryConvertToAsymmetricKeyFromBareParameters(jwk, out SecurityKey signingKey)) + { + return DPoPValidationResult.Failed("DPoP proof JWK could not be converted to a supported asymmetric key."); + } - if (!signatureResult.IsValid) + // Verify the proof signature without caching the SignatureProvider. + CryptoProviderFactory cryptoProviderFactory = signingKey.CryptoProviderFactory ?? CryptoProviderFactory.Default; + SignatureProvider signatureProvider = null; + try + { + signatureProvider = cryptoProviderFactory.CreateForVerifying(signingKey, alg, cacheProvider: false); + if (!VerifyProofSignature(proofToken, signatureProvider)) + { + return DPoPValidationResult.Failed("DPoP proof signature validation failed."); + } + } + catch (Exception ex) + { + return DPoPValidationResult.Failed("DPoP proof signature validation failed.", ex); + } + finally { - return DPoPValidationResult.Failed("DPoP proof signature validation failed."); + if (signatureProvider != null) + cryptoProviderFactory.ReleaseSignatureProvider(signatureProvider); } // Validate htm matches HTTP method @@ -284,20 +452,6 @@ private static async Task ValidateCoreAsync( return DPoPValidationResult.Failed("DPoP proof is missing the 'jti' claim."); } - // Replay protection - if (options.JtiReplayCache != null) - { - var jtiExpiration = issuedAt.Add(maxAge); - bool added = await options.JtiReplayCache - .TryAddAsync(jtiValue, jtiExpiration, cancellationToken) - .ConfigureAwait(false); - - if (!added) - { - return DPoPValidationResult.Failed("DPoP proof 'jti' has already been used (replay detected)."); - } - } - // Validate nonce if expected (null = skip nonce validation) if (options.ExpectedNonce != null) { @@ -312,7 +466,7 @@ private static async Task ValidateCoreAsync( return DPoPValidationResult.NonceRequired(); } - if (!string.Equals(options.ExpectedNonce, nonceValue, StringComparison.Ordinal)) + if (!AreEqualUtf8(options.ExpectedNonce, nonceValue)) { return DPoPValidationResult.NonceValidationFailed(); } @@ -326,18 +480,32 @@ private static async Task ValidateCoreAsync( } var expectedAth = ComputeAccessTokenHash(accessToken); - if (!string.Equals(athValue, expectedAth, StringComparison.Ordinal)) + if (!AreEqualUtf8(athValue, expectedAth)) { return DPoPValidationResult.Failed("DPoP proof 'ath' claim does not match the access token hash."); } // Compute thumbprint and validate cnf.jkt binding var thumbprint = ComputeJwkThumbprint(jwk); - if (!Utility.AreEqual(Encoding.UTF8.GetBytes(expectedCnfJkt), Encoding.UTF8.GetBytes(thumbprint))) + if (!AreEqualUtf8(expectedCnfJkt, thumbprint)) { return DPoPValidationResult.Failed("DPoP proof JWK thumbprint does not match the access token cnf.jkt claim."); } + // Replay protection + if (options.JtiReplayCache != null) + { + var jtiExpiration = issuedAt.Add(maxAge); + bool added = await options.JtiReplayCache + .TryAddAsync(jtiValue, jtiExpiration, cancellationToken) + .ConfigureAwait(false); + + if (!added) + { + return DPoPValidationResult.Failed("DPoP proof 'jti' has already been used (replay detected)."); + } + } + string proofNonceForResult = proofToken.TryGetPayloadValue(DPoPClaimTypes.Nonce, out string proofNonce) ? proofNonce : null; return DPoPValidationResult.Success(proofNonceForResult); } diff --git a/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs b/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs index 9df8985539..5d219d7f96 100644 --- a/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs +++ b/src/Microsoft.IdentityModel.Dpop/DPoPValidationOptions.cs @@ -19,18 +19,79 @@ public class DPoPValidationOptions public ISet AllowedSigningAlgorithms { get; set; } = new HashSet(StringComparer.Ordinal) { "ES256", "PS256", "RS256" }; + private int _maxLifetimeInSeconds = DPoPConstants.DefaultMaxLifetimeInSeconds; + private int _clockSkewInSeconds = DPoPConstants.DefaultClockSkewInSeconds; + private int _maxProofTokenSizeInBytes = DPoPConstants.DefaultMaxProofTokenSizeInBytes; + private int _maxRsaKeySizeInBits = DPoPConstants.DefaultMaxRsaKeySizeInBits; + private int _minRsaKeySizeInBits = DPoPConstants.DefaultMinRsaKeySizeInBits; + /// /// Gets or sets the maximum proof lifetime in seconds, measured from the iat claim. - /// Default: 300 (5 minutes). + /// Default: . + /// + /// Thrown when set to a value less than 1. + public int MaxLifetimeInSeconds + { + get => _maxLifetimeInSeconds; + set => _maxLifetimeInSeconds = value < 1 + ? throw new ArgumentOutOfRangeException(nameof(value), value, "MaxLifetimeInSeconds must be greater than zero.") + : value; + } + + /// + /// Gets or sets the maximum DPoP proof JWT size in bytes. + /// Proofs exceeding this size are rejected before parsing. + /// Default: . + /// + /// Thrown when set to a value less than 1. + public int MaxProofTokenSizeInBytes + { + get => _maxProofTokenSizeInBytes; + set => _maxProofTokenSizeInBytes = value < 1 + ? throw new ArgumentOutOfRangeException(nameof(value), value, "MaxProofTokenSizeInBytes must be greater than zero.") + : value; + } + + /// + /// Gets or sets the maximum RSA modulus size in bits permitted for DPoP proof signing keys. + /// Bounds the cost of verifying client-controlled keys. + /// Default: . + /// + /// Thrown when set to a value less than 1. + public int MaxRsaKeySizeInBits + { + get => _maxRsaKeySizeInBits; + set => _maxRsaKeySizeInBits = value < 1 + ? throw new ArgumentOutOfRangeException(nameof(value), value, "MaxRsaKeySizeInBits must be greater than zero.") + : value; + } + + /// + /// Gets or sets the minimum RSA modulus size in bits required for DPoP proof signing keys. + /// Default: . /// - public int MaxLifetimeInSeconds { get; set; } = 300; + /// Thrown when set to a value less than 1. + public int MinRsaKeySizeInBits + { + get => _minRsaKeySizeInBits; + set => _minRsaKeySizeInBits = value < 1 + ? throw new ArgumentOutOfRangeException(nameof(value), value, "MinRsaKeySizeInBits must be greater than zero.") + : value; + } /// /// Gets or sets the clock skew tolerance in seconds. /// Applied both for proofs that are slightly too old and for proofs issued slightly in the future. - /// Default: 300 (5 minutes). + /// Default: . /// - public int ClockSkewInSeconds { get; set; } = 300; + /// Thrown when set to a negative value. + public int ClockSkewInSeconds + { + get => _clockSkewInSeconds; + set => _clockSkewInSeconds = value < 0 + ? throw new ArgumentOutOfRangeException(nameof(value), value, "ClockSkewInSeconds must be non-negative.") + : value; + } /// /// Gets or sets the expected nonce value (RP-provided). diff --git a/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt index 8b6c4916fe..357c697e68 100644 --- a/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Dpop/InternalAPI.Unshipped.txt @@ -1,3 +1,4 @@ static Microsoft.IdentityModel.Dpop.DPoPProofValidator.ComputeAccessTokenHash(string accessToken) -> string static Microsoft.IdentityModel.Dpop.DPoPProofValidator.ComputeJwkThumbprint(Microsoft.IdentityModel.Tokens.JsonWebKey jwk) -> string static Microsoft.IdentityModel.Dpop.DPoPProofValidator.ContainsPrivateKeyMaterial(Microsoft.IdentityModel.Tokens.JsonWebKey jwk) -> bool +static Microsoft.IdentityModel.Dpop.DPoPProofValidator.TryConvertToAsymmetricKeyFromBareParameters(Microsoft.IdentityModel.Tokens.JsonWebKey jwk, out Microsoft.IdentityModel.Tokens.SecurityKey key) -> bool diff --git a/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt index 0617f5ce63..8c5c7fbd72 100644 --- a/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Dpop/PublicAPI.Unshipped.txt @@ -4,6 +4,11 @@ const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Htu = "htu" -> string const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Iat = "iat" -> string const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Jti = "jti" -> string const Microsoft.IdentityModel.Dpop.DPoPClaimTypes.Nonce = "nonce" -> string +const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultClockSkewInSeconds = 300 -> int +const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMaxLifetimeInSeconds = 300 -> int +const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMaxProofTokenSizeInBytes = 8192 -> int +const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMaxRsaKeySizeInBits = 4096 -> int +const Microsoft.IdentityModel.Dpop.DPoPConstants.DefaultMinRsaKeySizeInBits = 2048 -> int const Microsoft.IdentityModel.Dpop.DPoPConstants.DPoPNonceHeaderName = "DPoP-Nonce" -> string const Microsoft.IdentityModel.Dpop.DPoPConstants.DPoPProofTokenType = "dpop+jwt" -> string const Microsoft.IdentityModel.Dpop.DPoPConstants.DPoPTokenType = "DPoP" -> string @@ -27,6 +32,12 @@ Microsoft.IdentityModel.Dpop.DPoPValidationOptions.JtiReplayCache.get -> Microso Microsoft.IdentityModel.Dpop.DPoPValidationOptions.JtiReplayCache.set -> void Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxLifetimeInSeconds.get -> int Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxLifetimeInSeconds.set -> void +Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxProofTokenSizeInBytes.get -> int +Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxProofTokenSizeInBytes.set -> void +Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxRsaKeySizeInBits.get -> int +Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MaxRsaKeySizeInBits.set -> void +Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MinRsaKeySizeInBits.get -> int +Microsoft.IdentityModel.Dpop.DPoPValidationOptions.MinRsaKeySizeInBits.set -> void Microsoft.IdentityModel.Dpop.DPoPValidationResult Microsoft.IdentityModel.Dpop.DPoPValidationResult.Error.get -> string Microsoft.IdentityModel.Dpop.DPoPValidationResult.ErrorCode.get -> string diff --git a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs index f650e2c0e2..04a1e44f68 100644 --- a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs +++ b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPE2ETests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -228,4 +229,188 @@ public async Task E2E_NonceMismatch_ReturnsNonceRequired() Assert.False(result.IsValid); Assert.True(result.IsNonceRequired); } -} \ No newline at end of file + + [Fact] + public async Task E2E_JwkWithMismatchedX5cAndBareKey_BindingFails() + { + var (accessToken, originalProofKey, _) = CreateAccessTokenWithCnfJkt(); + var nonce = "server-provided-nonce-xyz789"; + var method = "POST"; + var uri = new Uri("https://api.example.com/data"); + + var handler = new JsonWebTokenHandler(); + var at = handler.ReadJsonWebToken(accessToken); + Assert.True(at.TryGetPayloadValue("cnf", out System.Text.Json.JsonElement cnf)); + var jkt = cnf.GetProperty("jkt").GetString(); + + var validator = new DPoPProofValidator(); + var options = new DPoPValidationOptions + { + AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "RS256" }, + ExpectedNonce = nonce, + }; + + var otherKey = CreateTestRsa(); + var x5cValue = Convert.ToBase64String(new byte[] { 0x30, 0x82, 0x01, 0x00 }); + + byte[] athHash; + using (var sha = SHA256.Create()) + athHash = sha.ComputeHash(Encoding.ASCII.GetBytes(accessToken)); + var athValue = Base64UrlEncoder.Encode(athHash); + + var originalJwk = JsonWebKeyConverter.ConvertFromSecurityKey(new RsaSecurityKey(originalProofKey)); + var htu = uri.GetLeftPart(UriPartial.Path); + var iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var jti = Guid.NewGuid().ToString(); + + var headerJson = + "{\"typ\":\"dpop+jwt\",\"alg\":\"RS256\",\"jwk\":{" + + "\"kty\":\"" + originalJwk.Kty + "\"," + + "\"e\":\"" + originalJwk.E + "\"," + + "\"n\":\"" + originalJwk.N + "\"," + + "\"x5c\":[\"" + x5cValue + "\"]" + + "}}"; + + var payloadJson = + "{\"htm\":\"" + method + "\"," + + "\"htu\":\"" + htu + "\"," + + "\"iat\":" + iat + "," + + "\"jti\":\"" + jti + "\"," + + "\"ath\":\"" + athValue + "\"," + + "\"nonce\":\"" + nonce + "\"}"; + + var encodedHeader = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson)); + var encodedPayload = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(payloadJson)); + var signingInput = encodedHeader + "." + encodedPayload; + var signature = otherKey.SignData( + Encoding.ASCII.GetBytes(signingInput), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + var mismatchedProofJwt = signingInput + "." + Base64UrlEncoder.Encode(signature); + + var mismatchedResult = await validator.ValidateAsync( + mismatchedProofJwt, method, uri, accessToken, jkt, options); + + Assert.False( + mismatchedResult.IsValid, + $"Proof should be rejected. Error was: {mismatchedResult.Error ?? ""}"); + + var proofCreator = new DPoPProofCreator(new DPoPProofCreatorOptions + { + SigningCredentials = new SigningCredentials(new RsaSecurityKey(originalProofKey), SecurityAlgorithms.RsaSha256), + IncludeNonce = true, + Nonce = nonce, + }); + var wellFormedProofJwt = proofCreator.CreateProof(method, uri, accessToken); + var wellFormedResult = await validator.ValidateAsync( + wellFormedProofJwt, method, uri, accessToken, jkt, options); + Assert.True(wellFormedResult.IsValid, $"Well-formed proof should validate: {wellFormedResult.Error}"); + } + + [Fact] + public async Task E2E_ValidateProof_DoesNotCacheSignatureProvider() + { + var (accessToken, proofKey, _) = CreateAccessTokenWithCnfJkt(); + var nonce = "nonce-cache-single"; + var method = "GET"; + var uri = new Uri("https://api.example.com/cache-single"); + + var handler = new JsonWebTokenHandler(); + var at = handler.ReadJsonWebToken(accessToken); + Assert.True(at.TryGetPayloadValue("cnf", out System.Text.Json.JsonElement cnf)); + var jkt = cnf.GetProperty("jkt").GetString(); + + var options = new DPoPValidationOptions + { + AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "RS256" }, + ExpectedNonce = nonce, + }; + + var proofCreator = new DPoPProofCreator(new DPoPProofCreatorOptions + { + SigningCredentials = new SigningCredentials(new RsaSecurityKey(proofKey), SecurityAlgorithms.RsaSha256), + IncludeNonce = true, + Nonce = nonce, + }); + var proofJwt = proofCreator.CreateProof(method, uri, accessToken); + + var probeKey = new RsaSecurityKey(proofKey); + var providerTypeName = typeof(AsymmetricSignatureProvider).ToString(); + Assert.False( + CryptoProviderFactory.Default.CryptoProviderCache.TryGetSignatureProvider( + probeKey, SecurityAlgorithms.RsaSha256, providerTypeName, willCreateSignatures: false, out _), + "Pre-validation: the verifying SignatureProvider should not already be cached."); + + var validator = new DPoPProofValidator(); + var result = await validator.ValidateAsync(proofJwt, method, uri, accessToken, jkt, options); + Assert.True(result.IsValid, $"Proof should validate: {result.Error}"); + + Assert.False( + CryptoProviderFactory.Default.CryptoProviderCache.TryGetSignatureProvider( + probeKey, SecurityAlgorithms.RsaSha256, providerTypeName, willCreateSignatures: false, out _), + "Post-validation: validating a DPoP proof must not add the verifying SignatureProvider to the cache."); + } + + [Fact] + public async Task E2E_ValidateManyProofs_DoesNotPopulateCacheWithEphemeralKeys() + { + const int proofCount = 25; + var validator = new DPoPProofValidator(); + var providerTypeName = typeof(AsymmetricSignatureProvider).ToString(); + var proofKeys = new List(); + + try + { + for (int i = 0; i < proofCount; i++) + { + var (accessToken, proofKey, _) = CreateAccessTokenWithCnfJkt(); + proofKeys.Add(proofKey); + + var nonce = "nonce-cache-many-" + i; + var method = "POST"; + var uri = new Uri("https://api.example.com/cache-many-" + i); + + var handler = new JsonWebTokenHandler(); + var at = handler.ReadJsonWebToken(accessToken); + Assert.True(at.TryGetPayloadValue("cnf", out System.Text.Json.JsonElement cnf)); + var jkt = cnf.GetProperty("jkt").GetString(); + + var options = new DPoPValidationOptions + { + AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "RS256" }, + ExpectedNonce = nonce, + }; + + var proofCreator = new DPoPProofCreator(new DPoPProofCreatorOptions + { + SigningCredentials = new SigningCredentials(new RsaSecurityKey(proofKey), SecurityAlgorithms.RsaSha256), + IncludeNonce = true, + Nonce = nonce, + }); + var proofJwt = proofCreator.CreateProof(method, uri, accessToken); + + var result = await validator.ValidateAsync(proofJwt, method, uri, accessToken, jkt, options); + Assert.True(result.IsValid, $"Proof #{i} should validate: {result.Error}"); + } + + int cachedCount = 0; + foreach (var key in proofKeys) + { + var probeKey = new RsaSecurityKey(key); + if (CryptoProviderFactory.Default.CryptoProviderCache.TryGetSignatureProvider( + probeKey, SecurityAlgorithms.RsaSha256, providerTypeName, + willCreateSignatures: false, out _)) + { + cachedCount++; + } + } + + Assert.Equal(0, cachedCount); + } + finally + { + foreach (var key in proofKeys) + key.Dispose(); + } + } +} diff --git a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs index 34c7d86932..dbea4635ba 100644 --- a/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs +++ b/test/Microsoft.IdentityModel.Dpop.Tests/DPoPProofValidatorTests.cs @@ -991,6 +991,197 @@ public async Task ValidateAsync_RsaKey_Succeeds() #endregion + #region Size Limits & Granular Errors + + [Fact] + public async Task ValidateAsync_ProofExceedsMaxSize_ReturnsInvalid() + { + var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken(); + var options = DefaultOptions(); + options.MaxProofTokenSizeInBytes = 16; + + var result = await _validator.ValidateAsync( + proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options); + + Assert.False(result.IsValid); + Assert.Contains("maximum allowed size", result.Error); + } + + [Fact] + public async Task ValidateAsync_ProofUnderMaxSize_Succeeds() + { + var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken(); + var options = DefaultOptions(); + options.MaxProofTokenSizeInBytes = proof.Length + 1; + + var result = await _validator.ValidateAsync( + proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateAsync_RsaModulusBelowMin_ReturnsInvalid() + { + // 2048-bit key + an artificially high minimum to trigger the floor. + var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken(); + var options = DefaultOptions(); + options.MinRsaKeySizeInBits = 4096; + + var result = await _validator.ValidateAsync( + proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options); + + Assert.False(result.IsValid); + Assert.Contains("below the minimum allowed size", result.Error); + } + + [Fact] + public async Task ValidateAsync_RsaModulusAboveMax_ReturnsInvalid() + { + // 2048-bit key + an artificially low maximum to trigger the ceiling. + var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken(); + var options = DefaultOptions(); + options.MaxRsaKeySizeInBits = 1024; + + var result = await _validator.ValidateAsync( + proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options); + + Assert.False(result.IsValid); + Assert.Contains("exceeds the maximum allowed size", result.Error); + } + + [Fact] + public async Task ValidateAsync_RsaModulusWithinBounds_Succeeds() + { + // 2048-bit key with defaults (Min=2048, Max=4096) should succeed. + var (proof, accessToken, cnfJkt) = CreateProofAndAccessToken(); + var options = DefaultOptions(); + + var result = await _validator.ValidateAsync( + proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxLifetimeInSeconds_InvalidValue_Throws(int value) + { + var options = new DPoPValidationOptions(); + Assert.Throws(() => options.MaxLifetimeInSeconds = value); + } + + [Theory] + [InlineData(-1)] + [InlineData(-300)] + public void ClockSkewInSeconds_NegativeValue_Throws(int value) + { + var options = new DPoPValidationOptions(); + Assert.Throws(() => options.ClockSkewInSeconds = value); + } + + [Fact] + public void Defaults_MatchConstants() + { + var options = new DPoPValidationOptions(); + Assert.Equal(DPoPConstants.DefaultMaxLifetimeInSeconds, options.MaxLifetimeInSeconds); + Assert.Equal(DPoPConstants.DefaultClockSkewInSeconds, options.ClockSkewInSeconds); + Assert.Equal(DPoPConstants.DefaultMaxProofTokenSizeInBytes, options.MaxProofTokenSizeInBytes); + Assert.Equal(DPoPConstants.DefaultMaxRsaKeySizeInBits, options.MaxRsaKeySizeInBits); + Assert.Equal(DPoPConstants.DefaultMinRsaKeySizeInBits, options.MinRsaKeySizeInBits); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxProofTokenSizeInBytes_InvalidValue_Throws(int value) + { + var options = new DPoPValidationOptions(); + Assert.Throws(() => options.MaxProofTokenSizeInBytes = value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxRsaKeySizeInBits_InvalidValue_Throws(int value) + { + var options = new DPoPValidationOptions(); + Assert.Throws(() => options.MaxRsaKeySizeInBits = value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MinRsaKeySizeInBits_InvalidValue_Throws(int value) + { + var options = new DPoPValidationOptions(); + Assert.Throws(() => options.MinRsaKeySizeInBits = value); + } + +#if !NET462 + [Fact] + public async Task ValidateAsync_EcAlgWithMismatchedCurve_ReturnsCurveSpecificError() + { + // Sign with ES256 (requires P-256) but advertise a P-384 JWK in the header. + var p256 = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384); + var (accessToken, cnfJkt) = CreateSimpleAccessToken(CreateTestRsa()); + + var signingCredentials = new SigningCredentials( + new ECDsaSecurityKey(p256), SecurityAlgorithms.EcdsaSha256); + + var p384Params = p384.ExportParameters(false); + var jwkForHeader = new System.Text.Json.Nodes.JsonObject + { + ["kty"] = "EC", + ["crv"] = "P-384", + ["x"] = Base64UrlEncoder.Encode(p384Params.Q.X), + ["y"] = Base64UrlEncoder.Encode(p384Params.Q.Y), + }; + + var now = DateTimeOffset.UtcNow; + var claims = new Dictionary + { + ["htm"] = "GET", + ["htu"] = "https://resource.example.org/api", + ["iat"] = now.ToUnixTimeSeconds(), + ["jti"] = Guid.NewGuid().ToString(), + ["nonce"] = DefaultTestNonce, + }; + + var descriptor = new SecurityTokenDescriptor + { + IncludeKeyIdInHeader = false, + Claims = claims, + AdditionalHeaderClaims = new Dictionary + { + { "typ", "dpop+jwt" }, + { "jwk", jwkForHeader }, + }, + SigningCredentials = signingCredentials, + }; + var handler = new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = false }; + var proof = handler.CreateToken(descriptor); + + var options = new DPoPValidationOptions + { + AllowedSigningAlgorithms = new HashSet(StringComparer.Ordinal) { "ES256" }, + ExpectedNonce = DefaultTestNonce, + }; + + var result = await _validator.ValidateAsync( + proof, "GET", new Uri("https://resource.example.org/api"), accessToken, cnfJkt, options); + + Assert.False(result.IsValid); + Assert.Contains("requires curve", result.Error); + } +#endif + + #endregion + + + #region Test Doubles ///