From d808604eb772890489cc7b90ec0c9b0a6a416c9b Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Fri, 8 May 2026 14:17:49 +0100 Subject: [PATCH 01/13] Add ML-DSA (FIPS 204) post-quantum signature support Implement ML-DSA-44, ML-DSA-65, and ML-DSA-87 digital signature support in Microsoft.IdentityModel.Tokens, enabling post-quantum signatures across the JOSE pipeline: JWS signing/verification, JWK representation, X.509 certificate key extraction, and JWK conversion. Standards: FIPS 204 (Final), RFC 9881 (Final), draft-ietf-cose-dilithium v11 (RFC 9964 pending) Targeting dev8x (Wilson 8.x) with representAsRsaKey parameter naming. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Build.targets | 18 + build/dependencies.props | 3 +- .../AsymmetricAdapter.cs | 137 ++- .../AsymmetricSignatureProvider.cs | 16 +- .../GlobalSuppressions.cs | 2 + .../InternalAPI.Unshipped.txt | 13 + .../Json/JsonWebKeySerializer.cs | 12 + .../JsonWebAlgorithmsKeyTypes.cs | 3 + .../JsonWebKey.cs | 63 +- .../JsonWebKeyConverter.cs | 155 ++- .../JsonWebKeyParameterNames.cs | 4 + .../LogMessages.cs | 5 + .../Microsoft.IdentityModel.Tokens.csproj | 1 + .../MlDsaAdapter.cs | 91 ++ .../MlDsaSecurityKey.cs | 138 +++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 19 + .../PublicAPI/net462/PublicAPI.Unshipped.txt | 19 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 19 + .../PublicAPI/net6.0/PublicAPI.Unshipped.txt | 19 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 19 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 19 + .../netstandard2.0/PublicAPI.Unshipped.txt | 19 + .../SecurityAlgorithms.cs | 5 + .../SupportedAlgorithms.cs | 55 +- .../Telemetry/CryptoTelemetry.cs | 22 +- .../X509SecurityKey.cs | 143 ++- .../MlDsaConditionalAttributes.cs | 36 + .../MlDsaKeyingMaterial.cs | 134 +++ .../AsymmetricSignatureTestData.cs | 37 + .../AsymmetricSignatureTests.cs | 58 +- .../MlDsaSecurityKeyTests.cs | 925 ++++++++++++++++++ 31 files changed, 2187 insertions(+), 22 deletions(-) create mode 100644 Directory.Build.targets create mode 100644 src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs create mode 100644 src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs create mode 100644 test/Microsoft.IdentityModel.TestUtils/MlDsaConditionalAttributes.cs create mode 100644 test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs create mode 100644 test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000000..d142d2d57f --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/build/dependencies.props b/build/dependencies.props index 428fd74658..ea8ba36e59 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -3,13 +3,14 @@ 2.1.1 4.14.0 + 10.0.2 8.0.1 4.5.0 1.0.0 2.0.3 13.0.3 6.0.2 - 4.5.5 + 4.6.3 4.5.0 8.0.5 diff --git a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs index 113a55c1c0..58f74f537a 100644 --- a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs @@ -87,6 +87,8 @@ internal AsymmetricAdapter( InitializeUsingX509SecurityKey(x509SecurityKeyFromJsonWebKey, algorithm, requirePrivateKey); else if (securityKey is ECDsaSecurityKey edcsaSecurityKeyFromJsonWebKey) InitializeUsingEcdsaSecurityKey(edcsaSecurityKeyFromJsonWebKey); + else if (securityKey is MlDsaSecurityKey mlDsaSecurityKeyFromJsonWebKey) + InitializeUsingMlDsaSecurityKey(mlDsaSecurityKeyFromJsonWebKey); else throw LogHelper.LogExceptionMessage( new NotSupportedException( @@ -99,6 +101,10 @@ internal AsymmetricAdapter( { InitializeUsingEcdsaSecurityKey(ecdsaKey); } + else if (key is MlDsaSecurityKey mlDsaKey) + { + InitializeUsingMlDsaSecurityKey(mlDsaKey); + } else throw LogHelper.LogExceptionMessage( new NotSupportedException( @@ -138,6 +144,9 @@ protected virtual void Dispose(bool disposing) { if (ECDsa != null) ECDsa.Dispose(); + + if (MLDsa != null) + MLDsa.Dispose(); #if DESKTOP if (RsaCryptoServiceProviderProxy != null) RsaCryptoServiceProviderProxy.Dispose(); @@ -151,6 +160,8 @@ protected virtual void Dispose(bool disposing) private ECDsa ECDsa { get; set; } + private MLDsa MLDsa { get; set; } + internal byte[] Encrypt(byte[] data) { return _encryptFunction(data); @@ -176,6 +187,23 @@ private void InitializeUsingEcdsaSecurityKey(ECDsaSecurityKey ecdsaSecurityKey) _verifyUsingOffsetFunction = VerifyUsingOffsetECDsa; } + private void InitializeUsingMlDsaSecurityKey(MlDsaSecurityKey mlDsaSecurityKey) + { + InitializeUsingMlDsa(mlDsaSecurityKey.MLDsa); + } + + private void InitializeUsingMlDsa(MLDsa mlDsa) + { + MLDsa = mlDsa; + _signFunction = SignMlDsa; + _signUsingOffsetFunction = SignUsingOffsetMlDsa; +#if NET6_0_OR_GREATER + _signUsingSpanFunction = SignUsingSpanMlDsa; +#endif + _verifyFunction = VerifyMlDsa; + _verifyUsingOffsetFunction = VerifyUsingOffsetMlDsa; + } + private void InitializeUsingRsa(RSA rsa, string algorithm) { // The return value for X509Certificate2.GetPrivateKey OR X509Certificate2.GetPublicKey.Key is a RSACryptoServiceProvider @@ -258,10 +286,50 @@ private void InitializeUsingX509SecurityKey( string algorithm, bool requirePrivateKey) { - if (requirePrivateKey) + if (x509SecurityKey.MlDsaPublicKey != null) + { + // ML-DSA certificate — borrow the MLDsa instance from X509SecurityKey. + // The X509SecurityKey retains ownership; _disposeCryptoOperators remains + // false so the adapter will not dispose it. Same pattern as RSA/ECDsa. + MLDsa mlDsa = requirePrivateKey ? x509SecurityKey.MlDsaPrivateKey : x509SecurityKey.MlDsaPublicKey; + if (mlDsa == null) + throw LogHelper.LogExceptionMessage( + new InvalidOperationException( + LogHelper.FormatInvariant( + LogMessages.IDX10723, + LogHelper.MarkAsNonPII(algorithm), + LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); + + InitializeUsingMlDsa(mlDsa); + } + else if (x509SecurityKey.PublicKey == null) + { + // Certificate contains neither a supported classical key (RSA/ECDSA) nor + // an extractable ML-DSA key. This occurs when the certificate uses a key + // type that the platform cannot extract (e.g., ML-DSA on older OS versions). + throw LogHelper.LogExceptionMessage( + new NotSupportedException( + LogHelper.FormatInvariant( + LogMessages.IDX10725, + LogHelper.MarkAsNonPII(algorithm), + LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); + } + else if (requirePrivateKey) + { + if (x509SecurityKey.PrivateKey == null) + throw LogHelper.LogExceptionMessage( + new InvalidOperationException( + LogHelper.FormatInvariant( + LogMessages.IDX10723, + LogHelper.MarkAsNonPII(algorithm), + LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); + InitializeUsingRsa(x509SecurityKey.PrivateKey as RSA, algorithm); + } else + { InitializeUsingRsa(x509SecurityKey.PublicKey as RSA, algorithm); + } } private RSA RSA { get; set; } @@ -342,6 +410,51 @@ private byte[] SignUsingOffsetECDsa(byte[] bytes, int offset, int count) return ECDsa.SignHash(HashAlgorithm.ComputeHash(bytes, offset, count)); } + private byte[] SignMlDsa(byte[] bytes) + { + return MLDsa.SignData(bytes, context: null); + } + +#if NET6_0_OR_GREATER + internal bool SignUsingSpanMlDsa( + ReadOnlySpan data, + Span destination, + out int bytesWritten) + { + int signatureSize = MLDsa.Algorithm.SignatureSizeInBytes; + if (destination.Length < signatureSize) + { + bytesWritten = 0; + return false; + } + + // MLDsa.SignData requires destination to be exactly SignatureSizeInBytes. + MLDsa.SignData(data, destination.Slice(0, signatureSize), context: default); + bytesWritten = signatureSize; + return true; + } +#endif + + private byte[] SignUsingOffsetMlDsa(byte[] bytes, int offset, int count) + { +#if NET6_0_OR_GREATER + int signatureSize = MLDsa.Algorithm.SignatureSizeInBytes; + byte[] signature = new byte[signatureSize]; + MLDsa.SignData( + new ReadOnlySpan(bytes, offset, count), + signature.AsSpan(), + context: default); + return signature; +#else + if (offset == 0 && count == bytes.Length) + return MLDsa.SignData(bytes, context: null); + + byte[] slice = new byte[count]; + Buffer.BlockCopy(bytes, offset, slice, 0, count); + return MLDsa.SignData(slice, context: null); +#endif + } + internal bool Verify(byte[] bytes, byte[] signature) { return _verifyFunction(bytes, signature); @@ -382,6 +495,28 @@ private bool VerifyUsingOffsetECDsa(byte[] bytes, int offset, int count, byte[] #endif } + private bool VerifyMlDsa(byte[] bytes, byte[] signature) + { + return MLDsa.VerifyData(bytes, signature, context: null); + } + + private bool VerifyUsingOffsetMlDsa(byte[] bytes, int offset, int count, byte[] signature) + { +#if NET6_0_OR_GREATER + return MLDsa.VerifyData( + new ReadOnlySpan(bytes, offset, count), + signature.AsSpan(), + context: default); +#else + if (offset == 0 && count == bytes.Length) + return MLDsa.VerifyData(bytes, signature, context: null); + + byte[] slice = new byte[count]; + Buffer.BlockCopy(bytes, offset, slice, 0, count); + return MLDsa.VerifyData(slice, signature, context: null); +#endif + } + private byte[] DecryptWithRsa(byte[] bytes) { return RSA.Decrypt(bytes, RSAEncryptionPadding); diff --git a/src/Microsoft.IdentityModel.Tokens/AsymmetricSignatureProvider.cs b/src/Microsoft.IdentityModel.Tokens/AsymmetricSignatureProvider.cs index 118d907fe4..55f8582f27 100644 --- a/src/Microsoft.IdentityModel.Tokens/AsymmetricSignatureProvider.cs +++ b/src/Microsoft.IdentityModel.Tokens/AsymmetricSignatureProvider.cs @@ -41,7 +41,10 @@ public class AsymmetricSignatureProvider : SignatureProvider { SecurityAlgorithms.RsaSsaPssSha512, 1040 }, { SecurityAlgorithms.RsaSsaPssSha256Signature, 528 }, { SecurityAlgorithms.RsaSsaPssSha384Signature, 784 }, - { SecurityAlgorithms.RsaSsaPssSha512Signature, 1040 } + { SecurityAlgorithms.RsaSsaPssSha512Signature, 1040 }, + { SecurityAlgorithms.MlDsa44, 10496 }, + { SecurityAlgorithms.MlDsa65, 15616 }, + { SecurityAlgorithms.MlDsa87, 20736 } }; /// @@ -66,7 +69,10 @@ public class AsymmetricSignatureProvider : SignatureProvider { SecurityAlgorithms.RsaSsaPssSha512, 1040 }, { SecurityAlgorithms.RsaSsaPssSha256Signature, 528 }, { SecurityAlgorithms.RsaSsaPssSha384Signature, 784 }, - { SecurityAlgorithms.RsaSsaPssSha512Signature, 1040 } + { SecurityAlgorithms.RsaSsaPssSha512Signature, 1040 }, + { SecurityAlgorithms.MlDsa44, 10496 }, + { SecurityAlgorithms.MlDsa65, 15616 }, + { SecurityAlgorithms.MlDsa87, 20736 } }; internal AsymmetricSignatureProvider( @@ -190,6 +196,12 @@ protected virtual HashAlgorithmName GetHashAlgorithmName(string algorithm) private AsymmetricAdapter CreateAsymmetricAdapter() { + // ML-DSA and other pure-signing algorithms do not use an external hash. + if (SupportedAlgorithms.IsSupportedMlDsaAlgorithm(Algorithm)) + return new AsymmetricAdapter(Key, Algorithm, WillCreateSignatures); + + // Preserve the protected virtual GetHashAlgorithmName extensibility point + // for hash-based algorithms (RSA, ECDSA). HashAlgorithmName hashAlgorithmName = GetHashAlgorithmName(Algorithm); return new AsymmetricAdapter( Key, diff --git a/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs b/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs index b45866a848..e2974ae654 100644 --- a/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs +++ b/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs @@ -28,7 +28,9 @@ [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used for try pattern", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.TryConvertToSymmetricSecurityKey(Microsoft.IdentityModel.Tokens.JsonWebKey,Microsoft.IdentityModel.Tokens.SecurityKey@)~System.Boolean")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used for try pattern", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.TryConvertToX509SecurityKey(Microsoft.IdentityModel.Tokens.JsonWebKey,Microsoft.IdentityModel.Tokens.SecurityKey@)~System.Boolean")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used for try pattern", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.TryCreateToRsaSecurityKey(Microsoft.IdentityModel.Tokens.JsonWebKey,Microsoft.IdentityModel.Tokens.SecurityKey@)~System.Boolean")] +[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used for try pattern", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.TryConvertToMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.JsonWebKey,Microsoft.IdentityModel.Tokens.SecurityKey@)~System.Boolean")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used as platform test", Scope = "member", Target = "~P:Microsoft.IdentityModel.Tokens.RsaSecurityKey.PrivateKeyStatus")] +[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used as platform test", Scope = "member", Target = "~P:Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Appropriate exception will be caught.", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.InMemoryCryptoProviderCache.TryRemove(Microsoft.IdentityModel.Tokens.SignatureProvider)~System.Boolean")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used as validation", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.InternalValidators.ValidateLifetimeAndIssuerAfterSignatureNotValidatedSaml(Microsoft.IdentityModel.Tokens.SecurityToken,System.Nullable{System.DateTime},System.Nullable{System.DateTime},System.String,Microsoft.IdentityModel.Tokens.TokenValidationParameters,System.Text.StringBuilder)")] [assembly: SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "Not using Globalization", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.Interop.Kernel32.GetMessage(System.Int32,System.IntPtr)~System.String")] diff --git a/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt index 4194681d26..06add946da 100644 --- a/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt @@ -110,3 +110,16 @@ virtual Microsoft.IdentityModel.Tokens.TokenHandler.CreateClaimsIdentityInternal virtual Microsoft.IdentityModel.Tokens.TokenHandler.ValidateTokenAsync(Microsoft.IdentityModel.Tokens.SecurityToken token, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.CallContext callContext, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task> virtual Microsoft.IdentityModel.Tokens.TokenHandler.ValidateTokenAsync(string token, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.CallContext callContext, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task> virtual Microsoft.IdentityModel.Tokens.ValidationError.CreateException() -> System.Exception +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(Microsoft.IdentityModel.Tokens.JsonWebKey webKey, bool usePrivateKey) -> void +static Microsoft.IdentityModel.Tokens.MlDsaAdapter.CreateMlDsa(Microsoft.IdentityModel.Tokens.JsonWebKey jsonWebKey, bool usePrivateKey) -> System.Security.Cryptography.MLDsa +static Microsoft.IdentityModel.Tokens.MlDsaAdapter.GetMLDsaAlgorithm(string algorithm) -> System.Security.Cryptography.MLDsaAlgorithm +static Microsoft.IdentityModel.Tokens.SupportedAlgorithms.IsSupportedMlDsaAlgorithm(string algorithm) -> bool +static Microsoft.IdentityModel.Tokens.SupportedAlgorithms.TryGetHashAlgorithmName(string algorithm, out System.Security.Cryptography.HashAlgorithmName hashAlgorithmName) -> bool +internal static Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.GetAlgorithmName(System.Security.Cryptography.MLDsaAlgorithm algorithm) -> string +Microsoft.IdentityModel.Tokens.X509SecurityKey.MlDsaPrivateKey.get -> System.Security.Cryptography.MLDsa +Microsoft.IdentityModel.Tokens.X509SecurityKey.MlDsaPublicKey.get -> System.Security.Cryptography.MLDsa +const Microsoft.IdentityModel.Tokens.LogMessages.IDX10721 = "IDX10721: Unable to create a key from the AKP JsonWebKey (alg: '{0}'). The required parameter '{1}' is missing or empty." -> string +const Microsoft.IdentityModel.Tokens.LogMessages.IDX10722 = "IDX10722: The AKP JsonWebKey (alg: '{0}') has inconsistent key material. The 'pub' parameter does not match the public key derived from 'priv'." -> string +const Microsoft.IdentityModel.Tokens.LogMessages.IDX10723 = "IDX10723: Unable to extract the private key from the X.509 certificate for algorithm '{0}' (Key: '{1}'). Private key extraction may not be supported on this platform." -> string +const Microsoft.IdentityModel.Tokens.LogMessages.IDX10724 = "IDX10724: Unable to compute a JWK thumbprint, public key extraction from the X.509 certificate is not supported on this platform (Key: '{0}')." -> string +const Microsoft.IdentityModel.Tokens.LogMessages.IDX10725 = "IDX10725: Unable to create a SignatureProvider for algorithm '{0}' (Key: '{1}'). The X.509 certificate key could not be extracted. This may indicate the platform does not support the certificate's key type." -> string diff --git a/src/Microsoft.IdentityModel.Tokens/Json/JsonWebKeySerializer.cs b/src/Microsoft.IdentityModel.Tokens/Json/JsonWebKeySerializer.cs index 9429ebcec7..76644e696c 100644 --- a/src/Microsoft.IdentityModel.Tokens/Json/JsonWebKeySerializer.cs +++ b/src/Microsoft.IdentityModel.Tokens/Json/JsonWebKeySerializer.cs @@ -45,6 +45,8 @@ public static readonly "N", "OTH", "P", + "PRIV", + "PUB", "Q", "QI", "USE", @@ -162,6 +164,10 @@ public static JsonWebKey Read(ref Utf8JsonReader reader, JsonWebKey jsonWebKey) JsonSerializerPrimitives.ReadStrings(ref reader, jsonWebKey.Oth, JsonWebKeyParameterNames.Oth, JsonWebKey.ClassName, true); else if (reader.ValueTextEquals(JsonWebKeyParameterUtf8Bytes.P)) jsonWebKey.P = JsonSerializerPrimitives.ReadString(ref reader, JsonWebKeyParameterNames.P, JsonWebKey.ClassName, true); + else if (reader.ValueTextEquals(JsonWebKeyParameterUtf8Bytes.Priv)) + jsonWebKey.Priv = JsonSerializerPrimitives.ReadString(ref reader, JsonWebKeyParameterNames.Priv, JsonWebKey.ClassName, true); + else if (reader.ValueTextEquals(JsonWebKeyParameterUtf8Bytes.Pub)) + jsonWebKey.Pub = JsonSerializerPrimitives.ReadString(ref reader, JsonWebKeyParameterNames.Pub, JsonWebKey.ClassName, true); else if (reader.ValueTextEquals(JsonWebKeyParameterUtf8Bytes.Q)) jsonWebKey.Q = JsonSerializerPrimitives.ReadString(ref reader, JsonWebKeyParameterNames.Q, JsonWebKey.ClassName, true); else if (reader.ValueTextEquals(JsonWebKeyParameterUtf8Bytes.QI)) @@ -377,6 +383,12 @@ public static void Write(ref Utf8JsonWriter writer, JsonWebKey jsonWebKey) if (!string.IsNullOrEmpty(jsonWebKey.P)) writer.WriteString(JsonWebKeyParameterUtf8Bytes.P, jsonWebKey.P); + if (!string.IsNullOrEmpty(jsonWebKey.Priv)) + writer.WriteString(JsonWebKeyParameterUtf8Bytes.Priv, jsonWebKey.Priv); + + if (!string.IsNullOrEmpty(jsonWebKey.Pub)) + writer.WriteString(JsonWebKeyParameterUtf8Bytes.Pub, jsonWebKey.Pub); + if (!string.IsNullOrEmpty(jsonWebKey.Q)) writer.WriteString(JsonWebKeyParameterUtf8Bytes.Q, jsonWebKey.Q); diff --git a/src/Microsoft.IdentityModel.Tokens/JsonWebAlgorithmsKeyTypes.cs b/src/Microsoft.IdentityModel.Tokens/JsonWebAlgorithmsKeyTypes.cs index a2b22bc4f1..0244fe0174 100644 --- a/src/Microsoft.IdentityModel.Tokens/JsonWebAlgorithmsKeyTypes.cs +++ b/src/Microsoft.IdentityModel.Tokens/JsonWebAlgorithmsKeyTypes.cs @@ -13,6 +13,9 @@ public static class JsonWebAlgorithmsKeyTypes public const string EllipticCurve = "EC"; public const string RSA = "RSA"; public const string Octet = "oct"; + + // See: https://datatracker.ietf.org/doc/draft-ietf-cose-dilithium/ (RFC 9964 pending) + public const string Akp = "AKP"; #pragma warning restore 1591 } } diff --git a/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs b/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs index f1645a6448..10ed0b5f5e 100644 --- a/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs @@ -319,6 +319,26 @@ public string Kid #endif public string Y { get; set; } + /// + /// Gets or sets the 'pub' (AKP public key). + /// + /// Value is formatted as: Base64urlEncoding + [JsonPropertyName(JsonWebKeyParameterNames.Pub)] +#if NET6_0_OR_GREATER + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] +#endif + public string Pub { get; set; } + + /// + /// Gets or sets the 'priv' (AKP private key / seed). + /// + /// Value is formatted as: Base64urlEncoding + [JsonPropertyName(JsonWebKeyParameterNames.Priv)] +#if NET6_0_OR_GREATER + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] +#endif + public string Priv { get; set; } + /// /// Gets the key size of . /// @@ -333,6 +353,8 @@ public override int KeySize return Base64UrlEncoder.DecodeBytes(X).Length * 8; else if (Kty == JsonWebAlgorithmsKeyTypes.Octet && !string.IsNullOrEmpty(K)) return Base64UrlEncoder.DecodeBytes(K).Length * 8; + else if (Kty == JsonWebAlgorithmsKeyTypes.Akp && !string.IsNullOrEmpty(Pub)) + return Base64UrlEncoder.DecodeBytes(Pub).Length * 8; else return 0; } @@ -351,6 +373,8 @@ public bool HasPrivateKey return D != null && DP != null && DQ != null && P != null && Q != null && QI != null; else if (Kty == JsonWebAlgorithmsKeyTypes.EllipticCurve) return D != null; + else if (Kty == JsonWebAlgorithmsKeyTypes.Akp) + return Priv != null; else return false; } @@ -393,6 +417,8 @@ public override bool CanComputeJwkThumbprint() return CanComputeRsaThumbprint(); else if (string.Equals(Kty, JsonWebAlgorithmsKeyTypes.Octet)) return CanComputeOctThumbprint(); + else if (string.Equals(Kty, JsonWebAlgorithmsKeyTypes.Akp)) + return CanComputeAkpThumbprint(); else return false; } @@ -412,8 +438,10 @@ public override byte[] ComputeJwkThumbprint() return ComputeRsaThumbprint(); else if (string.Equals(Kty, JsonWebAlgorithmsKeyTypes.Octet)) return ComputeOctThumbprint(); + else if (string.Equals(Kty, JsonWebAlgorithmsKeyTypes.Akp)) + return ComputeAkpThumbprint(); else - throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10706, LogHelper.MarkAsNonPII(nameof(Kty)), LogHelper.MarkAsNonPII(string.Join(", ", JsonWebAlgorithmsKeyTypes.EllipticCurve, JsonWebAlgorithmsKeyTypes.RSA, JsonWebAlgorithmsKeyTypes.Octet)), LogHelper.MarkAsNonPII(nameof(Kty))))); + throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10706, LogHelper.MarkAsNonPII(nameof(Kty)), LogHelper.MarkAsNonPII(string.Join(", ", JsonWebAlgorithmsKeyTypes.EllipticCurve, JsonWebAlgorithmsKeyTypes.RSA, JsonWebAlgorithmsKeyTypes.Octet, JsonWebAlgorithmsKeyTypes.Akp)), LogHelper.MarkAsNonPII(nameof(Kty))))); } private bool CanComputeOctThumbprint() @@ -467,6 +495,23 @@ private byte[] ComputeECThumbprint() return Utility.GenerateSha256Hash(canonicalJwk); } + private bool CanComputeAkpThumbprint() + { + return !string.IsNullOrEmpty(Alg) && !string.IsNullOrEmpty(Pub); + } + + private byte[] ComputeAkpThumbprint() + { + if (string.IsNullOrEmpty(Alg)) + throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10705, LogHelper.MarkAsNonPII(nameof(Alg))))); + + if (string.IsNullOrEmpty(Pub)) + throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10705, LogHelper.MarkAsNonPII(nameof(Pub))))); + + var canonicalJwk = $@"{{""{JsonWebKeyParameterNames.Alg}"":""{Alg}"",""{JsonWebKeyParameterNames.Kty}"":""{Kty}"",""{JsonWebKeyParameterNames.Pub}"":""{Pub}""}}"; + return Utility.GenerateSha256Hash(canonicalJwk); + } + /// /// Creates a minimal public JWK representation for DPoP proof headers per RFC 9449 and RFC 7638. /// Only the required members for the key type are included (no alg, kid, or use). @@ -515,6 +560,7 @@ internal JsonObject RepresentAsAsymmetricPublicJwkForDpop() /// /// Creates a JsonWebKey representation of an asymmetric public key. + /// For AKP keys, the 'alg' parameter is included as it is required by the key type specification. /// /// JsonWebKey representation of an asymmetric public key. /// https://datatracker.ietf.org/doc/html/rfc7800#section-3.2 @@ -552,8 +598,21 @@ internal string RepresentAsAsymmetricPublicJwk() $@"""{JsonWebKeyParameterNames.Kty}"":""{Kty}""," + $@"""{JsonWebKeyParameterNames.N}"":""{N}""}}"; } + else if (string.Equals(Kty, JsonWebAlgorithmsKeyTypes.Akp)) + { + if (string.IsNullOrEmpty(Alg)) + throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10708, LogHelper.MarkAsNonPII(nameof(Alg))))); + + if (string.IsNullOrEmpty(Pub)) + throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10708, LogHelper.MarkAsNonPII(nameof(Pub))))); + + return $@"{kid}" + + $@"""{JsonWebKeyParameterNames.Alg}"":""{Alg}""," + + $@"""{JsonWebKeyParameterNames.Kty}"":""{Kty}""," + + $@"""{JsonWebKeyParameterNames.Pub}"":""{Pub}""}}"; + } else - throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10707, LogHelper.MarkAsNonPII(nameof(Kty)), LogHelper.MarkAsNonPII(string.Join(", ", JsonWebAlgorithmsKeyTypes.EllipticCurve, JsonWebAlgorithmsKeyTypes.RSA)), LogHelper.MarkAsNonPII(nameof(Kty))))); + throw LogHelper.LogExceptionMessage(new ArgumentException(LogHelper.FormatInvariant(LogMessages.IDX10707, LogHelper.MarkAsNonPII(nameof(Kty)), LogHelper.MarkAsNonPII(string.Join(", ", JsonWebAlgorithmsKeyTypes.EllipticCurve, JsonWebAlgorithmsKeyTypes.RSA, JsonWebAlgorithmsKeyTypes.Akp)), LogHelper.MarkAsNonPII(nameof(Kty))))); } diff --git a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs index 6bd0ecb12a..02067e787f 100644 --- a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs +++ b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs @@ -38,6 +38,8 @@ public static JsonWebKey ConvertFromSecurityKey(SecurityKey key) else if (key is ECDsaSecurityKey ecdsaSecurityKey) return ConvertFromECDsaSecurityKey(ecdsaSecurityKey); #endif + else if (key is MlDsaSecurityKey mlDsaSecurityKey) + return ConvertFromMlDsaSecurityKey(mlDsaSecurityKey); else throw LogHelper.LogExceptionMessage(new NotSupportedException(LogHelper.FormatInvariant(LogMessages.IDX10674, LogHelper.MarkAsNonPII(key.GetType().FullName)))); } @@ -97,6 +99,26 @@ public static JsonWebKey ConvertFromX509SecurityKey(X509SecurityKey key) if (key == null) throw LogHelper.LogArgumentNullException(nameof(key)); + // ML-DSA certificates: PublicKey (AsymmetricAlgorithm) is null because MLDsa + // does not inherit from AsymmetricAlgorithm. Route via MlDsaPublicKey instead. + if (key.MlDsaPublicKey != null) + { + string alg = MlDsaSecurityKey.GetAlgorithmName(key.MlDsaPublicKey.Algorithm); + var jsonWebKey = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = alg, // REQUIRED for AKP keys per draft-ietf-cose-dilithium (RFC 9964 pending) + Kid = key.KeyId, + X5t = key.X5t, + ConvertedSecurityKey = key + }; + + if (key.Certificate.RawData != null) + jsonWebKey.X5c.Add(Convert.ToBase64String(key.Certificate.RawData)); + + return jsonWebKey; + } + var kty = key.PublicKey switch { RSA => JsonWebAlgorithmsKeyTypes.RSA, @@ -104,7 +126,7 @@ public static JsonWebKey ConvertFromX509SecurityKey(X509SecurityKey key) _ => throw LogHelper.LogExceptionMessage(new NotSupportedException(LogHelper.FormatInvariant(LogMessages.IDX10674, LogHelper.MarkAsNonPII(key.GetType().FullName)))) }; - var jsonWebKey = new JsonWebKey + var jwk = new JsonWebKey { Kty = kty, Kid = key.KeyId, @@ -113,9 +135,9 @@ public static JsonWebKey ConvertFromX509SecurityKey(X509SecurityKey key) }; if (key.Certificate.RawData != null) - jsonWebKey.X5c.Add(Convert.ToBase64String(key.Certificate.RawData)); + jwk.X5c.Add(Convert.ToBase64String(key.Certificate.RawData)); - return jsonWebKey; + return jwk; } /// @@ -123,7 +145,7 @@ public static JsonWebKey ConvertFromX509SecurityKey(X509SecurityKey key) /// /// a to convert. /// - /// true to represent the as an , + /// true to extract the key material (RSA, ECDsa, or ML-DSA) from the , /// false to represent the as an , using the "x5c" parameter. /// /// a . @@ -136,6 +158,43 @@ public static JsonWebKey ConvertFromX509SecurityKey(X509SecurityKey key, bool re if (!representAsRsaKey) return ConvertFromX509SecurityKey(key); + // ML-DSA: extract key material directly from the X509SecurityKey's cached MLDsa + // instance rather than wrapping in a temporary MlDsaSecurityKey. This avoids an + // unnecessary allocation and keeps the data flow explicit — we only need the raw + // byte exports (public key, seed), not a full SecurityKey wrapper. + if (key.MlDsaPublicKey != null) + { + MLDsa mlDsa = key.PrivateKeyStatus == PrivateKeyStatus.Exists + ? key.MlDsaPrivateKey + : key.MlDsaPublicKey; + + string alg = MlDsaSecurityKey.GetAlgorithmName(mlDsa.Algorithm); + byte[] publicKey = mlDsa.ExportMLDsaPublicKey(); + + var jsonWebKey = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = alg, + Pub = Base64UrlEncoder.Encode(publicKey), + Kid = key.KeyId + }; + + if (key.PrivateKeyStatus == PrivateKeyStatus.Exists) + { + byte[] seed = mlDsa.ExportMLDsaPrivateSeed(); + try + { + jsonWebKey.Priv = Base64UrlEncoder.Encode(seed); + } + finally + { + CryptographicOperations.ZeroMemory(seed); + } + } + + return jsonWebKey; + } + if (key.PrivateKeyStatus == PrivateKeyStatus.Exists) { if (key.PrivateKey is RSA rsaPrivateKey) @@ -259,6 +318,36 @@ public static bool TryConvertToSecurityKey(JsonWebKey webKey, out SecurityKey ke { return TryConvertToSymmetricSecurityKey(webKey, out key); } + else if (JsonWebAlgorithmsKeyTypes.Akp.Equals(webKey.Kty)) + { + // alg is REQUIRED for all AKP keys per draft-ietf-cose-dilithium (RFC 9964 pending). + if (string.IsNullOrEmpty(webKey.Alg)) + return false; + + // Only proceed if the alg is a supported AKP algorithm. + if (!SupportedAlgorithms.IsSupportedMlDsaAlgorithm(webKey.Alg)) + return false; + + // AKP JWKs with x5c contain a certificate — convert to X509SecurityKey. + // The certificate must contain an ML-DSA key matching the claimed alg. + if (webKey.X5c != null && webKey.X5c.Count > 0) + { + if (!TryConvertToX509SecurityKey(webKey, out key)) + return false; + + if (key is not X509SecurityKey x509Key + || x509Key.MlDsaPublicKey == null + || MlDsaSecurityKey.GetAlgorithmName(x509Key.MlDsaPublicKey.Algorithm) != webKey.Alg) + { + key = null; + return false; + } + + return true; + } + + return TryConvertToMlDsaSecurityKey(webKey, out key); + } } catch (Exception ex) { @@ -396,5 +485,63 @@ internal static bool TryConvertToECDsaSecurityKey(JsonWebKey webKey, out Securit return false; } + + /// + /// Converts an into a . + /// + public static JsonWebKey ConvertFromMlDsaSecurityKey(MlDsaSecurityKey key) + { + if (key == null) + throw LogHelper.LogArgumentNullException(nameof(key)); + + string algorithmName = MlDsaSecurityKey.GetAlgorithmName(key.MLDsa.Algorithm); + byte[] publicKey = key.MLDsa.ExportMLDsaPublicKey(); + + var jsonWebKey = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = algorithmName, + Pub = Base64UrlEncoder.Encode(publicKey), + Kid = key.KeyId + }; + + if (key.PrivateKeyStatus == PrivateKeyStatus.Exists) + { + byte[] seed = key.MLDsa.ExportMLDsaPrivateSeed(); + try + { + jsonWebKey.Priv = Base64UrlEncoder.Encode(seed); + } + finally + { + CryptographicOperations.ZeroMemory(seed); + } + } + + return jsonWebKey; + } + + internal static bool TryConvertToMlDsaSecurityKey(JsonWebKey webKey, out SecurityKey key) + { + key = null; + + if (!SupportedAlgorithms.IsSupportedMlDsaAlgorithm(webKey.Alg)) + return false; + + try + { + key = new MlDsaSecurityKey(webKey, !string.IsNullOrEmpty(webKey.Priv)); + return true; + } + catch (Exception ex) + { + string convertKeyInfo = LogHelper.FormatInvariant(LogMessages.IDX10813, LogHelper.MarkAsNonPII(typeof(MlDsaSecurityKey)), LogHelper.MarkAsNonPII(webKey.KeyId), ex); + webKey.ConvertKeyInfo = convertKeyInfo; + if (LogHelper.IsEnabled(EventLogLevel.Error)) + LogHelper.LogExceptionMessage(new InvalidOperationException(convertKeyInfo, ex)); + } + + return false; + } } } diff --git a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyParameterNames.cs b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyParameterNames.cs index 48d2efc19d..e938fa6eba 100644 --- a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyParameterNames.cs +++ b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyParameterNames.cs @@ -26,6 +26,8 @@ public static class JsonWebKeyParameterNames public const string N = "n"; public const string Oth = "oth"; public const string P = "p"; + public const string Priv = "priv"; + public const string Pub = "pub"; public const string Q = "q"; public const string QI = "qi"; public const string Use = "use"; @@ -58,6 +60,8 @@ internal readonly struct JsonWebKeyParameterUtf8Bytes public static ReadOnlySpan N => "n"u8; public static ReadOnlySpan Oth => "oth"u8; public static ReadOnlySpan P => "p"u8; + public static ReadOnlySpan Priv => "priv"u8; + public static ReadOnlySpan Pub => "pub"u8; public static ReadOnlySpan Q => "q"u8; public static ReadOnlySpan QI => "qi"u8; public static ReadOnlySpan Use => "use"u8; diff --git a/src/Microsoft.IdentityModel.Tokens/LogMessages.cs b/src/Microsoft.IdentityModel.Tokens/LogMessages.cs index 2cf87bc84b..1e40e56fef 100644 --- a/src/Microsoft.IdentityModel.Tokens/LogMessages.cs +++ b/src/Microsoft.IdentityModel.Tokens/LogMessages.cs @@ -277,6 +277,11 @@ internal static class LogMessages public const string IDX10718 = "IDX10718: AlgorithmToValidate is not supported: '{0}'. Algorithm '{1}'."; public const string IDX10719 = "IDX10719: SignatureSize (in bytes) was expected to be '{0}', was '{1}'."; public const string IDX10720 = "IDX10720: Unable to create KeyedHashAlgorithm for algorithm '{0}', the key size must be greater than: '{1}' bits, key has '{2}' bits."; + public const string IDX10721 = "IDX10721: Unable to create a key from the AKP JsonWebKey (alg: '{0}'). The required parameter '{1}' is missing or empty."; + public const string IDX10722 = "IDX10722: The AKP JsonWebKey (alg: '{0}') has inconsistent key material. The 'pub' parameter does not match the public key derived from 'priv'."; + public const string IDX10723 = "IDX10723: Unable to extract the private key from the X.509 certificate for algorithm '{0}' (Key: '{1}'). Private key extraction may not be supported on this platform."; + public const string IDX10724 = "IDX10724: Unable to compute a JWK thumbprint, public key extraction from the X.509 certificate is not supported on this platform (Key: '{0}')."; + public const string IDX10725 = "IDX10725: Unable to create a SignatureProvider for algorithm '{0}' (Key: '{1}'). The X.509 certificate key could not be extracted. This may indicate the platform does not support the certificate's key type."; // Json specific errors //public const string IDX10801 = "IDX10801:" diff --git a/src/Microsoft.IdentityModel.Tokens/Microsoft.IdentityModel.Tokens.csproj b/src/Microsoft.IdentityModel.Tokens/Microsoft.IdentityModel.Tokens.csproj index 36ab7eec14..dbd41b65c2 100644 --- a/src/Microsoft.IdentityModel.Tokens/Microsoft.IdentityModel.Tokens.csproj +++ b/src/Microsoft.IdentityModel.Tokens/Microsoft.IdentityModel.Tokens.csproj @@ -78,6 +78,7 @@ + diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs new file mode 100644 index 0000000000..158a6ad82d --- /dev/null +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Security.Cryptography; +using Microsoft.IdentityModel.Logging; + +namespace Microsoft.IdentityModel.Tokens; + +/// +/// Provides helper methods for creating instances from JWK parameters. +/// +internal static class MlDsaAdapter +{ + /// + /// Creates an instance from a . + /// + /// The JWK containing ML-DSA key material. + /// Whether to include the private key (seed). + /// An instance. + internal static MLDsa CreateMlDsa(JsonWebKey jsonWebKey, bool usePrivateKey) + { + if (jsonWebKey == null) + throw LogHelper.LogArgumentNullException(nameof(jsonWebKey)); + + if (string.IsNullOrEmpty(jsonWebKey.Pub)) + throw LogHelper.LogExceptionMessage( + new ArgumentException( + LogHelper.FormatInvariant(LogMessages.IDX10721, LogHelper.MarkAsNonPII(jsonWebKey.Alg), LogHelper.MarkAsNonPII(nameof(jsonWebKey.Pub))))); + + MLDsaAlgorithm algorithm = GetMLDsaAlgorithm(jsonWebKey.Alg); + + if (usePrivateKey) + { + if (string.IsNullOrEmpty(jsonWebKey.Priv)) + throw LogHelper.LogExceptionMessage( + new ArgumentException( + LogHelper.FormatInvariant(LogMessages.IDX10721, LogHelper.MarkAsNonPII(jsonWebKey.Alg), LogHelper.MarkAsNonPII(nameof(jsonWebKey.Priv))))); + + byte[] seed = Base64UrlEncoder.DecodeBytes(jsonWebKey.Priv); + MLDsa key = null; + bool success = false; + try + { + key = MLDsa.ImportMLDsaPrivateSeed(algorithm, seed); + + // Verify the claimed public key matches the key derived from the seed. + // This prevents key identity confusion where thumbprint/kid is computed + // from one public key but signing uses a different private key. + byte[] claimedPub = Base64UrlEncoder.DecodeBytes(jsonWebKey.Pub); + byte[] derivedPub = key.ExportMLDsaPublicKey(); + if (!claimedPub.SequenceEqual(derivedPub)) + { + throw LogHelper.LogExceptionMessage( + new ArgumentException( + LogHelper.FormatInvariant( + LogMessages.IDX10722, + LogHelper.MarkAsNonPII(jsonWebKey.Alg)))); + } + + success = true; + return key; + } + finally + { + if (!success) + key?.Dispose(); + + CryptographicOperations.ZeroMemory(seed); + } + } + + byte[] publicKey = Base64UrlEncoder.DecodeBytes(jsonWebKey.Pub); + return MLDsa.ImportMLDsaPublicKey(algorithm, publicKey); + } + + /// + /// Maps a JWK algorithm string to the corresponding . + /// + internal static MLDsaAlgorithm GetMLDsaAlgorithm(string algorithm) + { + return algorithm switch + { + SecurityAlgorithms.MlDsa44 => MLDsaAlgorithm.MLDsa44, + SecurityAlgorithms.MlDsa65 => MLDsaAlgorithm.MLDsa65, + SecurityAlgorithms.MlDsa87 => MLDsaAlgorithm.MLDsa87, + _ => throw LogHelper.LogArgumentException(nameof(algorithm), LogMessages.IDX10652, algorithm) + }; + } +} diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs new file mode 100644 index 0000000000..b41c770b91 --- /dev/null +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography; +using Microsoft.IdentityModel.Logging; + +namespace Microsoft.IdentityModel.Tokens; + +/// +/// Represents an ML-DSA security key. +/// +public class MlDsaSecurityKey : AsymmetricSecurityKey +{ + private bool? _hasPrivateKey; + + internal MlDsaSecurityKey(JsonWebKey webKey, bool usePrivateKey) + : base(webKey) + { + MLDsa = MlDsaAdapter.CreateMlDsa(webKey, usePrivateKey); + webKey.ConvertedSecurityKey = this; + } + + /// + /// Initializes a new instance of the class. + /// + /// The instance. The caller is responsible for the + /// lifetime of this instance; disposing the key does not dispose the MLDsa. + public MlDsaSecurityKey(MLDsa mlDsa) + { + MLDsa = mlDsa ?? throw LogHelper.LogArgumentNullException(nameof(mlDsa)); + } + + /// + /// The instance used to initialize the key. + /// + public MLDsa MLDsa { get; private set; } + + /// + /// Gets a bool indicating if a private key exists. + /// + /// if it has a private key; otherwise, . + [Obsolete("HasPrivateKey method is deprecated, please use PrivateKeyStatus instead.")] + public override bool HasPrivateKey + { + get + { + if (_hasPrivateKey == null) + { + try + { + byte[] seed = MLDsa.ExportMLDsaPrivateSeed(); + CryptographicOperations.ZeroMemory(seed); + _hasPrivateKey = true; + } + catch (CryptographicException) + { + _hasPrivateKey = false; + } + } + + return _hasPrivateKey.Value; + } + } + + /// + /// Gets a value indicating the existence of the private key. + /// + /// + /// if the private key exists. + /// if the private key does not exist. + /// if the existence of the private key cannot be determined. + /// + public override PrivateKeyStatus PrivateKeyStatus + { + get + { + if (_hasPrivateKey == null) + { + try + { + byte[] seed = MLDsa.ExportMLDsaPrivateSeed(); + CryptographicOperations.ZeroMemory(seed); + _hasPrivateKey = true; + } + catch (CryptographicException) + { + _hasPrivateKey = false; + } + } + + return _hasPrivateKey.Value ? PrivateKeyStatus.Exists : PrivateKeyStatus.DoesNotExist; + } + } + + /// + /// Gets the ML-DSA key size in bits. + /// + public override int KeySize => MLDsa.Algorithm.PublicKeySizeInBytes * 8; + + /// + /// Determines whether the can compute a JWK thumbprint. + /// + /// if JWK thumbprint can be computed; otherwise, . + /// See: . + public override bool CanComputeJwkThumbprint() => true; + + /// + /// Computes a SHA256 hash over the . + /// + /// A JWK thumbprint. + /// See: . + public override byte[] ComputeJwkThumbprint() + { + string algorithmName = GetAlgorithmName(MLDsa.Algorithm); + byte[] publicKey = MLDsa.ExportMLDsaPublicKey(); + var canonicalJwk = $@"{{""{JsonWebKeyParameterNames.Alg}"":""{algorithmName}"",""{JsonWebKeyParameterNames.Kty}"":""{JsonWebAlgorithmsKeyTypes.Akp}"",""{JsonWebKeyParameterNames.Pub}"":""{Base64UrlEncoder.Encode(publicKey)}""}}"; + return Utility.GenerateSha256Hash(canonicalJwk); + } + + /// + /// Gets the JWK algorithm name for the specified . + /// + /// The ML-DSA algorithm. + /// The algorithm name string (e.g., "ML-DSA-44"). + /// Thrown when the algorithm is not supported. + internal static string GetAlgorithmName(MLDsaAlgorithm algorithm) + { + if (algorithm == MLDsaAlgorithm.MLDsa44) + return SecurityAlgorithms.MlDsa44; + if (algorithm == MLDsaAlgorithm.MLDsa65) + return SecurityAlgorithms.MlDsa65; + if (algorithm == MLDsaAlgorithm.MLDsa87) + return SecurityAlgorithms.MlDsa87; + + throw LogHelper.LogArgumentException(nameof(algorithm), LogMessages.IDX10652, algorithm); + } +} diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt index adef10032b..d4f3bd46e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ ~Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity.SecurityToken.set -> void +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt index adef10032b..d4f3bd46e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ ~Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity.SecurityToken.set -> void +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt index adef10032b..d4f3bd46e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ ~Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity.SecurityToken.set -> void +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt index adef10032b..d4f3bd46e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ ~Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity.SecurityToken.set -> void +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt index adef10032b..d4f3bd46e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ ~Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity.SecurityToken.set -> void +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt index adef10032b..d4f3bd46e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ ~Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity.SecurityToken.set -> void +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index adef10032b..d4f3bd46e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,20 @@ ~Microsoft.IdentityModel.Tokens.CaseSensitiveClaimsIdentity.SecurityToken.set -> void +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey diff --git a/src/Microsoft.IdentityModel.Tokens/SecurityAlgorithms.cs b/src/Microsoft.IdentityModel.Tokens/SecurityAlgorithms.cs index 3db07ff95e..66b084b0a0 100644 --- a/src/Microsoft.IdentityModel.Tokens/SecurityAlgorithms.cs +++ b/src/Microsoft.IdentityModel.Tokens/SecurityAlgorithms.cs @@ -85,6 +85,11 @@ public static class SecurityAlgorithms public const string RsaSsaPssSha384 = "PS384"; public const string RsaSsaPssSha512 = "PS512"; + // See: https://datatracker.ietf.org/doc/draft-ietf-cose-dilithium/ (RFC 9964 pending) + public const string MlDsa44 = "ML-DSA-44"; + public const string MlDsa65 = "ML-DSA-65"; + public const string MlDsa87 = "ML-DSA-87"; + // See: https://datatracker.ietf.org/doc/html/rfc7518#section-5.1 public const string Aes128CbcHmacSha256 = "A128CBC-HS256"; public const string Aes192CbcHmacSha384 = "A192CBC-HS384"; diff --git a/src/Microsoft.IdentityModel.Tokens/SupportedAlgorithms.cs b/src/Microsoft.IdentityModel.Tokens/SupportedAlgorithms.cs index 7be591da2c..65270a52c7 100644 --- a/src/Microsoft.IdentityModel.Tokens/SupportedAlgorithms.cs +++ b/src/Microsoft.IdentityModel.Tokens/SupportedAlgorithms.cs @@ -104,6 +104,13 @@ internal static class SupportedAlgorithms SecurityAlgorithms.EcdhEsA256kw }; + internal static readonly ICollection MlDsaSigningAlgorithms = new Collection + { + SecurityAlgorithms.MlDsa44, + SecurityAlgorithms.MlDsa65, + SecurityAlgorithms.MlDsa87 + }; + /// /// Creating a Signature requires the use of a . /// This method returns the @@ -115,9 +122,27 @@ internal static class SupportedAlgorithms /// if is not supported. internal static HashAlgorithmName GetHashAlgorithmName(string algorithm) { + if (TryGetHashAlgorithmName(algorithm, out HashAlgorithmName hashAlgorithmName)) + return hashAlgorithmName; + if (string.IsNullOrWhiteSpace(algorithm)) throw LogHelper.LogArgumentNullException(nameof(algorithm)); + throw LogHelper.LogExceptionMessage(new ArgumentOutOfRangeException(nameof(algorithm), LogHelper.FormatInvariant(LogMessages.IDX10652, LogHelper.MarkAsNonPII(algorithm)))); + } + + /// + /// Attempts to get the for the specified signature algorithm. + /// Returns for algorithms that do not use an external hash (e.g., ML-DSA). + /// + internal static bool TryGetHashAlgorithmName(string algorithm, out HashAlgorithmName hashAlgorithmName) + { + if (string.IsNullOrWhiteSpace(algorithm)) + { + hashAlgorithmName = default; + return false; + } + switch (algorithm) { case SecurityAlgorithms.EcdsaSha256: @@ -126,7 +151,8 @@ internal static HashAlgorithmName GetHashAlgorithmName(string algorithm) case SecurityAlgorithms.RsaSha256Signature: case SecurityAlgorithms.RsaSsaPssSha256: case SecurityAlgorithms.RsaSsaPssSha256Signature: - return HashAlgorithmName.SHA256; + hashAlgorithmName = HashAlgorithmName.SHA256; + return true; case SecurityAlgorithms.EcdsaSha384: case SecurityAlgorithms.EcdsaSha384Signature: @@ -134,7 +160,8 @@ internal static HashAlgorithmName GetHashAlgorithmName(string algorithm) case SecurityAlgorithms.RsaSha384Signature: case SecurityAlgorithms.RsaSsaPssSha384: case SecurityAlgorithms.RsaSsaPssSha384Signature: - return HashAlgorithmName.SHA384; + hashAlgorithmName = HashAlgorithmName.SHA384; + return true; case SecurityAlgorithms.EcdsaSha512: case SecurityAlgorithms.EcdsaSha512Signature: @@ -142,10 +169,12 @@ internal static HashAlgorithmName GetHashAlgorithmName(string algorithm) case SecurityAlgorithms.RsaSha512Signature: case SecurityAlgorithms.RsaSsaPssSha512: case SecurityAlgorithms.RsaSsaPssSha512Signature: - return HashAlgorithmName.SHA512; + hashAlgorithmName = HashAlgorithmName.SHA512; + return true; } - throw LogHelper.LogExceptionMessage(new ArgumentOutOfRangeException(nameof(algorithm), LogHelper.FormatInvariant(LogMessages.IDX10652, LogHelper.MarkAsNonPII(algorithm)))); + hashAlgorithmName = default; + return false; } /// @@ -215,7 +244,9 @@ public static bool IsSupportedAlgorithm(string algorithm, SecurityKey key) if (key is X509SecurityKey x509Key) { - // only RSA keys are supported + if (x509Key.MlDsaPublicKey != null) + return MlDsaSecurityKey.GetAlgorithmName(x509Key.MlDsaPublicKey.Algorithm) == algorithm; + if (x509Key.PublicKey as RSA == null) return false; @@ -230,6 +261,8 @@ public static bool IsSupportedAlgorithm(string algorithm, SecurityKey key) return IsSupportedEcdsaAlgorithm(algorithm); else if (JsonWebAlgorithmsKeyTypes.Octet.Equals(jsonWebKey.Kty)) return IsSupportedSymmetricAlgorithm(algorithm); + else if (JsonWebAlgorithmsKeyTypes.Akp.Equals(jsonWebKey.Kty)) + return IsSupportedMlDsaAlgorithm(algorithm) && algorithm == jsonWebKey.Alg; return false; } @@ -237,6 +270,9 @@ public static bool IsSupportedAlgorithm(string algorithm, SecurityKey key) if (key is ECDsaSecurityKey) return IsSupportedEcdsaAlgorithm(algorithm); + if (key is MlDsaSecurityKey mlDsaKey) + return MlDsaSecurityKey.GetAlgorithmName(mlDsaKey.MLDsa.Algorithm) == algorithm; + if (key as SymmetricSecurityKey != null) return IsSupportedSymmetricAlgorithm(algorithm); @@ -288,6 +324,11 @@ private static bool IsSupportedEcdsaAlgorithm(string algorithm) return EcdsaSigningAlgorithms.Contains(algorithm); } + internal static bool IsSupportedMlDsaAlgorithm(string algorithm) + { + return MlDsaSigningAlgorithms.Contains(algorithm); + } + internal static bool IsSupportedHashAlgorithm(string algorithm) { return HashAlgorithms.Contains(algorithm); @@ -394,6 +435,10 @@ SecurityAlgorithms.RsaSsaPssSha512 or SecurityAlgorithms.RsaSsaPssSha512Signature or SecurityAlgorithms.RsaSha512Signature => 1024, + SecurityAlgorithms.MlDsa44 => 2420, + SecurityAlgorithms.MlDsa65 => 3309, + SecurityAlgorithms.MlDsa87 => 4627, + // if we don't know the algorithm, report 2K twice as big as any known algorithm. _ => 2048, }; diff --git a/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs b/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs index 0d12fce217..0378bd52ba 100644 --- a/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs +++ b/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs @@ -77,6 +77,11 @@ private static class KeyAlgorithmIds public const string Symmetric512 = "SYM-512"; public const string SymmetricUnknown = "SYM-UNKNOWN"; + public const string MlDsa44 = "MLDSA-44"; + public const string MlDsa65 = "MLDSA-65"; + public const string MlDsa87 = "MLDSA-87"; + public const string MlDsaUnknown = "MLDSA-UNKNOWN"; + public const string Unknown = "UNKNOWN"; // Key type is not recognized. public const string NoKey = "NO-KEY"; // Used when no key is found or provided to differentiate from unknown key types } @@ -130,7 +135,15 @@ internal static string GetKeyAlgorithmId(SecurityKey key) JsonWebKey jwk => GetJsonWebKeyAlgorithmId(jwk), - // EdDSA, MLDSA and other key types can be added here when needed. + MlDsaSecurityKey mlDsa => mlDsa.MLDsa.Algorithm.Name switch + { + "ML-DSA-44" => KeyAlgorithmIds.MlDsa44, + "ML-DSA-65" => KeyAlgorithmIds.MlDsa65, + "ML-DSA-87" => KeyAlgorithmIds.MlDsa87, + _ => KeyAlgorithmIds.MlDsaUnknown + }, + + // EdDSA and other key types can be added here when needed. _ => KeyAlgorithmIds.Unknown }; } @@ -165,6 +178,13 @@ private static string GetJsonWebKeyAlgorithmId(JsonWebKey jwk) 512 => KeyAlgorithmIds.Symmetric512, _ => KeyAlgorithmIds.SymmetricUnknown }, + JsonWebAlgorithmsKeyTypes.Akp => jwk.Alg switch + { + SecurityAlgorithms.MlDsa44 => KeyAlgorithmIds.MlDsa44, + SecurityAlgorithms.MlDsa65 => KeyAlgorithmIds.MlDsa65, + SecurityAlgorithms.MlDsa87 => KeyAlgorithmIds.MlDsa87, + _ => KeyAlgorithmIds.MlDsaUnknown + }, _ => KeyAlgorithmIds.Unknown }; } diff --git a/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs index 8b9e9f2c7b..6715c0c5d8 100644 --- a/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs @@ -24,8 +24,20 @@ public class X509SecurityKey : AsymmetricSecurityKey // and const string ECDsaOid = "1.2.840.10045.2.1"; + // OIDs for ML-DSA (FIPS 204) + // + const string MlDsa44Oid = "2.16.840.1.101.3.4.3.17"; + const string MlDsa65Oid = "2.16.840.1.101.3.4.3.18"; + const string MlDsa87Oid = "2.16.840.1.101.3.4.3.19"; + AsymmetricAlgorithm _privateKey; AsymmetricAlgorithm _publicKey; + MLDsa _mlDsaPrivateKey; + MLDsa _mlDsaPublicKey; + bool _isMlDsa; + volatile bool _mlDsaPrivateKeyInitialized; + volatile bool _mlDsaPublicKeyInitialized; + volatile bool _mlDsaPrivateKeyUnsupported; #if NET9_0_OR_GREATER Lock _thisLock = new(); #else @@ -36,6 +48,7 @@ internal X509SecurityKey(JsonWebKey webKey) { Certificate = CertificateHelper.LoadX509Certificate(webKey.X5c[0]); X5t = Base64UrlEncoder.Encode(Certificate.GetCertHash()); + _isMlDsa = IsMlDsaOid(Certificate.PublicKey.Oid.Value); webKey.ConvertedSecurityKey = this; } @@ -49,6 +62,7 @@ public X509SecurityKey(X509Certificate2 certificate) Certificate = certificate ?? throw LogHelper.LogArgumentNullException(nameof(certificate)); KeyId = certificate.Thumbprint; X5t = Base64UrlEncoder.Encode(certificate.GetCertHash()); + _isMlDsa = IsMlDsaOid(certificate.PublicKey.Oid.Value); } /// @@ -63,6 +77,7 @@ public X509SecurityKey(X509Certificate2 certificate, string keyId) Certificate = certificate ?? throw LogHelper.LogArgumentNullException(nameof(certificate)); KeyId = string.IsNullOrEmpty(keyId) ? throw LogHelper.LogArgumentNullException(nameof(keyId)) : keyId; X5t = Base64UrlEncoder.Encode(certificate.GetCertHash()); + _isMlDsa = IsMlDsaOid(certificate.PublicKey.Oid.Value); } /// @@ -70,7 +85,13 @@ public X509SecurityKey(X509Certificate2 certificate, string keyId) /// public override int KeySize { - get => PublicKey.KeySize; + get + { + if (_isMlDsa) + return MlDsaPublicKey?.Algorithm.PublicKeySizeInBytes * 8 ?? 0; + + return PublicKey.KeySize; + } } /// @@ -145,6 +166,81 @@ public AsymmetricAlgorithm PublicKey return _publicKey; } } + + /// + /// Gets the ML-DSA private key from the certificate, or null if the certificate + /// does not contain an ML-DSA key or does not have a private key. + /// + internal MLDsa MlDsaPrivateKey + { + get + { + if (!_mlDsaPrivateKeyInitialized && _isMlDsa) + { + lock (ThisLock) + { + if (!_mlDsaPrivateKeyInitialized) + { + try + { +#pragma warning disable SYSLIB5006 // GetMLDsaPrivateKey is experimental + _mlDsaPrivateKey = Certificate.GetMLDsaPrivateKey(); +#pragma warning restore SYSLIB5006 + } + catch (PlatformNotSupportedException) + { + // On .NET 6, GetMLDsaPrivateKey() from Microsoft.Bcl.Cryptography + // throws PlatformNotSupportedException. ML-DSA X.509 private key + // extraction requires .NET 8+. On .NET 6, ML-DSA certificates can + // still be used for signature verification (public key works) but + // not for signing via X509SecurityKey. Callers needing to sign on + // .NET 6 should use MlDsaSecurityKey with a standalone MLDsa key. + _mlDsaPrivateKeyUnsupported = true; + } + + _mlDsaPrivateKeyInitialized = true; + } + } + } + + return _mlDsaPrivateKey; + } + } + + /// + /// Gets the ML-DSA public key from the certificate, or null if the certificate + /// does not contain an ML-DSA key or the platform does not support ML-DSA. + /// + internal MLDsa MlDsaPublicKey + { + get + { + if (!_mlDsaPublicKeyInitialized && _isMlDsa) + { + lock (ThisLock) + { + if (!_mlDsaPublicKeyInitialized) + { + try + { +#pragma warning disable SYSLIB5006 // GetMLDsaPublicKey is experimental + _mlDsaPublicKey = Certificate.GetMLDsaPublicKey(); +#pragma warning restore SYSLIB5006 + } + catch (PlatformNotSupportedException) + { + // GetMLDsaPublicKey() may not be supported on all platforms. + // Return null so callers can degrade gracefully. + } + + _mlDsaPublicKeyInitialized = true; + } + } + } + + return _mlDsaPublicKey; + } + } #if NET9_0_OR_GREATER Lock ThisLock => _thisLock; #else @@ -160,7 +256,13 @@ object ThisLock [Obsolete("HasPrivateKey method is deprecated, please use PrivateKeyStatus.")] public override bool HasPrivateKey { - get { return PrivateKey != null; } + get + { + if (_isMlDsa) + return MlDsaPrivateKey != null; + + return PrivateKey != null; + } } /// @@ -171,6 +273,23 @@ public override PrivateKeyStatus PrivateKeyStatus { get { + if (_isMlDsa) + { + // On platforms where GetMLDsaPrivateKey() throws PlatformNotSupportedException, + // we return Unknown rather than DoesNotExist because the private key may exist + // in the certificate but the platform cannot extract it. + // Note: AsymmetricSignatureProvider only blocks signing when PrivateKeyStatus == + // DoesNotExist, so Unknown will pass the constructor guard. However, the adapter + // will fail with a clear InvalidOperationException (IDX10723) when it attempts + // to access the null MlDsaPrivateKey during signing initialization. + MLDsa mlDsaPrivateKey = MlDsaPrivateKey; + + if (_mlDsaPrivateKeyUnsupported) + return PrivateKeyStatus.Unknown; + + return mlDsaPrivateKey != null ? PrivateKeyStatus.Exists : PrivateKeyStatus.DoesNotExist; + } + return PrivateKey == null ? PrivateKeyStatus.DoesNotExist : PrivateKeyStatus.Exists; } } @@ -193,6 +312,9 @@ public X509Certificate2 Certificate /// See: public override bool CanComputeJwkThumbprint() { + if (_isMlDsa) + return MlDsaPublicKey != null; + return PublicKey is RSA || PublicKey is ECDsa; } @@ -203,6 +325,18 @@ public override bool CanComputeJwkThumbprint() /// See: public override byte[] ComputeJwkThumbprint() { + if (_isMlDsa) + { + if (MlDsaPublicKey == null) + throw LogHelper.LogExceptionMessage( + new PlatformNotSupportedException( + LogHelper.FormatInvariant( + LogMessages.IDX10724, + LogHelper.MarkAsNonPII(KeyId)))); + + return new MlDsaSecurityKey(MlDsaPublicKey).ComputeJwkThumbprint(); + } + return PublicKey is RSA ? new RsaSecurityKey(PublicKey as RSA).ComputeJwkThumbprint() : new ECDsaSecurityKey(PublicKey as ECDsa).ComputeJwkThumbprint(); } @@ -226,5 +360,10 @@ public override int GetHashCode() { return Certificate.GetHashCode(); } + + private static bool IsMlDsaOid(string oid) + { + return oid == MlDsa44Oid || oid == MlDsa65Oid || oid == MlDsa87Oid; + } } } diff --git a/test/Microsoft.IdentityModel.TestUtils/MlDsaConditionalAttributes.cs b/test/Microsoft.IdentityModel.TestUtils/MlDsaConditionalAttributes.cs new file mode 100644 index 0000000000..bf40e75048 --- /dev/null +++ b/test/Microsoft.IdentityModel.TestUtils/MlDsaConditionalAttributes.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using Xunit; + +namespace Microsoft.IdentityModel.TestUtils +{ + /// + /// A that skips the test when ML-DSA is not supported + /// on the current platform. ML-DSA requires OS-level cryptographic support (e.g., + /// SymCrypt) that may not be present on older OS versions. + /// + public sealed class MlDsaFactAttribute : FactAttribute + { + public MlDsaFactAttribute() + { + if (!MLDsa.IsSupported) + Skip = "ML-DSA is not supported on this platform."; + } + } + + /// + /// A that skips the test when ML-DSA is not supported + /// on the current platform. ML-DSA requires OS-level cryptographic support (e.g., + /// SymCrypt) that may not be present on older OS versions. + /// + public sealed class MlDsaTheoryAttribute : TheoryAttribute + { + public MlDsaTheoryAttribute() + { + if (!MLDsa.IsSupported) + Skip = "ML-DSA is not supported on this platform."; + } + } +} diff --git a/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs b/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs new file mode 100644 index 0000000000..e82fde6705 --- /dev/null +++ b/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.IdentityModel.TestUtils +{ + /// + /// ML-DSA test keying material, separated from to avoid + /// on platforms where ML-DSA is not supported. + /// All members are lazily initialized; callers must check + /// before accessing key material to avoid . + /// + public static class MlDsaKeyingMaterial + { + /// + /// Returns true if ML-DSA is supported on the current platform. + /// + public static bool IsSupported => MLDsa.IsSupported; + + /// + /// Returns true if ML-DSA private keys can be extracted from X.509 certificates. + /// GetMLDsaPrivateKey() throws PlatformNotSupportedException on .NET 6. + /// + public static bool CanExtractMlDsaPrivateKeyFromX509() + { + try + { +#pragma warning disable SYSLIB5006 + using var key = MlDsa44Cert.GetMLDsaPrivateKey(); +#pragma warning restore SYSLIB5006 + return key != null; + } + catch (PlatformNotSupportedException) + { + return false; + } + } + + // ML-DSA keys — lazily generated since MLDsa.GenerateKey() requires + // OS-level crypto support that may not be present on all platforms. + private static MlDsaSecurityKey _mlDsa44Key; + private static MlDsaSecurityKey _mlDsa65Key; + private static MlDsaSecurityKey _mlDsa87Key; + + public static MlDsaSecurityKey MlDsa44Key => _mlDsa44Key ??= new MlDsaSecurityKey(MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44)); + public static MlDsaSecurityKey MlDsa65Key => _mlDsa65Key ??= new MlDsaSecurityKey(MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65)); + public static MlDsaSecurityKey MlDsa87Key => _mlDsa87Key ??= new MlDsaSecurityKey(MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa87)); + + private static MlDsaSecurityKey _mlDsa44KeyPublic; + private static MlDsaSecurityKey _mlDsa65KeyPublic; + private static MlDsaSecurityKey _mlDsa87KeyPublic; + + public static MlDsaSecurityKey MlDsa44Key_Public => _mlDsa44KeyPublic ??= CreateMlDsaPublicKey(MlDsa44Key); + public static MlDsaSecurityKey MlDsa65Key_Public => _mlDsa65KeyPublic ??= CreateMlDsaPublicKey(MlDsa65Key); + public static MlDsaSecurityKey MlDsa87Key_Public => _mlDsa87KeyPublic ??= CreateMlDsaPublicKey(MlDsa87Key); + + public static MlDsaSecurityKey CreateMlDsaPublicKey(MlDsaSecurityKey privateKey) + { + byte[] publicKeyBytes = privateKey.MLDsa.ExportMLDsaPublicKey(); + var mlDsa = MLDsa.ImportMLDsaPublicKey(privateKey.MLDsa.Algorithm, publicKeyBytes); + return new MlDsaSecurityKey(mlDsa); + } + + public static JsonWebKey CreateJsonWebKeyMlDsa(string algorithm, string kid, MlDsaSecurityKey key) + { + byte[] publicKeyBytes = key.MLDsa.ExportMLDsaPublicKey(); + var jwk = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = algorithm, + Pub = Base64UrlEncoder.Encode(publicKeyBytes), + Kid = kid, + KeyId = kid + }; + + if (key.PrivateKeyStatus == PrivateKeyStatus.Exists) + { + byte[] seedBytes = key.MLDsa.ExportMLDsaPrivateSeed(); + jwk.Priv = Base64UrlEncoder.Encode(seedBytes); + Array.Clear(seedBytes, 0, seedBytes.Length); + } + + return jwk; + } + + public static JsonWebKey JsonWebKeyMlDsa44 => CreateJsonWebKeyMlDsa(SecurityAlgorithms.MlDsa44, "JsonWebKeyMlDsa44", MlDsa44Key); + public static JsonWebKey JsonWebKeyMlDsa44_Public => CreateJsonWebKeyMlDsa(SecurityAlgorithms.MlDsa44, "JsonWebKeyMlDsa44_Public", MlDsa44Key_Public); + public static JsonWebKey JsonWebKeyMlDsa65 => CreateJsonWebKeyMlDsa(SecurityAlgorithms.MlDsa65, "JsonWebKeyMlDsa65", MlDsa65Key); + public static JsonWebKey JsonWebKeyMlDsa65_Public => CreateJsonWebKeyMlDsa(SecurityAlgorithms.MlDsa65, "JsonWebKeyMlDsa65_Public", MlDsa65Key_Public); + public static JsonWebKey JsonWebKeyMlDsa87 => CreateJsonWebKeyMlDsa(SecurityAlgorithms.MlDsa87, "JsonWebKeyMlDsa87", MlDsa87Key); + public static JsonWebKey JsonWebKeyMlDsa87_Public => CreateJsonWebKeyMlDsa(SecurityAlgorithms.MlDsa87, "JsonWebKeyMlDsa87_Public", MlDsa87Key_Public); + + // ML-DSA X.509 certificates — pre-generated PFX bytes (password: "test"). + // Generated on .NET 10 using CertificateRequest(string, MLDsa).CreateSelfSigned(). + // Valid 2025-01-01 to 2035-01-01. Embedded as base64 for cross-TFM reuse. + // Note: GetMLDsaPrivateKey() throws PlatformNotSupportedException on net6.0. + private static readonly string MlDsa44CertPfx = "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"; + private static readonly string MlDsa65CertPfx = "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"; + private static readonly string MlDsa87CertPfx = "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"; + private const string MlDsaCertPassword = "test"; + + private static X509Certificate2 _mlDsa44Cert; + private static X509Certificate2 _mlDsa65Cert; + private static X509Certificate2 _mlDsa87Cert; + + public static X509Certificate2 MlDsa44Cert => _mlDsa44Cert ??= LoadMlDsaCert(MlDsa44CertPfx); + public static X509Certificate2 MlDsa65Cert => _mlDsa65Cert ??= LoadMlDsaCert(MlDsa65CertPfx); + public static X509Certificate2 MlDsa87Cert => _mlDsa87Cert ??= LoadMlDsaCert(MlDsa87CertPfx); + + private static X509Certificate2 LoadMlDsaCert(string pfxBase64) + { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(pfxBase64), MlDsaCertPassword, X509KeyStorageFlags.Exportable); +#else +#pragma warning disable SYSLIB0057 + return new X509Certificate2(Convert.FromBase64String(pfxBase64), MlDsaCertPassword, X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 +#endif + } + + private static X509SecurityKey _x509MlDsa44Key; + private static X509SecurityKey _x509MlDsa65Key; + private static X509SecurityKey _x509MlDsa87Key; + + public static X509SecurityKey X509MlDsa44Key => _x509MlDsa44Key ??= new X509SecurityKey(MlDsa44Cert); + public static X509SecurityKey X509MlDsa65Key => _x509MlDsa65Key ??= new X509SecurityKey(MlDsa65Cert); + public static X509SecurityKey X509MlDsa87Key => _x509MlDsa87Key ??= new X509SecurityKey(MlDsa87Cert); + } +} + diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTestData.cs b/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTestData.cs index 2566f01e88..53208376aa 100644 --- a/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTestData.cs +++ b/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTestData.cs @@ -101,6 +101,43 @@ public class AsymmetricSignatureTestData { KeyingMaterial.X509SecurityKeySelfSigned2048_SHA512, KeyingMaterial.X509SecurityKeySelfSigned2048_SHA512_Public, "X509Key3" } }; + private static List> _mlDsaSecurityKeys; + public static List> MlDsaSecurityKeys => _mlDsaSecurityKeys ??= new List> + { + Tuple.Create(MlDsaKeyingMaterial.MlDsa44Key, MlDsaKeyingMaterial.MlDsa44Key_Public, "MlDsa44Key", SecurityAlgorithms.MlDsa44), + Tuple.Create(MlDsaKeyingMaterial.MlDsa65Key, MlDsaKeyingMaterial.MlDsa65Key_Public, "MlDsa65Key", SecurityAlgorithms.MlDsa65), + Tuple.Create(MlDsaKeyingMaterial.MlDsa87Key, MlDsaKeyingMaterial.MlDsa87Key_Public, "MlDsa87Key", SecurityAlgorithms.MlDsa87) + }; + + private static List> _jsonMlDsaSecurityKeys; + public static List> JsonMlDsaSecurityKeys => _jsonMlDsaSecurityKeys ??= new List> + { + Tuple.Create(MlDsaKeyingMaterial.JsonWebKeyMlDsa44, MlDsaKeyingMaterial.JsonWebKeyMlDsa44_Public, "JsonMlDsa44Key", SecurityAlgorithms.MlDsa44), + Tuple.Create(MlDsaKeyingMaterial.JsonWebKeyMlDsa65, MlDsaKeyingMaterial.JsonWebKeyMlDsa65_Public, "JsonMlDsa65Key", SecurityAlgorithms.MlDsa65), + Tuple.Create(MlDsaKeyingMaterial.JsonWebKeyMlDsa87, MlDsaKeyingMaterial.JsonWebKeyMlDsa87_Public, "JsonMlDsa87Key", SecurityAlgorithms.MlDsa87) + }; + + private static List> _x509MlDsaSecurityKeys; + public static List> X509MlDsaSecurityKeys => _x509MlDsaSecurityKeys ??= new List> + { + Tuple.Create(MlDsaKeyingMaterial.X509MlDsa44Key, MlDsaKeyingMaterial.X509MlDsa44Key, "X509MlDsa44Key", SecurityAlgorithms.MlDsa44), + Tuple.Create(MlDsaKeyingMaterial.X509MlDsa65Key, MlDsaKeyingMaterial.X509MlDsa65Key, "X509MlDsa65Key", SecurityAlgorithms.MlDsa65), + Tuple.Create(MlDsaKeyingMaterial.X509MlDsa87Key, MlDsaKeyingMaterial.X509MlDsa87Key, "X509MlDsa87Key", SecurityAlgorithms.MlDsa87) + }; + + public static void AddMlDsaAlgorithmVariations(SignatureProviderTheoryData theoryData, string algorithm, TheoryData variations) + { + // ML-DSA has a 1:1 mapping between key and algorithm — no algorithm variations like ECDSA. + variations.Add(new SignatureProviderTheoryData + { + SigningAlgorithm = algorithm, + SigningKey = theoryData.SigningKey, + TestId = theoryData.TestId + algorithm, + VerifyAlgorithm = algorithm, + VerifyKey = theoryData.VerifyKey + }); + } + public static void AddECDsaAlgorithmVariations(SignatureProviderTheoryData theoryData, TheoryData variations) { foreach (var algorithm in ECDsaSigningAlgorithms) diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTests.cs b/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTests.cs index 083c36a9e4..b130a6ea76 100644 --- a/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTests.cs +++ b/test/Microsoft.IdentityModel.Tokens.Tests/AsymmetricSignatureTests.cs @@ -236,6 +236,44 @@ public static TheoryData SignVerifyTheoryData }, theoryData); + if (MLDsa.IsSupported) + { + foreach (var mlDsaKeyTuple in AsymmetricSignatureTestData.MlDsaSecurityKeys) + AsymmetricSignatureTestData.AddMlDsaAlgorithmVariations(new SignatureProviderTheoryData + { + SigningKey = mlDsaKeyTuple.Item1, + TestId = mlDsaKeyTuple.Item3, + VerifyKey = mlDsaKeyTuple.Item2 + }, + mlDsaKeyTuple.Item4, + theoryData); + + foreach (var jsonKeyTuple in AsymmetricSignatureTestData.JsonMlDsaSecurityKeys) + AsymmetricSignatureTestData.AddMlDsaAlgorithmVariations(new SignatureProviderTheoryData + { + SigningKey = jsonKeyTuple.Item1, + TestId = jsonKeyTuple.Item3, + VerifyKey = jsonKeyTuple.Item2 + }, + jsonKeyTuple.Item4, + theoryData); + } + + // X509 ML-DSA sign/verify requires private key extraction from PFX. + // GetMLDsaPrivateKey() throws PlatformNotSupportedException on .NET 6. + if (MLDsa.IsSupported && MlDsaKeyingMaterial.CanExtractMlDsaPrivateKeyFromX509()) + { + foreach (var x509MlDsaKeyTuple in AsymmetricSignatureTestData.X509MlDsaSecurityKeys) + AsymmetricSignatureTestData.AddMlDsaAlgorithmVariations(new SignatureProviderTheoryData + { + SigningKey = x509MlDsaKeyTuple.Item1, + TestId = x509MlDsaKeyTuple.Item3, + VerifyKey = x509MlDsaKeyTuple.Item2 + }, + x509MlDsaKeyTuple.Item4, + theoryData); + } + foreach (var jsonKeyTuple in AsymmetricSignatureTestData.JsonRsaSecurityKeys) AsymmetricSignatureTestData.AddRsaAlgorithmVariations(new SignatureProviderTheoryData { @@ -417,6 +455,18 @@ public static TheoryData VerifyAlgorithms TestId = algorithm }); + if (MLDsa.IsSupported) + { + foreach (var algorithm in SupportedAlgorithms.MlDsaSigningAlgorithms) + theoryData.Add( + new AsymmetricSignatureProviderTheoryData + { + Algorithm = algorithm, + SecurityKey = MlDsaKeyingMaterial.MlDsa44Key, + TestId = algorithm + }); + } + return theoryData; } } @@ -435,16 +485,16 @@ public void VerifyDefaultMinimumAsymmetricKeySizeAreSupported() var context = TestUtilities.WriteHeader($"{this}.VerifyDefaultMinimumAsymmetricKeySizeAreSupported", theoryData); foreach (var algorithm in AsymmetricSignatureProvider.DefaultMinimumAsymmetricKeySizeInBitsForSigningMap.Keys) - if (!(SupportedAlgorithms.EcdsaSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaPssSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaSigningAlgorithms.Contains(algorithm))) + if (!(SupportedAlgorithms.EcdsaSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaPssSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.MlDsaSigningAlgorithms.Contains(algorithm))) { - context.AddDiff($"DefaultMinimumAsymmetricKeySizeInBitsForSigningMap, algorithm: '{algorithm}' not found in (SupportedAlgorithms.EcdsaSigningAlgorithms || SupportedAlgorithms.RsaPssSigningAlgorithms || SupportedAlgorithms.RsaSigningAlgorithms."); + context.AddDiff($"DefaultMinimumAsymmetricKeySizeInBitsForSigningMap, algorithm: '{algorithm}' not found in (SupportedAlgorithms.EcdsaSigningAlgorithms || SupportedAlgorithms.RsaPssSigningAlgorithms || SupportedAlgorithms.RsaSigningAlgorithms || SupportedAlgorithms.MlDsaSigningAlgorithms."); context.AddDiff($"seems like algorithm was added somewhere: '{algorithm}'."); } foreach (var algorithm in AsymmetricSignatureProvider.DefaultMinimumAsymmetricKeySizeInBitsForVerifyingMap.Keys) - if (!(SupportedAlgorithms.EcdsaSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaPssSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaSigningAlgorithms.Contains(algorithm))) + if (!(SupportedAlgorithms.EcdsaSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaPssSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.RsaSigningAlgorithms.Contains(algorithm) || SupportedAlgorithms.MlDsaSigningAlgorithms.Contains(algorithm))) { - context.AddDiff($"DefaultMinimumAsymmetricKeySizeInBitsForVerifyingMap, algorithm: '{algorithm}' not found in (SupportedAlgorithms.EcdsaSigningAlgorithms || SupportedAlgorithms.RsaPssSigningAlgorithms || SupportedAlgorithms.RsaSigningAlgorithms"); + context.AddDiff($"DefaultMinimumAsymmetricKeySizeInBitsForVerifyingMap, algorithm: '{algorithm}' not found in (SupportedAlgorithms.EcdsaSigningAlgorithms || SupportedAlgorithms.RsaPssSigningAlgorithms || SupportedAlgorithms.RsaSigningAlgorithms || SupportedAlgorithms.MlDsaSigningAlgorithms"); context.AddDiff($"seems like algorithm was added somewhere: '{algorithm}'."); } diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs new file mode 100644 index 0000000000..50f96ddf08 --- /dev/null +++ b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs @@ -0,0 +1,925 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.TestUtils; +using Microsoft.IdentityModel.Tokens.Experimental; +using Microsoft.IdentityModel.Tokens.Json; +using Xunit; + +#pragma warning disable CS3016 // Arrays as attribute arguments is not CLS-compliant + +namespace Microsoft.IdentityModel.Tokens.Tests +{ + public class MlDsaSecurityKeyTests + { + [MlDsaFact] + public void Constructor_NullMlDsa_ThrowsArgumentNullException() + { + var ee = ExpectedException.ArgumentNullException("mlDsa"); + try + { + var key = new MlDsaSecurityKey(null); + ee.ProcessNoException(); + } + catch (Exception ex) + { + ee.ProcessException(ex); + } + } + + [MlDsaFact] + public void Constructor_ValidMlDsa() + { + using var mlDsa = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + var key = new MlDsaSecurityKey(mlDsa); + + Assert.NotNull(key.MLDsa); + Assert.True(key.KeySize > 0, "KeySize should be positive"); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void KeySize_MatchesExpectedBits(string algorithm) + { + var mlDsaAlg = MlDsaAdapter.GetMLDsaAlgorithm(algorithm); + using var mlDsa = MLDsa.GenerateKey(mlDsaAlg); + var key = new MlDsaSecurityKey(mlDsa); + + int expectedBits = mlDsaAlg.PublicKeySizeInBytes * 8; + Assert.Equal(expectedBits, key.KeySize); + } + + [MlDsaFact] + public void HasPrivateKey_WithPrivateKey_ReturnsTrue() + { + using var mlDsa = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + var key = new MlDsaSecurityKey(mlDsa); + +#pragma warning disable CS0618 // Type or member is obsolete + Assert.True(key.HasPrivateKey); +#pragma warning restore CS0618 + Assert.Equal(PrivateKeyStatus.Exists, key.PrivateKeyStatus); + } + + [MlDsaFact] + public void HasPrivateKey_WithPublicKeyOnly_ReturnsFalse() + { + using var privateKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + byte[] publicKeyBytes = privateKey.ExportMLDsaPublicKey(); + using var publicOnly = MLDsa.ImportMLDsaPublicKey(MLDsaAlgorithm.MLDsa44, publicKeyBytes); + var key = new MlDsaSecurityKey(publicOnly); + +#pragma warning disable CS0618 // Type or member is obsolete + Assert.False(key.HasPrivateKey); +#pragma warning restore CS0618 + Assert.Equal(PrivateKeyStatus.DoesNotExist, key.PrivateKeyStatus); + } + + [MlDsaFact] + public void CanComputeJwkThumbprint_ReturnsTrue() + { + Assert.True(MlDsaKeyingMaterial.MlDsa44Key.CanComputeJwkThumbprint()); + Assert.True(MlDsaKeyingMaterial.MlDsa65Key.CanComputeJwkThumbprint()); + Assert.True(MlDsaKeyingMaterial.MlDsa87Key.CanComputeJwkThumbprint()); + } + + [MlDsaFact] + public void ComputeJwkThumbprint_IsDeterministic() + { + byte[] thumbprint1 = MlDsaKeyingMaterial.MlDsa44Key.ComputeJwkThumbprint(); + byte[] thumbprint2 = MlDsaKeyingMaterial.MlDsa44Key.ComputeJwkThumbprint(); + + Assert.Equal(thumbprint1, thumbprint2); + } + + [MlDsaFact] + public void ComputeJwkThumbprint_PublicAndPrivateKeysMatch() + { + // A key's thumbprint should be the same whether computed from the private or public key + // since thumbprint only uses public key material. + byte[] privateThumbprint = MlDsaKeyingMaterial.MlDsa44Key.ComputeJwkThumbprint(); + byte[] publicThumbprint = MlDsaKeyingMaterial.MlDsa44Key_Public.ComputeJwkThumbprint(); + + Assert.Equal(privateThumbprint, publicThumbprint); + } + + [MlDsaFact] + public void ComputeJwkThumbprint_DifferentKeysProduceDifferentThumbprints() + { + byte[] thumbprint44 = MlDsaKeyingMaterial.MlDsa44Key.ComputeJwkThumbprint(); + byte[] thumbprint65 = MlDsaKeyingMaterial.MlDsa65Key.ComputeJwkThumbprint(); + byte[] thumbprint87 = MlDsaKeyingMaterial.MlDsa87Key.ComputeJwkThumbprint(); + + Assert.NotEqual(thumbprint44, thumbprint65); + Assert.NotEqual(thumbprint44, thumbprint87); + Assert.NotEqual(thumbprint65, thumbprint87); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void ConstructFromJsonWebKey_RoundTrips(string algorithm) + { + // Create a key, convert to JWK, then create a new key from the JWK + var mlDsaAlg = MlDsaAdapter.GetMLDsaAlgorithm(algorithm); + using var originalMlDsa = MLDsa.GenerateKey(mlDsaAlg); + var originalKey = new MlDsaSecurityKey(originalMlDsa); + + var jwk = JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(originalKey); + Assert.True(JsonWebKeyConverter.TryConvertToSecurityKey(jwk, out SecurityKey roundTrippedKey)); + + var mlDsaKey = Assert.IsType(roundTrippedKey); + Assert.Equal(originalKey.KeySize, mlDsaKey.KeySize); + Assert.Equal(PrivateKeyStatus.Exists, mlDsaKey.PrivateKeyStatus); + + // Verify the public keys match + byte[] originalPub = originalMlDsa.ExportMLDsaPublicKey(); + byte[] roundTrippedPub = mlDsaKey.MLDsa.ExportMLDsaPublicKey(); + Assert.Equal(originalPub, roundTrippedPub); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void X509SecurityKey_MlDsa_KeySize(string algorithm) + { + var (x509Key, expectedAlg) = GetX509MlDsaKey(algorithm); + Assert.Equal(expectedAlg.PublicKeySizeInBytes * 8, x509Key.KeySize); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void X509SecurityKey_MlDsa_CanComputeJwkThumbprint(string algorithm) + { + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + Assert.True(x509Key.CanComputeJwkThumbprint()); + byte[] thumbprint = x509Key.ComputeJwkThumbprint(); + Assert.NotNull(thumbprint); + Assert.True(thumbprint.Length > 0); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void X509SecurityKey_MlDsa_JwkThumbprint_IsDeterministic(string algorithm) + { + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + byte[] thumbprint1 = x509Key.ComputeJwkThumbprint(); + byte[] thumbprint2 = x509Key.ComputeJwkThumbprint(); + Assert.Equal(thumbprint1, thumbprint2); + } + + private static (X509SecurityKey key, MLDsaAlgorithm alg) GetX509MlDsaKey(string algorithm) + { + return algorithm switch + { + "ML-DSA-44" => (MlDsaKeyingMaterial.X509MlDsa44Key, MLDsaAlgorithm.MLDsa44), + "ML-DSA-65" => (MlDsaKeyingMaterial.X509MlDsa65Key, MLDsaAlgorithm.MLDsa65), + "ML-DSA-87" => (MlDsaKeyingMaterial.X509MlDsa87Key, MLDsaAlgorithm.MLDsa87), + _ => throw new ArgumentException(algorithm) + }; + } + + #region X509-to-JWK Conversion Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void ConvertFromX509SecurityKey_X5cMode(string algorithm) + { + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + var jwk = JsonWebKeyConverter.ConvertFromX509SecurityKey(x509Key); + + Assert.Equal(JsonWebAlgorithmsKeyTypes.Akp, jwk.Kty); + Assert.Equal(algorithm, jwk.Alg); + Assert.Equal(x509Key.KeyId, jwk.Kid); + Assert.NotNull(jwk.X5t); + Assert.Single(jwk.X5c); + // x5c mode should not include key material + Assert.Null(jwk.Pub); + Assert.Null(jwk.Priv); + + // Verify the x5c JWK can be converted back to an X509SecurityKey + // (requires ML-DSA public key extraction from a cert loaded via x5c) + if (CanExtractMlDsaPublicKeyFromX509PublicOnlyCert()) + { + Assert.True(JsonWebKeyConverter.TryConvertToSecurityKey(jwk, out var roundTripped)); + Assert.IsType(roundTripped); + } + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void ConvertFromX509SecurityKey_ExtractKeyMaterial(string algorithm) + { + if (!MlDsaKeyingMaterial.CanExtractMlDsaPrivateKeyFromX509()) + return; // skip on platforms that can't extract ML-DSA private keys + + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + var jwk = JsonWebKeyConverter.ConvertFromX509SecurityKey(x509Key, representAsRsaKey: true); + + Assert.Equal(JsonWebAlgorithmsKeyTypes.Akp, jwk.Kty); + Assert.Equal(algorithm, jwk.Alg); + Assert.NotNull(jwk.Pub); + Assert.NotNull(jwk.Priv); + Assert.True(jwk.HasPrivateKey); + + // Verify round-trip: the extracted JWK should produce a working key + Assert.True(JsonWebKeyConverter.TryConvertToSecurityKey(jwk, out SecurityKey roundTrippedKey)); + var mlDsaKey = Assert.IsType(roundTrippedKey); + Assert.Equal(x509Key.KeySize, mlDsaKey.KeySize); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void ConvertFromX509SecurityKey_ExtractKeyMaterial_PublicOnly(string algorithm) + { + if (!CanExtractMlDsaPublicKeyFromX509PublicOnlyCert()) + return; // GetMLDsaPublicKey() on public-only certs is not supported on all platforms + + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + // Create a public-only X509SecurityKey by loading only the certificate (no private key) +#if NET9_0_OR_GREATER + var publicOnlyCert = X509CertificateLoader.LoadCertificate(x509Key.Certificate.RawData); +#else +#pragma warning disable SYSLIB0057 + var publicOnlyCert = new X509Certificate2(x509Key.Certificate.RawData); +#pragma warning restore SYSLIB0057 +#endif + var publicOnlyKey = new X509SecurityKey(publicOnlyCert); + + var jwk = JsonWebKeyConverter.ConvertFromX509SecurityKey(publicOnlyKey, representAsRsaKey: true); + + Assert.Equal(JsonWebAlgorithmsKeyTypes.Akp, jwk.Kty); + Assert.Equal(algorithm, jwk.Alg); + Assert.NotNull(jwk.Pub); + Assert.Null(jwk.Priv); + Assert.False(jwk.HasPrivateKey); + } + + #endregion + + #region JWK Negative Tests + + [MlDsaFact] + public void JwkMissingAlg_FailsConversion() + { + var jwk = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Pub = Base64UrlEncoder.Encode(new byte[1312]) // dummy public key + }; + + Assert.False(JsonWebKeyConverter.TryConvertToSecurityKey(jwk, out _)); + } + + [MlDsaFact] + public void JwkMissingPub_ThrowsOnConstruction() + { + var jwk = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = SecurityAlgorithms.MlDsa44 + }; + + Assert.Throws(() => new MlDsaSecurityKey(jwk, false)); + } + + [MlDsaFact] + public void JwkInvalidAlg_FailsConversion() + { + var jwk = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = "UNSUPPORTED-ALG", + Pub = Base64UrlEncoder.Encode(new byte[1312]) + }; + + Assert.False(JsonWebKeyConverter.TryConvertToSecurityKey(jwk, out _)); + } + + #endregion + + #region Pub/Priv Mismatch Tests + + [MlDsaFact] + public void JwkWithMismatchedPubPriv_FailsConversion() + { + // Create two different ML-DSA-44 keys + using var keyA = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + using var keyB = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + + // Build a JWK with priv from keyA but pub from keyB + var jwk = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = SecurityAlgorithms.MlDsa44, + Pub = Base64UrlEncoder.Encode(keyB.ExportMLDsaPublicKey()), + Priv = Base64UrlEncoder.Encode(keyA.ExportMLDsaPrivateSeed()) + }; + + // TryConvert should fail because pub does not match the key derived from priv + Assert.False(JsonWebKeyConverter.TryConvertToSecurityKey(jwk, out _)); + } + + #endregion + + #region Algorithm Mismatch Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44", "ML-DSA-65")] + [InlineData("ML-DSA-44", "ML-DSA-87")] + [InlineData("ML-DSA-65", "ML-DSA-44")] + [InlineData("ML-DSA-65", "ML-DSA-87")] + [InlineData("ML-DSA-87", "ML-DSA-44")] + [InlineData("ML-DSA-87", "ML-DSA-65")] + public void IsSupportedAlgorithm_RejectsKeyAlgorithmMismatch(string keyAlgorithm, string requestedAlgorithm) + { + var key = GetMlDsaKey(keyAlgorithm); + Assert.False( + SupportedAlgorithms.IsSupportedAlgorithm(requestedAlgorithm, key), + $"Expected {requestedAlgorithm} to be rejected for {keyAlgorithm} key"); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void IsSupportedAlgorithm_AcceptsMatchingKeyAlgorithm(string algorithm) + { + var key = GetMlDsaKey(algorithm); + Assert.True( + SupportedAlgorithms.IsSupportedAlgorithm(algorithm, key), + $"Expected {algorithm} to be accepted for matching key"); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44", "ML-DSA-65")] + [InlineData("ML-DSA-65", "ML-DSA-87")] + [InlineData("ML-DSA-87", "ML-DSA-44")] + public void IsSupportedAlgorithm_RejectsJwkAlgorithmMismatch(string jwkAlgorithm, string requestedAlgorithm) + { + var jwk = new JsonWebKey + { + Kty = JsonWebAlgorithmsKeyTypes.Akp, + Alg = jwkAlgorithm, + Pub = Base64UrlEncoder.Encode(new byte[32]) // dummy + }; + Assert.False( + SupportedAlgorithms.IsSupportedAlgorithm(requestedAlgorithm, jwk), + $"Expected {requestedAlgorithm} to be rejected for JWK with alg={jwkAlgorithm}"); + } + + #endregion + + #region Public-Key-Only Signing Tests + + [MlDsaFact] + public void SignWithPublicKeyOnly_Throws() + { + // Creating a signing provider with a public-only key should fail at construction + using var privateKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + byte[] publicKeyBytes = privateKey.ExportMLDsaPublicKey(); + using var publicOnly = MLDsa.ImportMLDsaPublicKey(MLDsaAlgorithm.MLDsa44, publicKeyBytes); + var publicOnlyKey = new MlDsaSecurityKey(publicOnly); + + Assert.Throws(() => + new AsymmetricSignatureProvider(publicOnlyKey, SecurityAlgorithms.MlDsa44, true)); + } + + [MlDsaFact] + public void VerifyWithPublicKeyOnly_Succeeds() + { + // Verifying should work with a public-only key + byte[] data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var signingProvider = new AsymmetricSignatureProvider(MlDsaKeyingMaterial.MlDsa44Key, SecurityAlgorithms.MlDsa44, true); + byte[] signature = signingProvider.Sign(data); + + var verifyProvider = new AsymmetricSignatureProvider(MlDsaKeyingMaterial.MlDsa44Key_Public, SecurityAlgorithms.MlDsa44, false); + Assert.True(verifyProvider.Verify(data, signature)); + } + + #endregion + + #region Signature Correctness Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void TamperedSignature_FailsVerification(string algorithm) + { + byte[] data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var key = GetMlDsaKey(algorithm); + var signingProvider = new AsymmetricSignatureProvider(key, algorithm, true); + byte[] signature = signingProvider.Sign(data); + + // Tamper with one byte of the signature + signature[0] ^= 0xFF; + + var verifyProvider = new AsymmetricSignatureProvider(GetMlDsaPublicKey(algorithm), algorithm, false); + Assert.False(verifyProvider.Verify(data, signature)); + } + + [MlDsaFact] + public void CrossKeyVerification_Fails() + { + // Signature from MlDsa44 key should not verify with a different MlDsa44 key + byte[] data = new byte[] { 10, 20, 30, 40 }; + var signingProvider = new AsymmetricSignatureProvider(MlDsaKeyingMaterial.MlDsa44Key, SecurityAlgorithms.MlDsa44, true); + byte[] signature = signingProvider.Sign(data); + + // Create a completely different key pair + using var differentMlDsa = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + var differentKey = new MlDsaSecurityKey(differentMlDsa); + var verifyProvider = new AsymmetricSignatureProvider(differentKey, SecurityAlgorithms.MlDsa44, false); + Assert.False(verifyProvider.Verify(data, signature)); + } + + #endregion + + #region Sign/Verify Round-Trip Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void SignVerify_RoundTrip(string algorithm) + { + byte[] data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var signingKey = GetMlDsaKey(algorithm); + var verifyKey = GetMlDsaPublicKey(algorithm); + + var signingProvider = new AsymmetricSignatureProvider(signingKey, algorithm, true); + byte[] signature = signingProvider.Sign(data); + + Assert.NotNull(signature); + Assert.True(signature.Length > 0); + + var verifyProvider = new AsymmetricSignatureProvider(verifyKey, algorithm, false); + Assert.True(verifyProvider.Verify(data, signature)); + } + + #endregion + + #region Thumbprint Consistency Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void JwkThumbprint_MatchesBetweenSecurityKeyAndJsonWebKey(string algorithm) + { + // MlDsaSecurityKey and its JWK representation should produce identical thumbprints. + var key = GetMlDsaKey(algorithm); + var jwk = JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(key); + + byte[] keyThumbprint = key.ComputeJwkThumbprint(); + byte[] jwkThumbprint = jwk.ComputeJwkThumbprint(); + + Assert.Equal(keyThumbprint, jwkThumbprint); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void JwkThumbprint_X509MatchesMlDsaSecurityKey(string algorithm) + { + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + // Extract the public key from the X509 cert and create a standalone MlDsaSecurityKey. +#pragma warning disable SYSLIB5006 + using var mlDsaPub = x509Key.Certificate.GetMLDsaPublicKey(); +#pragma warning restore SYSLIB5006 + var standaloneKey = new MlDsaSecurityKey(mlDsaPub); + + byte[] x509Thumbprint = x509Key.ComputeJwkThumbprint(); + byte[] standaloneThumbprint = standaloneKey.ComputeJwkThumbprint(); + + Assert.Equal(x509Thumbprint, standaloneThumbprint); + } + + #endregion + + #region X509 Algorithm Enforcement Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44", "ML-DSA-65")] + [InlineData("ML-DSA-44", "ML-DSA-87")] + [InlineData("ML-DSA-65", "ML-DSA-44")] + public void IsSupportedAlgorithm_RejectsX509KeyAlgorithmMismatch(string keyAlgorithm, string requestedAlgorithm) + { + var (x509Key, _) = GetX509MlDsaKey(keyAlgorithm); + Assert.False( + SupportedAlgorithms.IsSupportedAlgorithm(requestedAlgorithm, x509Key), + $"Expected {requestedAlgorithm} to be rejected for X509 {keyAlgorithm} key"); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void IsSupportedAlgorithm_AcceptsMatchingX509KeyAlgorithm(string algorithm) + { + var (x509Key, _) = GetX509MlDsaKey(algorithm); + Assert.True( + SupportedAlgorithms.IsSupportedAlgorithm(algorithm, x509Key), + $"Expected {algorithm} to be accepted for matching X509 key"); + } + + #endregion + + #region JWK JSON Serialization Round-Trip + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void JwkJsonSerialization_RoundTrips(string algorithm) + { + var mlDsaAlg = MlDsaAdapter.GetMLDsaAlgorithm(algorithm); + using var mlDsa = MLDsa.GenerateKey(mlDsaAlg); + var key = new MlDsaSecurityKey(mlDsa); + + // Convert to JWK + var originalJwk = JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(key); + + // Serialize to JSON using the custom serializer + string json = JsonWebKeySerializer.Write(originalJwk); + + // Deserialize back + var parsedJwk = new JsonWebKey(json); + + // Verify all key properties survived + Assert.Equal(JsonWebAlgorithmsKeyTypes.Akp, parsedJwk.Kty); + Assert.Equal(algorithm, parsedJwk.Alg); + Assert.Equal(originalJwk.Pub, parsedJwk.Pub); + Assert.Equal(originalJwk.Priv, parsedJwk.Priv); + Assert.True(parsedJwk.HasPrivateKey); + + // Verify the parsed JWK can create a working key + Assert.True(JsonWebKeyConverter.TryConvertToSecurityKey(parsedJwk, out SecurityKey roundTrippedKey)); + var mlDsaKey = Assert.IsType(roundTrippedKey); + Assert.Equal(key.KeySize, mlDsaKey.KeySize); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void JwkJsonSerialization_PublicKeyOnly_RoundTrips(string algorithm) + { + var mlDsaAlg = MlDsaAdapter.GetMLDsaAlgorithm(algorithm); + using var mlDsa = MLDsa.GenerateKey(mlDsaAlg); + byte[] publicKeyBytes = mlDsa.ExportMLDsaPublicKey(); + using var publicOnly = MLDsa.ImportMLDsaPublicKey(mlDsaAlg, publicKeyBytes); + var key = new MlDsaSecurityKey(publicOnly); + + var originalJwk = JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(key); + string json = JsonWebKeySerializer.Write(originalJwk); + var parsedJwk = new JsonWebKey(json); + + Assert.Equal(JsonWebAlgorithmsKeyTypes.Akp, parsedJwk.Kty); + Assert.Equal(algorithm, parsedJwk.Alg); + Assert.Equal(originalJwk.Pub, parsedJwk.Pub); + Assert.Null(parsedJwk.Priv); + Assert.False(parsedJwk.HasPrivateKey); + } + + #endregion + + #region End-to-End JWT Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async System.Threading.Tasks.Task JwtCreateAndValidate_EndToEnd(string algorithm) + { + var signingKey = GetMlDsaKey(algorithm); + var verifyKey = GetMlDsaPublicKey(algorithm); + + var handler = new JsonWebTokenHandler(); + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://test-issuer.example.com", + Audience = "https://test-audience.example.com", + SigningCredentials = new SigningCredentials(signingKey, algorithm), + Claims = new System.Collections.Generic.Dictionary + { + { "sub", "test-user" }, + { "name", "Test User" } + } + }; + + string token = handler.CreateToken(descriptor); + Assert.False(string.IsNullOrEmpty(token)); + + // Validate + var validationParams = new TokenValidationParameters + { + ValidIssuer = "https://test-issuer.example.com", + ValidAudience = "https://test-audience.example.com", + IssuerSigningKey = verifyKey, + ValidateLifetime = false + }; + + var result = await handler.ValidateTokenAsync(token, validationParams); + Assert.True(result.IsValid, $"Token validation failed: {result.Exception?.Message}"); + Assert.Equal("test-user", result.Claims["sub"]); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async System.Threading.Tasks.Task JwtCreateAndValidate_WithJsonWebKey(string algorithm) + { + var signingJwk = GetMlDsaJsonWebKey(algorithm); + var verifyJwk = GetMlDsaJsonWebKeyPublic(algorithm); + + var handler = new JsonWebTokenHandler(); + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://test-issuer.example.com", + Audience = "https://test-audience.example.com", + SigningCredentials = new SigningCredentials(signingJwk, algorithm), + Claims = new System.Collections.Generic.Dictionary + { + { "sub", "jwk-test-user" } + } + }; + + string token = handler.CreateToken(descriptor); + Assert.False(string.IsNullOrEmpty(token)); + + var validationParams = new TokenValidationParameters + { + ValidIssuer = "https://test-issuer.example.com", + ValidAudience = "https://test-audience.example.com", + IssuerSigningKey = verifyJwk, + ValidateLifetime = false + }; + + var result = await handler.ValidateTokenAsync(token, validationParams); + Assert.True(result.IsValid, $"Token validation failed: {result.Exception?.Message}"); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public void JwtCreateAndValidate_WithJwtSecurityTokenHandler(string algorithm) + { + var signingKey = GetMlDsaKey(algorithm); + var verifyKey = GetMlDsaPublicKey(algorithm); + + var handler = new JwtSecurityTokenHandler(); + handler.InboundClaimTypeMap.Clear(); + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://test-issuer.example.com", + Audience = "https://test-audience.example.com", + SigningCredentials = new SigningCredentials(signingKey, algorithm), + Subject = new CaseSensitiveClaimsIdentity(new[] + { + new Claim("sub", "jwt-handler-user") + }) + }; + + var securityToken = handler.CreateToken(descriptor); + string token = handler.WriteToken(securityToken); + Assert.False(string.IsNullOrEmpty(token)); + + var validationParams = new TokenValidationParameters + { + ValidIssuer = "https://test-issuer.example.com", + ValidAudience = "https://test-audience.example.com", + IssuerSigningKey = verifyKey, + ValidateLifetime = false + }; + + var principal = handler.ValidateToken(token, validationParams, out SecurityToken validatedToken); + Assert.NotNull(principal); + Assert.NotNull(validatedToken); + Assert.Equal("jwt-handler-user", principal.FindFirst("sub")?.Value); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async Task JwtCreateAndValidate_WithExperimentalValidationParameters(string algorithm) + { + var signingKey = GetMlDsaKey(algorithm); + var verifyKey = GetMlDsaPublicKey(algorithm); + + var handler = new JsonWebTokenHandler(); + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://test-issuer.example.com", + Audience = "https://test-audience.example.com", + SigningCredentials = new SigningCredentials(signingKey, algorithm), + Claims = new System.Collections.Generic.Dictionary + { + { "sub", "experimental-user" } + } + }; + + string token = handler.CreateToken(descriptor); + Assert.False(string.IsNullOrEmpty(token)); + + var validationParameters = new ValidationParameters(); + validationParameters.ValidIssuers.Add("https://test-issuer.example.com"); + validationParameters.ValidAudiences.Add("https://test-audience.example.com"); + validationParameters.SigningKeys.Add(verifyKey); + validationParameters.TryAllSigningKeys = true; + validationParameters.LifetimeValidator = SkipValidationDelegates.SkipLifetimeValidation; + validationParameters.TokenTypeValidator = SkipValidationDelegates.SkipTokenTypeValidation; + + var result = await ((IResultBasedValidation)handler).ValidateTokenAsync( + token, validationParameters, new CallContext()); + + Assert.True(result.Succeeded, $"Validation failed: {result.Error?.Message}"); + Assert.NotNull(result.Result); + } + + #endregion + + #region X509 ML-DSA End-to-End JWT Tests + + // On .NET 6, loading an ML-DSA certificate from RawData (without private key) and then + // calling GetMLDsaPublicKey() throws PlatformNotSupportedException, even though the same + // call works on a PFX-loaded certificate. This guards tests that create public-only certs. + private static bool CanExtractMlDsaPublicKeyFromX509PublicOnlyCert() + { + try + { + // Simulate exactly what the test does: load cert from RawData (public-only) + // and attempt to extract ML-DSA public key. + var x509Key = new X509SecurityKey( +#if NET9_0_OR_GREATER + X509CertificateLoader.LoadCertificate(MlDsaKeyingMaterial.MlDsa44Cert.RawData)); +#else +#pragma warning disable SYSLIB0057 + new X509Certificate2(MlDsaKeyingMaterial.MlDsa44Cert.RawData)); +#pragma warning restore SYSLIB0057 +#endif + return x509Key.MlDsaPublicKey != null; + } + catch (Exception) + { + return false; + } + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async System.Threading.Tasks.Task JwtCreateAndValidate_WithX509SecurityKey(string algorithm) + { + if (!MlDsaKeyingMaterial.CanExtractMlDsaPrivateKeyFromX509()) + return; // skip on platforms that can't extract ML-DSA private keys from X509 + + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + var handler = new JsonWebTokenHandler(); + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://test-issuer.example.com", + Audience = "https://test-audience.example.com", + SigningCredentials = new SigningCredentials(x509Key, algorithm), + Claims = new System.Collections.Generic.Dictionary + { + { "sub", "x509-test-user" } + } + }; + + string token = handler.CreateToken(descriptor); + Assert.False(string.IsNullOrEmpty(token)); + + // Validate using the same X509 key (public key only path) + var validationParams = new TokenValidationParameters + { + ValidIssuer = "https://test-issuer.example.com", + ValidAudience = "https://test-audience.example.com", + IssuerSigningKey = x509Key, + ValidateLifetime = false + }; + + var result = await handler.ValidateTokenAsync(token, validationParams); + Assert.True(result.IsValid, $"Token validation failed: {result.Exception?.Message}"); + Assert.Equal("x509-test-user", result.Claims["sub"]); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async System.Threading.Tasks.Task JwtCreateWithX509_ValidateWithMlDsaKey(string algorithm) + { + if (!MlDsaKeyingMaterial.CanExtractMlDsaPrivateKeyFromX509()) + return; // skip on platforms that can't extract ML-DSA private keys from X509 + + var (x509Key, _) = GetX509MlDsaKey(algorithm); + + var handler = new JsonWebTokenHandler(); + var descriptor = new SecurityTokenDescriptor + { + Issuer = "https://test-issuer.example.com", + Audience = "https://test-audience.example.com", + SigningCredentials = new SigningCredentials(x509Key, algorithm), + Claims = new System.Collections.Generic.Dictionary + { + { "sub", "cross-key-user" } + } + }; + + string token = handler.CreateToken(descriptor); + + // Validate using an MlDsaSecurityKey created from the X509 certificate's public key +#pragma warning disable SYSLIB5006 + using var mlDsaPub = x509Key.Certificate.GetMLDsaPublicKey(); +#pragma warning restore SYSLIB5006 + var mlDsaKey = new MlDsaSecurityKey(mlDsaPub); + + var validationParams = new TokenValidationParameters + { + ValidIssuer = "https://test-issuer.example.com", + ValidAudience = "https://test-audience.example.com", + IssuerSigningKey = mlDsaKey, + ValidateLifetime = false + }; + + var result = await handler.ValidateTokenAsync(token, validationParams); + Assert.True(result.IsValid, $"Token validation failed: {result.Exception?.Message}"); + Assert.Equal("cross-key-user", result.Claims["sub"]); + } + + #endregion + + #region Helpers + + private static MlDsaSecurityKey GetMlDsaKey(string algorithm) => algorithm switch + { + "ML-DSA-44" => MlDsaKeyingMaterial.MlDsa44Key, + "ML-DSA-65" => MlDsaKeyingMaterial.MlDsa65Key, + "ML-DSA-87" => MlDsaKeyingMaterial.MlDsa87Key, + _ => throw new ArgumentException(algorithm) + }; + + private static MlDsaSecurityKey GetMlDsaPublicKey(string algorithm) => algorithm switch + { + "ML-DSA-44" => MlDsaKeyingMaterial.MlDsa44Key_Public, + "ML-DSA-65" => MlDsaKeyingMaterial.MlDsa65Key_Public, + "ML-DSA-87" => MlDsaKeyingMaterial.MlDsa87Key_Public, + _ => throw new ArgumentException(algorithm) + }; + + private static JsonWebKey GetMlDsaJsonWebKey(string algorithm) => algorithm switch + { + "ML-DSA-44" => MlDsaKeyingMaterial.JsonWebKeyMlDsa44, + "ML-DSA-65" => MlDsaKeyingMaterial.JsonWebKeyMlDsa65, + "ML-DSA-87" => MlDsaKeyingMaterial.JsonWebKeyMlDsa87, + _ => throw new ArgumentException(algorithm) + }; + + private static JsonWebKey GetMlDsaJsonWebKeyPublic(string algorithm) => algorithm switch + { + "ML-DSA-44" => MlDsaKeyingMaterial.JsonWebKeyMlDsa44_Public, + "ML-DSA-65" => MlDsaKeyingMaterial.JsonWebKeyMlDsa65_Public, + "ML-DSA-87" => MlDsaKeyingMaterial.JsonWebKeyMlDsa87_Public, + _ => throw new ArgumentException(algorithm) + }; + + #endregion + } +} + +#pragma warning restore CS3016 // Arrays as attribute arguments is not CLS-compliant From fc2be48053538f17aa0e15c378dd6b6aaca32ec3 Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Fri, 8 May 2026 15:06:53 +0100 Subject: [PATCH 02/13] Refactor X509 adapter routing, fix telemetry, improve PFX loading - Refactor InitializeUsingX509SecurityKey into explicit routing: InitializeUsingX509MlDsa, InitializeUsingX509Rsa, with clear unsupported key type fallback (IDX10725). Extensible pattern for future PQC algorithms (Composite ML-DSA, SLH-DSA). - Fix X509 ML-DSA telemetry: extract GetX509KeyAlgorithmId helper to correctly identify ML-DSA certs instead of reporting UNKNOWN. - Use EphemeralKeySet for PFX loading on .NET 6+ to avoid persisting keys to disk; fall back to Exportable on .NET Framework. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AsymmetricAdapter.cs | 58 ++++++++++++------- .../Telemetry/CryptoTelemetry.cs | 44 +++++++++----- .../MlDsaKeyingMaterial.cs | 6 +- 3 files changed, 73 insertions(+), 35 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs index 58f74f537a..46a8f71608 100644 --- a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs @@ -288,25 +288,17 @@ private void InitializeUsingX509SecurityKey( { if (x509SecurityKey.MlDsaPublicKey != null) { - // ML-DSA certificate — borrow the MLDsa instance from X509SecurityKey. - // The X509SecurityKey retains ownership; _disposeCryptoOperators remains - // false so the adapter will not dispose it. Same pattern as RSA/ECDsa. - MLDsa mlDsa = requirePrivateKey ? x509SecurityKey.MlDsaPrivateKey : x509SecurityKey.MlDsaPublicKey; - if (mlDsa == null) - throw LogHelper.LogExceptionMessage( - new InvalidOperationException( - LogHelper.FormatInvariant( - LogMessages.IDX10723, - LogHelper.MarkAsNonPII(algorithm), - LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); - - InitializeUsingMlDsa(mlDsa); + InitializeUsingX509MlDsa(x509SecurityKey, algorithm, requirePrivateKey); } - else if (x509SecurityKey.PublicKey == null) + else if (x509SecurityKey.PublicKey is RSA) { - // Certificate contains neither a supported classical key (RSA/ECDSA) nor - // an extractable ML-DSA key. This occurs when the certificate uses a key - // type that the platform cannot extract (e.g., ML-DSA on older OS versions). + InitializeUsingX509Rsa(x509SecurityKey, algorithm, requirePrivateKey); + } + else + { + // Certificate key type is not supported (not RSA, ECDSA, or ML-DSA). + // ECDSA X509 certs are routed through InitializeUsingEcdsaSecurityKey + // by the constructor and do not reach here. throw LogHelper.LogExceptionMessage( new NotSupportedException( LogHelper.FormatInvariant( @@ -314,14 +306,40 @@ private void InitializeUsingX509SecurityKey( LogHelper.MarkAsNonPII(algorithm), LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); } - else if (requirePrivateKey) + } + + private void InitializeUsingX509MlDsa( + X509SecurityKey x509SecurityKey, + string algorithm, + bool requirePrivateKey) + { + // Borrow the MLDsa instance from X509SecurityKey. + // The X509SecurityKey retains ownership; _disposeCryptoOperators remains + // false so the adapter will not dispose it. Same pattern as RSA/ECDsa. + MLDsa mlDsa = requirePrivateKey ? x509SecurityKey.MlDsaPrivateKey : x509SecurityKey.MlDsaPublicKey; + if (mlDsa == null) + throw LogHelper.LogExceptionMessage( + new InvalidOperationException( + LogHelper.FormatInvariant( + LogMessages.IDX10723, + LogHelper.MarkAsNonPII(algorithm), + LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); + + InitializeUsingMlDsa(mlDsa); + } + + private void InitializeUsingX509Rsa( + X509SecurityKey x509SecurityKey, + string algorithm, + bool requirePrivateKey) + { + if (requirePrivateKey) { if (x509SecurityKey.PrivateKey == null) throw LogHelper.LogExceptionMessage( new InvalidOperationException( LogHelper.FormatInvariant( - LogMessages.IDX10723, - LogHelper.MarkAsNonPII(algorithm), + LogMessages.IDX10638, LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); InitializeUsingRsa(x509SecurityKey.PrivateKey as RSA, algorithm); diff --git a/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs b/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs index 0378bd52ba..208f7e9cee 100644 --- a/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs +++ b/src/Microsoft.IdentityModel.Tokens/Telemetry/CryptoTelemetry.cs @@ -118,20 +118,7 @@ internal static string GetKeyAlgorithmId(SecurityKey key) _ => KeyAlgorithmIds.SymmetricUnknown }, - X509SecurityKey x509 => x509.KeySize switch - { - 2048 => KeyAlgorithmIds.Rsa2048, - 3072 => KeyAlgorithmIds.Rsa3072, - 4096 => KeyAlgorithmIds.Rsa4096, - 256 => KeyAlgorithmIds.EcdsaP256, - 384 => KeyAlgorithmIds.EcdsaP384, - 521 => KeyAlgorithmIds.EcdsaP521, - _ => x509.PublicKey is RSA - ? KeyAlgorithmIds.RsaUnknown - : x509.PublicKey is ECDsa - ? KeyAlgorithmIds.EcdsaUnknown - : KeyAlgorithmIds.Unknown - }, + X509SecurityKey x509 => GetX509KeyAlgorithmId(x509), JsonWebKey jwk => GetJsonWebKeyAlgorithmId(jwk), @@ -148,6 +135,35 @@ internal static string GetKeyAlgorithmId(SecurityKey key) }; } + private static string GetX509KeyAlgorithmId(X509SecurityKey x509) + { + if (x509.MlDsaPublicKey != null) + { + return x509.MlDsaPublicKey.Algorithm.Name switch + { + "ML-DSA-44" => KeyAlgorithmIds.MlDsa44, + "ML-DSA-65" => KeyAlgorithmIds.MlDsa65, + "ML-DSA-87" => KeyAlgorithmIds.MlDsa87, + _ => KeyAlgorithmIds.MlDsaUnknown + }; + } + + return x509.KeySize switch + { + 2048 => KeyAlgorithmIds.Rsa2048, + 3072 => KeyAlgorithmIds.Rsa3072, + 4096 => KeyAlgorithmIds.Rsa4096, + 256 => KeyAlgorithmIds.EcdsaP256, + 384 => KeyAlgorithmIds.EcdsaP384, + 521 => KeyAlgorithmIds.EcdsaP521, + _ => x509.PublicKey is RSA + ? KeyAlgorithmIds.RsaUnknown + : x509.PublicKey is ECDsa + ? KeyAlgorithmIds.EcdsaUnknown + : KeyAlgorithmIds.Unknown + }; + } + private static string GetJsonWebKeyAlgorithmId(JsonWebKey jwk) { if (jwk.ConvertedSecurityKey != null) diff --git a/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs b/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs index e82fde6705..bd85a6ac50 100644 --- a/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs +++ b/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs @@ -114,7 +114,11 @@ public static JsonWebKey CreateJsonWebKeyMlDsa(string algorithm, string kid, MlD private static X509Certificate2 LoadMlDsaCert(string pfxBase64) { #if NET9_0_OR_GREATER - return X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(pfxBase64), MlDsaCertPassword, X509KeyStorageFlags.Exportable); + return X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(pfxBase64), MlDsaCertPassword, X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable); +#elif NET6_0_OR_GREATER +#pragma warning disable SYSLIB0057 + return new X509Certificate2(Convert.FromBase64String(pfxBase64), MlDsaCertPassword, X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable); +#pragma warning restore SYSLIB0057 #else #pragma warning disable SYSLIB0057 return new X509Certificate2(Convert.FromBase64String(pfxBase64), MlDsaCertPassword, X509KeyStorageFlags.Exportable); From 71cccecacb4d5195337c93ed5bb2fa26f711126b Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Fri, 8 May 2026 15:29:25 +0100 Subject: [PATCH 03/13] Route ECDSA X509 certs correctly, broaden PrivateKeyStatus handling, fix Task style - Add ECDsa routing in InitializeUsingX509SecurityKey so ECDSA X509 certificates are handled correctly instead of falling through to the unsupported key type error - Broaden exception handling in MlDsaSecurityKey.PrivateKeyStatus and HasPrivateKey to return Unknown/false for non-CryptographicException failures (e.g., platform limitations), matching the documented contract - Use consistent short-form Task in test method signatures - Add CA1031 suppression for MlDsaSecurityKey.HasPrivateKey Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AsymmetricAdapter.cs | 8 +++++--- .../GlobalSuppressions.cs | 1 + src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs | 10 ++++++++++ .../MlDsaSecurityKeyTests.cs | 8 ++++---- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs index 46a8f71608..d418393966 100644 --- a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs @@ -294,11 +294,13 @@ private void InitializeUsingX509SecurityKey( { InitializeUsingX509Rsa(x509SecurityKey, algorithm, requirePrivateKey); } + else if (x509SecurityKey.PublicKey is ECDsa ecDsa) + { + InitializeUsingEcdsaSecurityKey(new ECDsaSecurityKey(ecDsa)); + } else { - // Certificate key type is not supported (not RSA, ECDSA, or ML-DSA). - // ECDSA X509 certs are routed through InitializeUsingEcdsaSecurityKey - // by the constructor and do not reach here. + // Certificate key type is not recognized (not RSA, ECDSA, or ML-DSA). throw LogHelper.LogExceptionMessage( new NotSupportedException( LogHelper.FormatInvariant( diff --git a/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs b/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs index e2974ae654..dd15d709c7 100644 --- a/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs +++ b/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs @@ -31,6 +31,7 @@ [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used for try pattern", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.TryConvertToMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.JsonWebKey,Microsoft.IdentityModel.Tokens.SecurityKey@)~System.Boolean")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used as platform test", Scope = "member", Target = "~P:Microsoft.IdentityModel.Tokens.RsaSecurityKey.PrivateKeyStatus")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used as platform test", Scope = "member", Target = "~P:Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus")] +[assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used as platform test", Scope = "member", Target = "~P:Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Appropriate exception will be caught.", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.InMemoryCryptoProviderCache.TryRemove(Microsoft.IdentityModel.Tokens.SignatureProvider)~System.Boolean")] [assembly: SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Used as validation", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.InternalValidators.ValidateLifetimeAndIssuerAfterSignatureNotValidatedSaml(Microsoft.IdentityModel.Tokens.SecurityToken,System.Nullable{System.DateTime},System.Nullable{System.DateTime},System.String,Microsoft.IdentityModel.Tokens.TokenValidationParameters,System.Text.StringBuilder)")] [assembly: SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "Not using Globalization", Scope = "member", Target = "~M:Microsoft.IdentityModel.Tokens.Interop.Kernel32.GetMessage(System.Int32,System.IntPtr)~System.String")] diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs index b41c770b91..092ef1a547 100644 --- a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs @@ -57,6 +57,11 @@ public override bool HasPrivateKey { _hasPrivateKey = false; } + catch (Exception) + { + // Cannot determine private key status (e.g., platform limitation). + _hasPrivateKey = false; + } } return _hasPrivateKey.Value; @@ -87,6 +92,11 @@ public override PrivateKeyStatus PrivateKeyStatus { _hasPrivateKey = false; } + catch (Exception) + { + // Cannot determine private key status (e.g., platform limitation). + return PrivateKeyStatus.Unknown; + } } return _hasPrivateKey.Value ? PrivateKeyStatus.Exists : PrivateKeyStatus.DoesNotExist; diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs index 50f96ddf08..ccf377ac54 100644 --- a/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs +++ b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs @@ -619,7 +619,7 @@ public void JwkJsonSerialization_PublicKeyOnly_RoundTrips(string algorithm) [InlineData("ML-DSA-44")] [InlineData("ML-DSA-65")] [InlineData("ML-DSA-87")] - public async System.Threading.Tasks.Task JwtCreateAndValidate_EndToEnd(string algorithm) + public async Task JwtCreateAndValidate_EndToEnd(string algorithm) { var signingKey = GetMlDsaKey(algorithm); var verifyKey = GetMlDsaPublicKey(algorithm); @@ -658,7 +658,7 @@ public async System.Threading.Tasks.Task JwtCreateAndValidate_EndToEnd(string al [InlineData("ML-DSA-44")] [InlineData("ML-DSA-65")] [InlineData("ML-DSA-87")] - public async System.Threading.Tasks.Task JwtCreateAndValidate_WithJsonWebKey(string algorithm) + public async Task JwtCreateAndValidate_WithJsonWebKey(string algorithm) { var signingJwk = GetMlDsaJsonWebKey(algorithm); var verifyJwk = GetMlDsaJsonWebKeyPublic(algorithm); @@ -802,7 +802,7 @@ private static bool CanExtractMlDsaPublicKeyFromX509PublicOnlyCert() [InlineData("ML-DSA-44")] [InlineData("ML-DSA-65")] [InlineData("ML-DSA-87")] - public async System.Threading.Tasks.Task JwtCreateAndValidate_WithX509SecurityKey(string algorithm) + public async Task JwtCreateAndValidate_WithX509SecurityKey(string algorithm) { if (!MlDsaKeyingMaterial.CanExtractMlDsaPrivateKeyFromX509()) return; // skip on platforms that can't extract ML-DSA private keys from X509 @@ -842,7 +842,7 @@ public async System.Threading.Tasks.Task JwtCreateAndValidate_WithX509SecurityKe [InlineData("ML-DSA-44")] [InlineData("ML-DSA-65")] [InlineData("ML-DSA-87")] - public async System.Threading.Tasks.Task JwtCreateWithX509_ValidateWithMlDsaKey(string algorithm) + public async Task JwtCreateWithX509_ValidateWithMlDsaKey(string algorithm) { if (!MlDsaKeyingMaterial.CanExtractMlDsaPrivateKeyFromX509()) return; // skip on platforms that can't extract ML-DSA private keys from X509 From 3b1757568a44e9fa4346df213967bed75a01fcbf Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Fri, 8 May 2026 15:45:13 +0100 Subject: [PATCH 04/13] Fix HasPrivateKey/PrivateKeyStatus interaction, align AKP HasPrivateKey check - Don't cache _hasPrivateKey on general Exception in HasPrivateKey so PrivateKeyStatus can independently return Unknown for indeterminate cases - Use !string.IsNullOrEmpty(Priv) for AKP HasPrivateKey in JsonWebKey, consistent with MlDsaAdapter which treats empty priv as missing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs | 2 +- src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs b/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs index 10ed0b5f5e..35192d2f3b 100644 --- a/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/JsonWebKey.cs @@ -374,7 +374,7 @@ public bool HasPrivateKey else if (Kty == JsonWebAlgorithmsKeyTypes.EllipticCurve) return D != null; else if (Kty == JsonWebAlgorithmsKeyTypes.Akp) - return Priv != null; + return !string.IsNullOrEmpty(Priv); else return false; } diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs index 092ef1a547..484faee7b5 100644 --- a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs @@ -60,7 +60,8 @@ public override bool HasPrivateKey catch (Exception) { // Cannot determine private key status (e.g., platform limitation). - _hasPrivateKey = false; + // Do not cache — let PrivateKeyStatus determine independently. + return false; } } From 5bc48ff8ec549651145ce901e3a65e1a8bae9350 Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Fri, 8 May 2026 16:09:23 +0100 Subject: [PATCH 05/13] Clear ConvertedSecurityKey on AKP x5c validation failure When AKP x5c validation rejects a certificate (non-ML-DSA cert or alg mismatch), clear webKey.ConvertedSecurityKey to prevent subsequent TryConvertToSecurityKey calls from returning the cached key and bypassing validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs index 02067e787f..508efdae9e 100644 --- a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs +++ b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs @@ -340,6 +340,7 @@ public static bool TryConvertToSecurityKey(JsonWebKey webKey, out SecurityKey ke || MlDsaSecurityKey.GetAlgorithmName(x509Key.MlDsaPublicKey.Algorithm) != webKey.Alg) { key = null; + webKey.ConvertedSecurityKey = null; return false; } From b4b8d0df600a0c97a1bab48938ba942de0bfd7dc Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Fri, 8 May 2026 16:25:30 +0100 Subject: [PATCH 06/13] Dispose X509Certificate2 instances in tests Add using declarations to public-only X509Certificate2 instances created from RawData to avoid leaking certificate handles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MlDsaSecurityKeyTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs index ccf377ac54..40a9f92a9a 100644 --- a/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs +++ b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs @@ -264,10 +264,10 @@ public void ConvertFromX509SecurityKey_ExtractKeyMaterial_PublicOnly(string algo // Create a public-only X509SecurityKey by loading only the certificate (no private key) #if NET9_0_OR_GREATER - var publicOnlyCert = X509CertificateLoader.LoadCertificate(x509Key.Certificate.RawData); + using var publicOnlyCert = X509CertificateLoader.LoadCertificate(x509Key.Certificate.RawData); #else #pragma warning disable SYSLIB0057 - var publicOnlyCert = new X509Certificate2(x509Key.Certificate.RawData); + using var publicOnlyCert = new X509Certificate2(x509Key.Certificate.RawData); #pragma warning restore SYSLIB0057 #endif var publicOnlyKey = new X509SecurityKey(publicOnlyCert); @@ -782,14 +782,14 @@ private static bool CanExtractMlDsaPublicKeyFromX509PublicOnlyCert() { // Simulate exactly what the test does: load cert from RawData (public-only) // and attempt to extract ML-DSA public key. - var x509Key = new X509SecurityKey( #if NET9_0_OR_GREATER - X509CertificateLoader.LoadCertificate(MlDsaKeyingMaterial.MlDsa44Cert.RawData)); + using var cert = X509CertificateLoader.LoadCertificate(MlDsaKeyingMaterial.MlDsa44Cert.RawData); #else #pragma warning disable SYSLIB0057 - new X509Certificate2(MlDsaKeyingMaterial.MlDsa44Cert.RawData)); + using var cert = new X509Certificate2(MlDsaKeyingMaterial.MlDsa44Cert.RawData); #pragma warning restore SYSLIB0057 #endif + var x509Key = new X509SecurityKey(cert); return x509Key.MlDsaPublicKey != null; } catch (Exception) From 71fe5480603a2216740bf989e549f93b7d4513d5 Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Tue, 19 May 2026 17:23:34 +0100 Subject: [PATCH 07/13] Clean up InternalAPI.Unshipped.txt: keep only ML-DSA entries Remove non-ML-DSA entries that were incorrectly added during the dev8x merge conflict resolution. The file should only contain our new internal API entries, not entries already tracked in InternalAPI.Shipped.txt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../InternalAPI.Unshipped.txt | 115 +----------------- 1 file changed, 2 insertions(+), 113 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt index ea7f200c32..ad9824b5e2 100644 --- a/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt @@ -1,115 +1,4 @@ -const Microsoft.IdentityModel.Tokens.AppContextSwitches.UseCapitalizedXMLTypeAttrSwitch = "Switch.Microsoft.IdentityModel.UseCapitalizedXMLTypeAttr" -> string -const Microsoft.IdentityModel.Tokens.LogMessages.IDX10278 = "IDX10278: Unable to retrieve configuration from authority: '{0}'. \nProceeding with token decryption in case the relevant properties have been set manually on the TokenValidationParameters. Exception caught: \n {1}. See https://aka.ms/validate-using-configuration-manager for additional information." -> string -static Microsoft.IdentityModel.Tokens.AppContextSwitches.UseCapitalizedXMLTypeAttr.get -> bool -const Microsoft.IdentityModel.Telemetry.TelemetryConstants.BlockingTypeTag = "Blocking" -> string -const Microsoft.IdentityModel.Telemetry.TelemetryDataRecorder.BackgroundConfigurationRefreshFailureCounterDescription = "Counter capturing configuration manager background refresh failures." -> string -const Microsoft.IdentityModel.Telemetry.TelemetryDataRecorder.BackgroundConfigurationRefreshFailureCounterName = "IdentityModelConfigurationManagerBackgroundRefreshFailure" -> string -const Microsoft.IdentityModel.Tokens.AppContextSwitches.UpdateConfigAsBlockingSwitch = "Switch.Microsoft.IdentityModel.UpdateConfigAsBlocking" -> string -const Microsoft.IdentityModel.Tokens.LogMessages.IDX10519 = "IDX10519: Signature validation failed. The token's kid is missing and ValidationParameters.TryAllIssuerSigningKeys is set to false." -> string -const Microsoft.IdentityModel.Tokens.LogMessages.IDX10520 = "IDX10520: Signature validation failed. The key provided could not validate the signature. Key tried: '{0}'." -> string -const Microsoft.IdentityModel.Tokens.LogMessages.IDX10521 = "IDX10521: Signature validation failed. An exception was thrown when trying to validate the signature. Key tried: '{0}'. Exception: '{1}'." -> string -const Microsoft.IdentityModel.Tokens.LogMessages.IDX10620 = "IDX10620: Unable to obtain a CryptoProviderFactory, both EncryptingCredentials.CryptoProviderFactory and EncryptingCredentials.Key.CryptoProviderFactory are null." -> string -Microsoft.IdentityModel.Telemetry.ITelemetryClient.IncrementConfigurationRefreshRequestCounter(string metadataAddress, string operationStatus, string configurationSource) -> void -Microsoft.IdentityModel.Telemetry.ITelemetryClient.IncrementConfigurationRefreshRequestCounter(string metadataAddress, string operationStatus, string configurationSource, System.Exception exception) -> void -Microsoft.IdentityModel.Telemetry.ITelemetryClient.LogBackgroundConfigurationRefreshFailure(string metadataAddress, string configurationSource, System.Exception exception) -> void -Microsoft.IdentityModel.Telemetry.ITelemetryClient.LogConfigurationRetrievalDuration(string metadataAddress, string configurationSource, System.TimeSpan operationDuration) -> void -Microsoft.IdentityModel.Telemetry.ITelemetryClient.LogConfigurationRetrievalDuration(string metadataAddress, string configurationSource, System.TimeSpan operationDuration, System.Exception exception) -> void -Microsoft.IdentityModel.Telemetry.TelemetryClient.IncrementConfigurationRefreshRequestCounter(string metadataAddress, string operationStatus, string configurationSource) -> void -Microsoft.IdentityModel.Telemetry.TelemetryClient.IncrementConfigurationRefreshRequestCounter(string metadataAddress, string operationStatus, string configurationSource, System.Exception exception) -> void -Microsoft.IdentityModel.Telemetry.TelemetryClient.LogBackgroundConfigurationRefreshFailure(string metadataAddress, string configurationSource, System.Exception exception) -> void -Microsoft.IdentityModel.Telemetry.TelemetryClient.LogConfigurationRetrievalDuration(string metadataAddress, string configurationSource, System.TimeSpan operationDuration) -> void -Microsoft.IdentityModel.Telemetry.TelemetryClient.LogConfigurationRetrievalDuration(string metadataAddress, string configurationSource, System.TimeSpan operationDuration, System.Exception exception) -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedSigningKeyLifetime.ValidatedSigningKeyLifetime(System.DateTime? validFrom, System.DateTime? validTo, System.DateTime? validationTime) -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ActorValidationResult.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ClaimsIdentityNoLocking.get -> System.Security.Claims.ClaimsIdentity -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ClaimsIdentityNoLocking.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ValidatedAudience.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ValidatedIssuer.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ValidatedLifetime.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ValidatedSigningKey.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ValidatedSigningKeyLifetime.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ValidatedTokenReplayExpirationTime.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidatedToken.ValidatedTokenType.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationError.CreateException(System.Type exceptionType, System.Exception innerException) -> System.Exception -Microsoft.IdentityModel.Tokens.Experimental.ValidationError.MessageDetail.get -> Microsoft.IdentityModel.Tokens.Experimental.MessageDetail -Microsoft.IdentityModel.Tokens.Experimental.ValidationFailure.Name.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.IssuerSigningKeys.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.TimeProvider.get -> System.TimeProvider -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.TimeProvider.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.TokenDecryptionKeyResolver.get -> Microsoft.IdentityModel.Tokens.Experimental.DecryptionKeyResolverDelegate -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.TokenDecryptionKeyResolver.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.TokenDecryptionKeys.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.ValidAlgorithms.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.ValidAudiences.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.ValidIssuers.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters.ValidTypes.set -> void -Microsoft.IdentityModel.Tokens.Experimental.ValidationResult.UnwrapError() -> TError -Microsoft.IdentityModel.Tokens.Experimental.ValidationResult.UnwrapResult() -> TResult -Microsoft.IdentityModel.Tokens.LogDetail.LogDetail(Microsoft.IdentityModel.Tokens.Experimental.MessageDetail messageDetail, Microsoft.IdentityModel.Abstractions.EventLogLevel eventLogLevel) -> void -Microsoft.IdentityModel.Tokens.LogDetail.MessageDetail.get -> Microsoft.IdentityModel.Tokens.Experimental.MessageDetail -Microsoft.IdentityModel.Tokens.RsaSecurityKey.InitializeWithRsaParameters(System.Security.Cryptography.RSAParameters rsaParameters) -> void -Microsoft.IdentityModel.Tokens.SecurityTokenArgumentNullException.SetValidationError(Microsoft.IdentityModel.Tokens.Experimental.ValidationError validationError) -> void -Microsoft.IdentityModel.Tokens.SecurityTokenException.SetValidationError(Microsoft.IdentityModel.Tokens.Experimental.ValidationError validationError) -> void -Microsoft.IdentityModel.Tokens.TokenValidationResult.TokenValidationResult(Microsoft.IdentityModel.Tokens.SecurityToken securityToken, Microsoft.IdentityModel.Tokens.TokenHandler tokenHandler, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, string issuer, System.Collections.Generic.List validationResults, Microsoft.IdentityModel.Tokens.Experimental.ValidationError validationError) -> void -Microsoft.IdentityModel.Tokens.TokenValidationResult.TokenValidationResult(Microsoft.IdentityModel.Tokens.SecurityToken securityToken, Microsoft.IdentityModel.Tokens.TokenHandler tokenHandler, Microsoft.IdentityModel.Tokens.TokenValidationParameters tokenValidationParameters, string issuer, System.Collections.Generic.List validationResults) -> void -Microsoft.IdentityModel.Tokens.TokenValidationResult.TokenValidationResult(Microsoft.IdentityModel.Tokens.TokenHandler tokenHandler, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.Experimental.ValidationError validationError) -> void -Microsoft.IdentityModel.Tokens.ValidatedIssuer.Equals(Microsoft.IdentityModel.Tokens.ValidatedIssuer other) -> bool -Microsoft.IdentityModel.Tokens.ValidatedIssuer.ValidatedIssuer(string issuer, Microsoft.IdentityModel.Tokens.IssuerValidationSource validationSource) -> void -Microsoft.IdentityModel.Tokens.ValidatedLifetime.Equals(Microsoft.IdentityModel.Tokens.ValidatedLifetime other) -> bool -Microsoft.IdentityModel.Tokens.ValidatedLifetime.ValidatedLifetime(System.DateTime? notBefore, System.DateTime? expires) -> void -Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime.Equals(Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime other) -> bool -Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime.ValidatedSigningKeyLifetime(System.DateTime? validFrom, System.DateTime? validTo, System.DateTime? validationTime) -> void -Microsoft.IdentityModel.Tokens.ValidatedTokenType.Equals(Microsoft.IdentityModel.Tokens.ValidatedTokenType other) -> bool -Microsoft.IdentityModel.Tokens.ValidatedTokenType.ValidatedTokenType(string type, int validTypeCount) -> void -Microsoft.IdentityModel.Tokens.ValidationError.CreateException(System.Type exceptionType, System.Exception innerException) -> System.Exception -Microsoft.IdentityModel.Tokens.ValidationError.GetException() -> System.Exception -Microsoft.IdentityModel.Tokens.ValidationError.Message.get -> string -Microsoft.IdentityModel.Tokens.ValidationParameters.IssuerSigningKeys.set -> void -Microsoft.IdentityModel.Tokens.ValidationParameters.TryAllDecryptionKeys.get -> bool -Microsoft.IdentityModel.Tokens.ValidationParameters.TryAllDecryptionKeys.set -> void -Microsoft.IdentityModel.Tokens.ValidationParameters.ValidAudiences.set -> void -Microsoft.IdentityModel.Tokens.ValidationParameters.ValidIssuers.set -> void -Microsoft.IdentityModel.Tokens.ValidationParameters.ValidTypes.set -> void -override Microsoft.IdentityModel.Tokens.AlgorithmValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.AudienceValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.IssuerSigningKeyValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.IssuerValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.IssuerValidationSource.ToString() -> string -override Microsoft.IdentityModel.Tokens.LifetimeValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.SignatureValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.TokenReplayValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.TokenTypeValidationError.CreateException() -> System.Exception -override Microsoft.IdentityModel.Tokens.ValidatedIssuer.Equals(object obj) -> bool -override Microsoft.IdentityModel.Tokens.ValidatedIssuer.GetHashCode() -> int -override Microsoft.IdentityModel.Tokens.ValidatedIssuer.ToString() -> string -override Microsoft.IdentityModel.Tokens.ValidatedLifetime.Equals(object obj) -> bool -override Microsoft.IdentityModel.Tokens.ValidatedLifetime.GetHashCode() -> int -override Microsoft.IdentityModel.Tokens.ValidatedLifetime.ToString() -> string -override Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime.Equals(object obj) -> bool -override Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime.GetHashCode() -> int -override Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime.ToString() -> string -override Microsoft.IdentityModel.Tokens.ValidatedTokenType.Equals(object obj) -> bool -override Microsoft.IdentityModel.Tokens.ValidatedTokenType.GetHashCode() -> int -override Microsoft.IdentityModel.Tokens.ValidatedTokenType.ToString() -> string -static Microsoft.IdentityModel.Telemetry.TelemetryDataRecorder.IncrementBackgroundConfigurationRefreshFailureCounter(in System.Diagnostics.TagList tagList) -> void -static Microsoft.IdentityModel.Tokens.AppContextSwitches.UpdateConfigAsBlocking.get -> bool -static Microsoft.IdentityModel.Tokens.ValidatedIssuer.operator !=(Microsoft.IdentityModel.Tokens.ValidatedIssuer left, Microsoft.IdentityModel.Tokens.ValidatedIssuer right) -> bool -static Microsoft.IdentityModel.Tokens.ValidatedIssuer.operator ==(Microsoft.IdentityModel.Tokens.ValidatedIssuer left, Microsoft.IdentityModel.Tokens.ValidatedIssuer right) -> bool -static Microsoft.IdentityModel.Tokens.ValidatedLifetime.operator !=(Microsoft.IdentityModel.Tokens.ValidatedLifetime left, Microsoft.IdentityModel.Tokens.ValidatedLifetime right) -> bool -static Microsoft.IdentityModel.Tokens.ValidatedLifetime.operator ==(Microsoft.IdentityModel.Tokens.ValidatedLifetime left, Microsoft.IdentityModel.Tokens.ValidatedLifetime right) -> bool -static Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime.operator !=(Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime left, Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime right) -> bool -static Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime.operator ==(Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime left, Microsoft.IdentityModel.Tokens.ValidatedSigningKeyLifetime right) -> bool -static Microsoft.IdentityModel.Tokens.ValidatedTokenType.operator !=(Microsoft.IdentityModel.Tokens.ValidatedTokenType left, Microsoft.IdentityModel.Tokens.ValidatedTokenType right) -> bool -static Microsoft.IdentityModel.Tokens.ValidatedTokenType.operator ==(Microsoft.IdentityModel.Tokens.ValidatedTokenType left, Microsoft.IdentityModel.Tokens.ValidatedTokenType right) -> bool -static Microsoft.IdentityModel.Tokens.Validators.ValidateIssuerSigningKeyLifeTime(Microsoft.IdentityModel.Tokens.SecurityKey securityKey, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.CallContext callContext) -> Microsoft.IdentityModel.Tokens.Experimental.ValidationResult -static readonly Microsoft.IdentityModel.Telemetry.TelemetryDataRecorder.BackgroundConfigurationRefreshFailureCounter -> System.Diagnostics.Metrics.Counter -static readonly Microsoft.IdentityModel.Tokens.IssuerValidationSource.IssuerMatchedConfiguration -> Microsoft.IdentityModel.Tokens.IssuerValidationSource -static readonly Microsoft.IdentityModel.Tokens.IssuerValidationSource.IssuerMatchedValidationParameters -> Microsoft.IdentityModel.Tokens.IssuerValidationSource -static readonly Microsoft.IdentityModel.Tokens.IssuerValidationSource.NotValidated -> Microsoft.IdentityModel.Tokens.IssuerValidationSource -virtual Microsoft.IdentityModel.Tokens.TokenHandler.CreateClaimsIdentityInternal(Microsoft.IdentityModel.Tokens.SecurityToken securityToken, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, string issuer) -> System.Security.Claims.ClaimsIdentity -virtual Microsoft.IdentityModel.Tokens.TokenHandler.ValidateTokenAsync(Microsoft.IdentityModel.Tokens.SecurityToken token, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.CallContext callContext, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task> -virtual Microsoft.IdentityModel.Tokens.TokenHandler.ValidateTokenAsync(string token, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.CallContext callContext, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task> -virtual Microsoft.IdentityModel.Tokens.ValidationError.CreateException() -> System.Exception + ~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(Microsoft.IdentityModel.Tokens.JsonWebKey webKey, bool usePrivateKey) -> void static Microsoft.IdentityModel.Tokens.MlDsaAdapter.CreateMlDsa(Microsoft.IdentityModel.Tokens.JsonWebKey jsonWebKey, bool usePrivateKey) -> System.Security.Cryptography.MLDsa static Microsoft.IdentityModel.Tokens.MlDsaAdapter.GetMLDsaAlgorithm(string algorithm) -> System.Security.Cryptography.MLDsaAlgorithm @@ -122,4 +11,4 @@ const Microsoft.IdentityModel.Tokens.LogMessages.IDX10721 = "IDX10721: Unable to const Microsoft.IdentityModel.Tokens.LogMessages.IDX10722 = "IDX10722: The AKP JsonWebKey (alg: '{0}') has inconsistent key material. The 'pub' parameter does not match the public key derived from 'priv'." -> string const Microsoft.IdentityModel.Tokens.LogMessages.IDX10723 = "IDX10723: Unable to extract the private key from the X.509 certificate for algorithm '{0}' (Key: '{1}'). Private key extraction may not be supported on this platform." -> string const Microsoft.IdentityModel.Tokens.LogMessages.IDX10724 = "IDX10724: Unable to compute a JWK thumbprint, public key extraction from the X.509 certificate is not supported on this platform (Key: '{0}')." -> string -const Microsoft.IdentityModel.Tokens.LogMessages.IDX10725 = "IDX10725: Unable to create a SignatureProvider for algorithm '{0}' (Key: '{1}'). The X.509 certificate key could not be extracted. This may indicate the platform does not support the certificate's key type." -> string \ No newline at end of file +const Microsoft.IdentityModel.Tokens.LogMessages.IDX10725 = "IDX10725: Unable to create a SignatureProvider for algorithm '{0}' (Key: '{1}'). The X.509 certificate key could not be extracted. This may indicate the platform does not support the certificate's key type." -> string From 897f707fceef07a03c533e342c0210f313cd5f32 Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Tue, 19 May 2026 17:32:03 +0100 Subject: [PATCH 08/13] Move ML-DSA PublicAPI entries from per-TFM to shared root file The ML-DSA entries are identical across all TFMs. Move them to the root PublicAPI.Unshipped.txt and empty the per-TFM files, matching the repo convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 19 +++++++++++++++++++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 19 ------------------- .../PublicAPI/net462/PublicAPI.Unshipped.txt | 19 ------------------- .../PublicAPI/net472/PublicAPI.Unshipped.txt | 19 ------------------- .../PublicAPI/net6.0/PublicAPI.Unshipped.txt | 19 ------------------- .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 19 ------------------- .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 19 ------------------- .../netstandard2.0/PublicAPI.Unshipped.txt | 19 ------------------- 8 files changed, 19 insertions(+), 133 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt index e69de29bb2..885c1ce45b 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt @@ -0,0 +1,19 @@ +~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string +~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string +~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void +~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int +override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool +~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string +~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void +~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 885c1ce45b..e69de29bb2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,19 +0,0 @@ -~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string -Microsoft.IdentityModel.Tokens.MlDsaSecurityKey -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool -~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void -~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt index 885c1ce45b..e69de29bb2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net462/PublicAPI.Unshipped.txt @@ -1,19 +0,0 @@ -~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string -Microsoft.IdentityModel.Tokens.MlDsaSecurityKey -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool -~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void -~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt index 885c1ce45b..e69de29bb2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,19 +0,0 @@ -~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string -Microsoft.IdentityModel.Tokens.MlDsaSecurityKey -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool -~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void -~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt index 885c1ce45b..e69de29bb2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net6.0/PublicAPI.Unshipped.txt @@ -1,19 +0,0 @@ -~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string -Microsoft.IdentityModel.Tokens.MlDsaSecurityKey -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool -~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void -~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 885c1ce45b..e69de29bb2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,19 +0,0 @@ -~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string -Microsoft.IdentityModel.Tokens.MlDsaSecurityKey -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool -~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void -~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 885c1ce45b..e69de29bb2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,19 +0,0 @@ -~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string -Microsoft.IdentityModel.Tokens.MlDsaSecurityKey -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool -~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void -~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 885c1ce45b..e69de29bb2 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,19 +0,0 @@ -~const Microsoft.IdentityModel.Tokens.JsonWebAlgorithmsKeyTypes.Akp = "AKP" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Priv = "priv" -> string -~const Microsoft.IdentityModel.Tokens.JsonWebKeyParameterNames.Pub = "pub" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa44 = "ML-DSA-44" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa65 = "ML-DSA-65" -> string -~const Microsoft.IdentityModel.Tokens.SecurityAlgorithms.MlDsa87 = "ML-DSA-87" -> string -Microsoft.IdentityModel.Tokens.MlDsaSecurityKey -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MlDsaSecurityKey(System.Security.Cryptography.MLDsa mlDsa) -> void -~Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.MLDsa.get -> System.Security.Cryptography.MLDsa -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.HasPrivateKey.get -> bool -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> Microsoft.IdentityModel.Tokens.PrivateKeyStatus -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int -override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool -~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string -~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.set -> void -~static Microsoft.IdentityModel.Tokens.JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(Microsoft.IdentityModel.Tokens.MlDsaSecurityKey key) -> Microsoft.IdentityModel.Tokens.JsonWebKey \ No newline at end of file From 31e1aac05cb5ad3f9fd6342b80b0e2a170a72ab4 Mon Sep 17 00:00:00 2001 From: Zhenya Polyvanyi Date: Thu, 28 May 2026 13:31:39 +0100 Subject: [PATCH 09/13] Make MlDsaSecurityKey disposable and dispose MlDsaPublicKey on AKP rejection path --- .../JsonWebKeyConverter.cs | 5 +++ .../MlDsaSecurityKey.cs | 33 ++++++++++++++++++- .../PublicAPI.Unshipped.txt | 2 ++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs index 508efdae9e..307d9911a0 100644 --- a/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs +++ b/src/Microsoft.IdentityModel.Tokens/JsonWebKeyConverter.cs @@ -339,6 +339,11 @@ public static bool TryConvertToSecurityKey(JsonWebKey webKey, out SecurityKey ke || x509Key.MlDsaPublicKey == null || MlDsaSecurityKey.GetAlgorithmName(x509Key.MlDsaPublicKey.Algorithm) != webKey.Alg) { + // Dispose the materialized MLDsa handle to avoid leaking + // the native SymCrypt handle to the finalizer queue. + if (key is X509SecurityKey rejectedKey) + rejectedKey.MlDsaPublicKey?.Dispose(); + key = null; webKey.ConvertedSecurityKey = null; return false; diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs index 484faee7b5..c6dd3eefab 100644 --- a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs @@ -10,14 +10,17 @@ namespace Microsoft.IdentityModel.Tokens; /// /// Represents an ML-DSA security key. /// -public class MlDsaSecurityKey : AsymmetricSecurityKey +public class MlDsaSecurityKey : AsymmetricSecurityKey, IDisposable { private bool? _hasPrivateKey; + private bool _disposed; + private readonly bool _ownsMlDsa; internal MlDsaSecurityKey(JsonWebKey webKey, bool usePrivateKey) : base(webKey) { MLDsa = MlDsaAdapter.CreateMlDsa(webKey, usePrivateKey); + _ownsMlDsa = true; webKey.ConvertedSecurityKey = this; } @@ -36,6 +39,34 @@ public MlDsaSecurityKey(MLDsa mlDsa) /// public MLDsa MLDsa { get; private set; } + /// + /// Releases resources held by this instance. + /// Only disposes the if this key owns the instance + /// (i.e., the instance was created internally from a ). + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and, optionally, managed resources. + /// + /// to release both managed and unmanaged resources; + /// to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + _disposed = true; + if (disposing && _ownsMlDsa) + { + MLDsa?.Dispose(); + } + } + } + /// /// Gets a bool indicating if a private key exists. /// diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt index 885c1ce45b..2f29582905 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt @@ -12,6 +12,8 @@ override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.PrivateKeyStatus.get -> override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.KeySize.get -> int override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.CanComputeJwkThumbprint() -> bool ~override Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.ComputeJwkThumbprint() -> byte[] +Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.Dispose() -> void +virtual Microsoft.IdentityModel.Tokens.MlDsaSecurityKey.Dispose(bool disposing) -> void ~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.get -> string ~Microsoft.IdentityModel.Tokens.JsonWebKey.Pub.set -> void ~Microsoft.IdentityModel.Tokens.JsonWebKey.Priv.get -> string From ed497304b40c8c7f7801d032fc4650894dfb0e48 Mon Sep 17 00:00:00 2001 From: Zhenya Polyvanyi Date: Thu, 28 May 2026 17:55:19 +0100 Subject: [PATCH 10/13] Update ComputeJwkThumbprint() to properly dispose ML-DSA key object --- src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs index 6715c0c5d8..0ad4038e7f 100644 --- a/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/X509SecurityKey.cs @@ -334,7 +334,8 @@ public override byte[] ComputeJwkThumbprint() LogMessages.IDX10724, LogHelper.MarkAsNonPII(KeyId)))); - return new MlDsaSecurityKey(MlDsaPublicKey).ComputeJwkThumbprint(); + using var mlDsaKey = new MlDsaSecurityKey(MlDsaPublicKey); + return mlDsaKey.ComputeJwkThumbprint(); } return PublicKey is RSA ? new RsaSecurityKey(PublicKey as RSA).ComputeJwkThumbprint() : new ECDsaSecurityKey(PublicKey as ECDsa).ComputeJwkThumbprint(); From 94c9f8cfa7bb35f84fd363cc275d0ee60cff1d76 Mon Sep 17 00:00:00 2001 From: Zhenya Polyvanyi Date: Thu, 28 May 2026 18:36:31 +0100 Subject: [PATCH 11/13] Clone MLDsa per AsymmetricAdapter for thread safety MLDsa instance methods are not guaranteed thread-safe by the .NET BCL team. Since AsymmetricSignatureProvider pools multiple adapters that previously shared the same MLDsa reference, concurrent Sign/Verify calls could race on a single instance. Fix: clone the MLDsa instance in each pooled adapter via export/import of key material, so each adapter owns an independent native handle. Private seed bytes are zeroed after import. Add concurrency stress tests with CountdownEvent barriers to verify parallel signing and verification on shared providers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AsymmetricAdapter.cs | 22 +- .../MlDsaAdapter.cs | 31 +++ .../MlDsaSecurityKeyTests.cs | 226 ++++++++++++++++++ 3 files changed, 269 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs index d418393966..e14f252139 100644 --- a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs @@ -88,7 +88,7 @@ internal AsymmetricAdapter( else if (securityKey is ECDsaSecurityKey edcsaSecurityKeyFromJsonWebKey) InitializeUsingEcdsaSecurityKey(edcsaSecurityKeyFromJsonWebKey); else if (securityKey is MlDsaSecurityKey mlDsaSecurityKeyFromJsonWebKey) - InitializeUsingMlDsaSecurityKey(mlDsaSecurityKeyFromJsonWebKey); + InitializeUsingMlDsaSecurityKey(mlDsaSecurityKeyFromJsonWebKey, requirePrivateKey); else throw LogHelper.LogExceptionMessage( new NotSupportedException( @@ -103,7 +103,7 @@ internal AsymmetricAdapter( } else if (key is MlDsaSecurityKey mlDsaKey) { - InitializeUsingMlDsaSecurityKey(mlDsaKey); + InitializeUsingMlDsaSecurityKey(mlDsaKey, requirePrivateKey); } else throw LogHelper.LogExceptionMessage( @@ -187,14 +187,19 @@ private void InitializeUsingEcdsaSecurityKey(ECDsaSecurityKey ecdsaSecurityKey) _verifyUsingOffsetFunction = VerifyUsingOffsetECDsa; } - private void InitializeUsingMlDsaSecurityKey(MlDsaSecurityKey mlDsaSecurityKey) + private void InitializeUsingMlDsaSecurityKey(MlDsaSecurityKey mlDsaSecurityKey, bool requirePrivateKey) { - InitializeUsingMlDsa(mlDsaSecurityKey.MLDsa); + InitializeUsingMlDsa(mlDsaSecurityKey.MLDsa, requirePrivateKey); } - private void InitializeUsingMlDsa(MLDsa mlDsa) + private void InitializeUsingMlDsa(MLDsa mlDsa, bool includePrivateKey) { - MLDsa = mlDsa; + // Clone the MLDsa instance so each adapter holds an independent copy. + // MLDsa instance methods are not guaranteed thread-safe, and multiple + // adapters from the object pool may reference the same source key. + // Cloning ensures each adapter can be used concurrently without races. + MLDsa = MlDsaAdapter.CloneMlDsa(mlDsa, includePrivateKey); + _disposeCryptoOperators = true; _signFunction = SignMlDsa; _signUsingOffsetFunction = SignUsingOffsetMlDsa; #if NET6_0_OR_GREATER @@ -315,9 +320,6 @@ private void InitializeUsingX509MlDsa( string algorithm, bool requirePrivateKey) { - // Borrow the MLDsa instance from X509SecurityKey. - // The X509SecurityKey retains ownership; _disposeCryptoOperators remains - // false so the adapter will not dispose it. Same pattern as RSA/ECDsa. MLDsa mlDsa = requirePrivateKey ? x509SecurityKey.MlDsaPrivateKey : x509SecurityKey.MlDsaPublicKey; if (mlDsa == null) throw LogHelper.LogExceptionMessage( @@ -327,7 +329,7 @@ private void InitializeUsingX509MlDsa( LogHelper.MarkAsNonPII(algorithm), LogHelper.MarkAsNonPII(x509SecurityKey.KeyId)))); - InitializeUsingMlDsa(mlDsa); + InitializeUsingMlDsa(mlDsa, requirePrivateKey); } private void InitializeUsingX509Rsa( diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs index 158a6ad82d..65039d9491 100644 --- a/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs @@ -88,4 +88,35 @@ internal static MLDsaAlgorithm GetMLDsaAlgorithm(string algorithm) _ => throw LogHelper.LogArgumentException(nameof(algorithm), LogMessages.IDX10652, algorithm) }; } + + /// + /// Creates an independent clone of an instance by re-importing key material. + /// The clone is fully independent and safe for concurrent use on a separate thread. + /// + /// The source to clone. + /// Whether to include the private key in the clone. + /// A new instance with the same key material. + internal static MLDsa CloneMlDsa(MLDsa source, bool includePrivateKey) + { + if (source == null) + throw LogHelper.LogArgumentNullException(nameof(source)); + + MLDsaAlgorithm algorithm = source.Algorithm; + + if (includePrivateKey) + { + byte[] seed = source.ExportMLDsaPrivateSeed(); + try + { + return MLDsa.ImportMLDsaPrivateSeed(algorithm, seed); + } + finally + { + CryptographicOperations.ZeroMemory(seed); + } + } + + byte[] publicKey = source.ExportMLDsaPublicKey(); + return MLDsa.ImportMLDsaPublicKey(algorithm, publicKey); + } } diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs index 40a9f92a9a..75166afdad 100644 --- a/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs +++ b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Threading; using System.Threading.Tasks; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.TestUtils; @@ -884,6 +885,231 @@ public async Task JwtCreateWithX509_ValidateWithMlDsaKey(string algorithm) #endregion + #region Clone and Dispose Tests + + [MlDsaFact] + public void CloneMlDsa_PublicKeyOnly_ProducesIndependentInstance() + { + // Arrange + using var original = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + byte[] data = new byte[] { 1, 2, 3, 4, 5 }; + + // Act + using var clone = MlDsaAdapter.CloneMlDsa(original, includePrivateKey: false); + + // Assert — clone can verify signatures produced by original + byte[] signature = original.SignData(data, context: null); + Assert.True(clone.VerifyData(data, signature, context: null)); + + // Clone should NOT be able to sign (public-key only) + Assert.Throws(() => clone.SignData(data, context: null)); + } + + [MlDsaFact] + public void CloneMlDsa_WithPrivateKey_ProducesSigningCapableInstance() + { + // Arrange + using var original = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65); + byte[] data = new byte[] { 10, 20, 30, 40, 50 }; + + // Act + using var clone = MlDsaAdapter.CloneMlDsa(original, includePrivateKey: true); + + // Assert — clone can sign and original can verify + byte[] signature = clone.SignData(data, context: null); + Assert.True(original.VerifyData(data, signature, context: null)); + } + + [MlDsaFact] + public void CloneMlDsa_IsIndependent_DisposingCloneDoesNotAffectOriginal() + { + // Arrange + using var original = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + byte[] data = new byte[] { 1, 2, 3 }; + + // Act — create and immediately dispose the clone + var clone = MlDsaAdapter.CloneMlDsa(original, includePrivateKey: true); + clone.Dispose(); + + // Assert — original still works + byte[] signature = original.SignData(data, context: null); + Assert.NotNull(signature); + Assert.True(original.VerifyData(data, signature, context: null)); + } + + [MlDsaFact] + public void Dispose_OwnedKey_DisposesMLDsa() + { + // Arrange — internal constructor owns the MLDsa + using var mlDsa = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + var jwk = JsonWebKeyConverter.ConvertFromMlDsaSecurityKey(new MlDsaSecurityKey(mlDsa)); + var key = new MlDsaSecurityKey(jwk, usePrivateKey: false); + + // Act + key.Dispose(); + + // Assert — the owned MLDsa should be disposed (accessing it should throw) + Assert.Throws(() => key.MLDsa.ExportMLDsaPublicKey()); + } + + [MlDsaFact] + public void Dispose_BorrowedKey_DoesNotDisposeMLDsa() + { + // Arrange — public constructor does not own the MLDsa + using var mlDsa = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa44); + var key = new MlDsaSecurityKey(mlDsa); + + // Act + key.Dispose(); + + // Assert — the borrowed MLDsa should still be usable + byte[] exported = mlDsa.ExportMLDsaPublicKey(); + Assert.NotNull(exported); + } + + #endregion + + #region Concurrency Tests + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async Task ConcurrentSign_WithSharedProvider_ProducesValidSignatures(string algorithm) + { + // Arrange + var signingKey = GetMlDsaKey(algorithm); + var verifyKey = GetMlDsaPublicKey(algorithm); + var signingProvider = new AsymmetricSignatureProvider(signingKey, algorithm, true); + var verifyProvider = new AsymmetricSignatureProvider(verifyKey, algorithm, false); + int taskCount = 100; + using var barrier = new CountdownEvent(taskCount); + + // Act — sign 100 different payloads concurrently on the same provider + var tasks = new Task<(byte[] data, byte[] signature)>[taskCount]; + for (int i = 0; i < taskCount; i++) + { + int index = i; + tasks[i] = Task.Run(() => + { + byte[] data = new byte[32]; + byte[] indexBytes = BitConverter.GetBytes(index); + Buffer.BlockCopy(indexBytes, 0, data, 0, indexBytes.Length); + + // Wait until all tasks are ready before signing concurrently. + barrier.Signal(); + barrier.Wait(); + + byte[] signature = signingProvider.Sign(data); + return (data, signature); + }); + } + + var results = await Task.WhenAll(tasks); + + // Assert — every signature must be valid + foreach (var (data, signature) in results) + { + Assert.NotNull(signature); + Assert.True(signature.Length > 0, "Signature should not be empty"); + Assert.True(verifyProvider.Verify(data, signature), + "Concurrent signature failed verification"); + } + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async Task ConcurrentVerify_WithSharedProvider_AllSucceed(string algorithm) + { + // Arrange — create a signature to verify + byte[] data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var signingKey = GetMlDsaKey(algorithm); + var verifyKey = GetMlDsaPublicKey(algorithm); + var signingProvider = new AsymmetricSignatureProvider(signingKey, algorithm, true); + byte[] signature = signingProvider.Sign(data); + var verifyProvider = new AsymmetricSignatureProvider(verifyKey, algorithm, false); + int taskCount = 100; + using var barrier = new CountdownEvent(taskCount); + + // Act — verify the same signature concurrently on the same provider + var tasks = new Task[taskCount]; + for (int i = 0; i < taskCount; i++) + { + tasks[i] = Task.Run(() => + { + barrier.Signal(); + barrier.Wait(); + return verifyProvider.Verify(data, signature); + }); + } + + var results = await Task.WhenAll(tasks); + + // Assert + Assert.All(results, result => Assert.True(result, + "Concurrent verification returned false")); + } + + [MlDsaTheory] + [InlineData("ML-DSA-44")] + [InlineData("ML-DSA-65")] + [InlineData("ML-DSA-87")] + public async Task ConcurrentSignAndVerify_WithSharedKey_ProducesCorrectResults(string algorithm) + { + // Arrange — use the same key for both sign and verify concurrently + var signingKey = GetMlDsaKey(algorithm); + var verifyKey = GetMlDsaPublicKey(algorithm); + var signingProvider = new AsymmetricSignatureProvider(signingKey, algorithm, true); + var verifyProvider = new AsymmetricSignatureProvider(verifyKey, algorithm, false); + int taskCount = 50; + using var signBarrier = new CountdownEvent(taskCount); + + // Act — sign all payloads concurrently + var signTasks = new Task<(byte[] data, byte[] signature)>[taskCount]; + for (int i = 0; i < taskCount; i++) + { + int index = i; + signTasks[i] = Task.Run(() => + { + byte[] data = new byte[64]; + byte[] indexBytes = BitConverter.GetBytes(index); + Buffer.BlockCopy(indexBytes, 0, data, 0, indexBytes.Length); + + signBarrier.Signal(); + signBarrier.Wait(); + + byte[] signature = signingProvider.Sign(data); + return (data, signature); + }); + } + + var signResults = await Task.WhenAll(signTasks); + + // Now verify all signatures concurrently + using var verifyBarrier = new CountdownEvent(taskCount); + var verifyTasks = new Task[taskCount]; + for (int i = 0; i < taskCount; i++) + { + var (data, sig) = signResults[i]; + verifyTasks[i] = Task.Run(() => + { + verifyBarrier.Signal(); + verifyBarrier.Wait(); + return verifyProvider.Verify(data, sig); + }); + } + + var verifyResults = await Task.WhenAll(verifyTasks); + + // Assert + Assert.All(verifyResults, result => Assert.True(result, + "Signature produced under concurrency failed verification")); + } + + #endregion + #region Helpers private static MlDsaSecurityKey GetMlDsaKey(string algorithm) => algorithm switch From d607a421a11eafa96bce8cc5f04a8025cced47c6 Mon Sep 17 00:00:00 2001 From: Zhenya Polyvanyi Date: Thu, 28 May 2026 19:14:23 +0100 Subject: [PATCH 12/13] Remove TFM condition compilation of ML-DSA and use sign operation as sentinel of presence of ML-DSA private key --- .../AsymmetricAdapter.cs | 20 ----------- .../MlDsaSecurityKey.cs | 36 ++++--------------- 2 files changed, 7 insertions(+), 49 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs index e14f252139..9f790a24fb 100644 --- a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs @@ -437,7 +437,6 @@ private byte[] SignMlDsa(byte[] bytes) return MLDsa.SignData(bytes, context: null); } -#if NET6_0_OR_GREATER internal bool SignUsingSpanMlDsa( ReadOnlySpan data, Span destination, @@ -455,11 +454,9 @@ internal bool SignUsingSpanMlDsa( bytesWritten = signatureSize; return true; } -#endif private byte[] SignUsingOffsetMlDsa(byte[] bytes, int offset, int count) { -#if NET6_0_OR_GREATER int signatureSize = MLDsa.Algorithm.SignatureSizeInBytes; byte[] signature = new byte[signatureSize]; MLDsa.SignData( @@ -467,14 +464,6 @@ private byte[] SignUsingOffsetMlDsa(byte[] bytes, int offset, int count) signature.AsSpan(), context: default); return signature; -#else - if (offset == 0 && count == bytes.Length) - return MLDsa.SignData(bytes, context: null); - - byte[] slice = new byte[count]; - Buffer.BlockCopy(bytes, offset, slice, 0, count); - return MLDsa.SignData(slice, context: null); -#endif } internal bool Verify(byte[] bytes, byte[] signature) @@ -524,19 +513,10 @@ private bool VerifyMlDsa(byte[] bytes, byte[] signature) private bool VerifyUsingOffsetMlDsa(byte[] bytes, int offset, int count, byte[] signature) { -#if NET6_0_OR_GREATER return MLDsa.VerifyData( new ReadOnlySpan(bytes, offset, count), signature.AsSpan(), context: default); -#else - if (offset == 0 && count == bytes.Length) - return MLDsa.VerifyData(bytes, signature, context: null); - - byte[] slice = new byte[count]; - Buffer.BlockCopy(bytes, offset, slice, 0, count); - return MLDsa.VerifyData(slice, signature, context: null); -#endif } private byte[] DecryptWithRsa(byte[] bytes) diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs index c6dd3eefab..23b0feace6 100644 --- a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs @@ -72,33 +72,7 @@ protected virtual void Dispose(bool disposing) /// /// if it has a private key; otherwise, . [Obsolete("HasPrivateKey method is deprecated, please use PrivateKeyStatus instead.")] - public override bool HasPrivateKey - { - get - { - if (_hasPrivateKey == null) - { - try - { - byte[] seed = MLDsa.ExportMLDsaPrivateSeed(); - CryptographicOperations.ZeroMemory(seed); - _hasPrivateKey = true; - } - catch (CryptographicException) - { - _hasPrivateKey = false; - } - catch (Exception) - { - // Cannot determine private key status (e.g., platform limitation). - // Do not cache — let PrivateKeyStatus determine independently. - return false; - } - } - - return _hasPrivateKey.Value; - } - } + public override bool HasPrivateKey => PrivateKeyStatus == PrivateKeyStatus.Exists; /// /// Gets a value indicating the existence of the private key. @@ -116,8 +90,12 @@ public override PrivateKeyStatus PrivateKeyStatus { try { - byte[] seed = MLDsa.ExportMLDsaPrivateSeed(); - CryptographicOperations.ZeroMemory(seed); + // Try signing to detect private key — this is the most reliable + // check because keys imported from expanded private key material + // (not seed) can sign but cannot export the seed. + byte[] dummy = new byte[1]; + byte[] sig = MLDsa.SignData(dummy, context: null); + CryptographicOperations.ZeroMemory(sig); _hasPrivateKey = true; } catch (CryptographicException) From 445d578fa86260c0bffe1880379a35928bbc6a45 Mon Sep 17 00:00:00 2001 From: Zhenya Polyvanyi Date: Thu, 28 May 2026 19:19:05 +0100 Subject: [PATCH 13/13] Share ML-DSA between adapters in case of non-exportable seed or private key with performing its operations under lock --- .../AsymmetricAdapter.cs | 73 +++++++++++++++++-- .../MlDsaAdapter.cs | 54 ++++++++++++-- 2 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs index 9f790a24fb..004bda2583 100644 --- a/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/AsymmetricAdapter.cs @@ -31,6 +31,7 @@ internal class AsymmetricAdapter : IDisposable #endif private bool _disposeCryptoOperators; private bool _disposed; + private object _mlDsaSyncLock; private DecryptDelegate _decryptFunction = DecryptFunctionNotFound; private EncryptDelegate _encryptFunction = EncryptFunctionNotFound; private SignDelegate _signFunction = SignFunctionNotFound; @@ -197,9 +198,22 @@ private void InitializeUsingMlDsa(MLDsa mlDsa, bool includePrivateKey) // Clone the MLDsa instance so each adapter holds an independent copy. // MLDsa instance methods are not guaranteed thread-safe, and multiple // adapters from the object pool may reference the same source key. - // Cloning ensures each adapter can be used concurrently without races. - MLDsa = MlDsaAdapter.CloneMlDsa(mlDsa, includePrivateKey); - _disposeCryptoOperators = true; + MLDsa clone = MlDsaAdapter.CloneMlDsa(mlDsa, includePrivateKey); + + if (clone is not null) + { + // Clone succeeded — each adapter owns its independent instance. + MLDsa = clone; + _disposeCryptoOperators = true; + } + else + { + // Key is non-exportable (e.g., HSM-backed) — share the original + // instance and serialize access with a lock. + MLDsa = mlDsa; + _mlDsaSyncLock = new object(); + } + _signFunction = SignMlDsa; _signUsingOffsetFunction = SignUsingOffsetMlDsa; #if NET6_0_OR_GREATER @@ -434,6 +448,12 @@ private byte[] SignUsingOffsetECDsa(byte[] bytes, int offset, int count) private byte[] SignMlDsa(byte[] bytes) { + if (_mlDsaSyncLock is not null) + { + lock (_mlDsaSyncLock) + return MLDsa.SignData(bytes, context: null); + } + return MLDsa.SignData(bytes, context: null); } @@ -450,7 +470,16 @@ internal bool SignUsingSpanMlDsa( } // MLDsa.SignData requires destination to be exactly SignatureSizeInBytes. - MLDsa.SignData(data, destination.Slice(0, signatureSize), context: default); + if (_mlDsaSyncLock is not null) + { + lock (_mlDsaSyncLock) + MLDsa.SignData(data, destination.Slice(0, signatureSize), context: default); + } + else + { + MLDsa.SignData(data, destination.Slice(0, signatureSize), context: default); + } + bytesWritten = signatureSize; return true; } @@ -459,10 +488,23 @@ private byte[] SignUsingOffsetMlDsa(byte[] bytes, int offset, int count) { int signatureSize = MLDsa.Algorithm.SignatureSizeInBytes; byte[] signature = new byte[signatureSize]; - MLDsa.SignData( - new ReadOnlySpan(bytes, offset, count), - signature.AsSpan(), - context: default); + + if (_mlDsaSyncLock is not null) + { + lock (_mlDsaSyncLock) + MLDsa.SignData( + new ReadOnlySpan(bytes, offset, count), + signature.AsSpan(), + context: default); + } + else + { + MLDsa.SignData( + new ReadOnlySpan(bytes, offset, count), + signature.AsSpan(), + context: default); + } + return signature; } @@ -508,11 +550,26 @@ private bool VerifyUsingOffsetECDsa(byte[] bytes, int offset, int count, byte[] private bool VerifyMlDsa(byte[] bytes, byte[] signature) { + if (_mlDsaSyncLock is not null) + { + lock (_mlDsaSyncLock) + return MLDsa.VerifyData(bytes, signature, context: null); + } + return MLDsa.VerifyData(bytes, signature, context: null); } private bool VerifyUsingOffsetMlDsa(byte[] bytes, int offset, int count, byte[] signature) { + if (_mlDsaSyncLock is not null) + { + lock (_mlDsaSyncLock) + return MLDsa.VerifyData( + new ReadOnlySpan(bytes, offset, count), + signature.AsSpan(), + context: default); + } + return MLDsa.VerifyData( new ReadOnlySpan(bytes, offset, count), signature.AsSpan(), diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs index 65039d9491..647165cee1 100644 --- a/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs +++ b/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs @@ -95,28 +95,66 @@ internal static MLDsaAlgorithm GetMLDsaAlgorithm(string algorithm) /// /// The source to clone. /// Whether to include the private key in the clone. - /// A new instance with the same key material. + /// + /// A new instance with the same key material, or + /// if the key material cannot be exported (e.g., HSM-backed non-exportable keys). + /// internal static MLDsa CloneMlDsa(MLDsa source, bool includePrivateKey) { - if (source == null) + if (source is null) throw LogHelper.LogArgumentNullException(nameof(source)); MLDsaAlgorithm algorithm = source.Algorithm; if (includePrivateKey) { - byte[] seed = source.ExportMLDsaPrivateSeed(); + // Try seed first (smallest representation), then expanded private key. + // Either may fail for keys that don't know the seed (imported from + // expanded key) or are non-exportable (HSM/CNG-backed). try { - return MLDsa.ImportMLDsaPrivateSeed(algorithm, seed); + byte[] seed = source.ExportMLDsaPrivateSeed(); + try + { + return MLDsa.ImportMLDsaPrivateSeed(algorithm, seed); + } + finally + { + CryptographicOperations.ZeroMemory(seed); + } } - finally + catch (CryptographicException) { - CryptographicOperations.ZeroMemory(seed); + } + + try + { + byte[] expandedKey = source.ExportMLDsaPrivateKey(); + try + { + return MLDsa.ImportMLDsaPrivateKey(algorithm, expandedKey); + } + finally + { + CryptographicOperations.ZeroMemory(expandedKey); + } + } + catch (CryptographicException) + { + // Key is non-exportable — caller must fall back to + // using the original instance with synchronization. + return null; } } - byte[] publicKey = source.ExportMLDsaPublicKey(); - return MLDsa.ImportMLDsaPublicKey(algorithm, publicKey); + try + { + byte[] publicKey = source.ExportMLDsaPublicKey(); + return MLDsa.ImportMLDsaPublicKey(algorithm, publicKey); + } + catch (CryptographicException) + { + return null; + } } }