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..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;
@@ -87,6 +88,8 @@ internal AsymmetricAdapter(
InitializeUsingX509SecurityKey(x509SecurityKeyFromJsonWebKey, algorithm, requirePrivateKey);
else if (securityKey is ECDsaSecurityKey edcsaSecurityKeyFromJsonWebKey)
InitializeUsingEcdsaSecurityKey(edcsaSecurityKeyFromJsonWebKey);
+ else if (securityKey is MlDsaSecurityKey mlDsaSecurityKeyFromJsonWebKey)
+ InitializeUsingMlDsaSecurityKey(mlDsaSecurityKeyFromJsonWebKey, requirePrivateKey);
else
throw LogHelper.LogExceptionMessage(
new NotSupportedException(
@@ -99,6 +102,10 @@ internal AsymmetricAdapter(
{
InitializeUsingEcdsaSecurityKey(ecdsaKey);
}
+ else if (key is MlDsaSecurityKey mlDsaKey)
+ {
+ InitializeUsingMlDsaSecurityKey(mlDsaKey, requirePrivateKey);
+ }
else
throw LogHelper.LogExceptionMessage(
new NotSupportedException(
@@ -138,6 +145,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 +161,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 +188,41 @@ private void InitializeUsingEcdsaSecurityKey(ECDsaSecurityKey ecdsaSecurityKey)
_verifyUsingOffsetFunction = VerifyUsingOffsetECDsa;
}
+ private void InitializeUsingMlDsaSecurityKey(MlDsaSecurityKey mlDsaSecurityKey, bool requirePrivateKey)
+ {
+ InitializeUsingMlDsa(mlDsaSecurityKey.MLDsa, requirePrivateKey);
+ }
+
+ 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.
+ 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
+ _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
@@ -257,11 +304,68 @@ private void InitializeUsingX509SecurityKey(
X509SecurityKey x509SecurityKey,
string algorithm,
bool requirePrivateKey)
+ {
+ if (x509SecurityKey.MlDsaPublicKey != null)
+ {
+ InitializeUsingX509MlDsa(x509SecurityKey, algorithm, requirePrivateKey);
+ }
+ else if (x509SecurityKey.PublicKey is RSA)
+ {
+ InitializeUsingX509Rsa(x509SecurityKey, algorithm, requirePrivateKey);
+ }
+ else if (x509SecurityKey.PublicKey is ECDsa ecDsa)
+ {
+ InitializeUsingEcdsaSecurityKey(new ECDsaSecurityKey(ecDsa));
+ }
+ else
+ {
+ // Certificate key type is not recognized (not RSA, ECDSA, or ML-DSA).
+ throw LogHelper.LogExceptionMessage(
+ new NotSupportedException(
+ LogHelper.FormatInvariant(
+ LogMessages.IDX10725,
+ LogHelper.MarkAsNonPII(algorithm),
+ LogHelper.MarkAsNonPII(x509SecurityKey.KeyId))));
+ }
+ }
+
+ private void InitializeUsingX509MlDsa(
+ X509SecurityKey x509SecurityKey,
+ string algorithm,
+ bool requirePrivateKey)
+ {
+ 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, requirePrivateKey);
+ }
+
+ private void InitializeUsingX509Rsa(
+ X509SecurityKey x509SecurityKey,
+ string algorithm,
+ bool requirePrivateKey)
{
if (requirePrivateKey)
+ {
+ if (x509SecurityKey.PrivateKey == null)
+ throw LogHelper.LogExceptionMessage(
+ new InvalidOperationException(
+ LogHelper.FormatInvariant(
+ LogMessages.IDX10638,
+ LogHelper.MarkAsNonPII(x509SecurityKey.KeyId))));
+
InitializeUsingRsa(x509SecurityKey.PrivateKey as RSA, algorithm);
+ }
else
+ {
InitializeUsingRsa(x509SecurityKey.PublicKey as RSA, algorithm);
+ }
}
private RSA RSA { get; set; }
@@ -342,6 +446,68 @@ private byte[] SignUsingOffsetECDsa(byte[] bytes, int offset, int count)
return ECDsa.SignHash(HashAlgorithm.ComputeHash(bytes, offset, 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);
+ }
+
+ 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.
+ 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;
+ }
+
+ private byte[] SignUsingOffsetMlDsa(byte[] bytes, int offset, int count)
+ {
+ int signatureSize = MLDsa.Algorithm.SignatureSizeInBytes;
+ byte[] signature = new byte[signatureSize];
+
+ 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;
+ }
+
internal bool Verify(byte[] bytes, byte[] signature)
{
return _verifyFunction(bytes, signature);
@@ -382,6 +548,34 @@ private bool VerifyUsingOffsetECDsa(byte[] bytes, int offset, int count, byte[]
#endif
}
+ 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(),
+ context: default);
+ }
+
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..dd15d709c7 100644
--- a/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs
+++ b/src/Microsoft.IdentityModel.Tokens/GlobalSuppressions.cs
@@ -28,7 +28,10 @@
[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 = "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/InternalAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt
index e69de29bb2..ad9824b5e2 100644
--- a/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt
+++ b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt
@@ -0,0 +1,14 @@
+
+~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..35192d2f3b 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 !string.IsNullOrEmpty(Priv);
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..307d9911a0 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,42 @@ 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)
+ {
+ // 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;
+ }
+
+ return true;
+ }
+
+ return TryConvertToMlDsaSecurityKey(webKey, out key);
+ }
}
catch (Exception ex)
{
@@ -396,5 +491,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..647165cee1
--- /dev/null
+++ b/src/Microsoft.IdentityModel.Tokens/MlDsaAdapter.cs
@@ -0,0 +1,160 @@
+// 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)
+ };
+ }
+
+ ///
+ /// 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, 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 is null)
+ throw LogHelper.LogArgumentNullException(nameof(source));
+
+ MLDsaAlgorithm algorithm = source.Algorithm;
+
+ if (includePrivateKey)
+ {
+ // 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
+ {
+ byte[] seed = source.ExportMLDsaPrivateSeed();
+ try
+ {
+ return MLDsa.ImportMLDsaPrivateSeed(algorithm, seed);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(seed);
+ }
+ }
+ catch (CryptographicException)
+ {
+ }
+
+ 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;
+ }
+ }
+
+ try
+ {
+ byte[] publicKey = source.ExportMLDsaPublicKey();
+ return MLDsa.ImportMLDsaPublicKey(algorithm, publicKey);
+ }
+ catch (CryptographicException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs
new file mode 100644
index 0000000000..23b0feace6
--- /dev/null
+++ b/src/Microsoft.IdentityModel.Tokens/MlDsaSecurityKey.cs
@@ -0,0 +1,158 @@
+// 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, 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;
+ }
+
+ ///
+ /// 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; }
+
+ ///
+ /// 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.
+ ///
+ /// if it has a private key; otherwise, .
+ [Obsolete("HasPrivateKey method is deprecated, please use PrivateKeyStatus instead.")]
+ public override bool HasPrivateKey => PrivateKeyStatus == PrivateKeyStatus.Exists;
+
+ ///
+ /// 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
+ {
+ // 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)
+ {
+ _hasPrivateKey = false;
+ }
+ catch (Exception)
+ {
+ // Cannot determine private key status (e.g., platform limitation).
+ return PrivateKeyStatus.Unknown;
+ }
+ }
+
+ 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.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt
index e69de29bb2..2f29582905 100644
--- a/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt
@@ -0,0 +1,21 @@
+~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.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
+~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/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 dd9d63dae1..b84268516d 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
}
@@ -113,28 +118,52 @@ 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),
- // 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
};
}
+ 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)
@@ -165,6 +194,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..0ad4038e7f 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,19 @@ 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))));
+
+ 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();
}
@@ -226,5 +361,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..bd85a6ac50
--- /dev/null
+++ b/test/Microsoft.IdentityModel.TestUtils/MlDsaKeyingMaterial.cs
@@ -0,0 +1,138 @@
+// 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.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);
+#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..75166afdad
--- /dev/null
+++ b/test/Microsoft.IdentityModel.Tokens.Tests/MlDsaSecurityKeyTests.cs
@@ -0,0 +1,1151 @@
+// 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;
+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
+ using var publicOnlyCert = X509CertificateLoader.LoadCertificate(x509Key.Certificate.RawData);
+#else
+#pragma warning disable SYSLIB0057
+ using 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 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 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.
+#if NET9_0_OR_GREATER
+ using var cert = X509CertificateLoader.LoadCertificate(MlDsaKeyingMaterial.MlDsa44Cert.RawData);
+#else
+#pragma warning disable SYSLIB0057
+ using var cert = new X509Certificate2(MlDsaKeyingMaterial.MlDsa44Cert.RawData);
+#pragma warning restore SYSLIB0057
+#endif
+ var x509Key = new X509SecurityKey(cert);
+ return x509Key.MlDsaPublicKey != null;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ [MlDsaTheory]
+ [InlineData("ML-DSA-44")]
+ [InlineData("ML-DSA-65")]
+ [InlineData("ML-DSA-87")]
+ public async 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 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 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
+ {
+ "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