Search code examples
c#cryptographyazure-keyvaultduende-identity-server

How to use an RSA key for Duende Identity Server v7


My thought process was:

  1. Create the RSA key in Azure Key Vault --> Keys
  2. Retrieve the key using KeyClient
  3. Pass the key to AddSigningCredential

Here is the C# code

string keyVaultUrl = Configuration.GetValue<string>("AzureSettings:KeyVaultUrl");
var identityServerConfig = Configuration.GetSection("IdentityServer:Key");
string keyName = identityServerConfig["Name"];
var client = new KeyClient(new Uri(keyVaultUrl), new DefaultAzureCredential());

KeyVaultKey keyVaultKey = client.GetKey(keyName);

// Ensure the key is an RSA key
if (keyVaultKey.KeyType != KeyType.Rsa && keyVaultKey.KeyType != KeyType.RsaHsm) {
  throw new InvalidOperationException("The key retrieved from Key Vault is not an RSA key.");
}

// Convert KeyVault's RSA key to RsaSecurityKey
var rsa = new RSAParameters { Modulus = keyVaultKey.Key.N, Exponent = keyVaultKey.Key.E };

var rsaCryptoServiceProvider = RSA.Create();
rsaCryptoServiceProvider.ImportParameters(rsa);

RsaSecurityKey key = new(rsaCryptoServiceProvider);
identityServiceBuild.AddSigningCredential(key, IdentityServerConstants.RsaSigningAlgorithm.RS256);

Here is the RSA Key creation inside Azure Key Vault

enter image description here

Here is the exception thrown :(

[01:51:18 Information] UnhandledExceptionEvent { Details: "System.Security.Cryptography.CryptographicException: Unknown error (0xc100000d)
   at System.Security.Cryptography.RSABCrypt.TrySignHash(ReadOnlySpan`1 hash, Span`1 destination, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding, Int32& bytesWritten)
   at System.Security.Cryptography.RSA.TrySignData(ReadOnlySpan`1 data, Span`1 destination, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding, Int32& bytesWritten)
   at Microsoft.IdentityModel.Tokens.AsymmetricAdapter.SignUsingSpanRsa(ReadOnlySpan`1 data, Span`1 destination, Int32& bytesWritten)
   at Microsoft.IdentityModel.Tokens.AsymmetricAdapter.SignUsingSpan(ReadOnlySpan`1 data, Span`1 destination, Int32& bytesWritten)
   at Microsoft.IdentityModel.Tokens.AsymmetricSignatureProvider.Sign(ReadOnlySpan`1 input, Span`1 signature, Int32& bytesWritten)
   at Microsoft.IdentityModel.JsonWebTokens.JwtTokenUtilities.CreateSignature(ReadOnlySpan`1 data, Span`1 destination, SigningCredentials signingCredentials, Int32& bytesWritten)

I have also tried generating an RSA key using Putty Key Generator and uploading that but same exception is thrown.

Refer to cert in C# like this @Tore?

string keyVaultUrl = Configuration.GetValue<string>("AzureSettings:KeyVaultUrl");
var client = new CertificateClient(new Uri(keyVaultUrl), new DefaultAzureCredential());

KeyVaultCertificateWithPolicy certificate = client.GetCertificate("duendeidentityserverv7");

if (certificate == null) {
  throw new InvalidOperationException($"Certificate duendeidentityserverv7 not found in Azure Key Vault.");
}

X509Certificate2 cert = new X509Certificate2(certificate.Cer);

identityServiceBuild.AddSigningCredential(cert);

Solution

  • I have seen this problem before and when you get the key, you only get the public key, not the private key. to get the private key, you need to get it as a secret. When I have stored keys in AKV, I have uploaded and stored them as a certificate instead of a raw key and when you try to get the certifictate, you only get the public key and not the private key. So perhaps you see the same problem?

    Below is the code I wrote for a few years ago to download the key from Azure Key Vault (key stored as a certificate)

    namespace Infrastructure
    {
        /// <summary>
        /// Extension methods and helper methods to access the Azure Key Vault
        /// 
        /// Written by Tore Nestenius, https://tn-data.se , https://nestenius.se
        /// </summary>
        public static class KeyVaultExtensions
        {
            /// <summary>
            /// Add Azure Key vault to the ASP.NET Configuration system
            /// </summary>
            /// <param name="config"></param>
            public static void AddAzureKeyVaultSupport(this IConfigurationBuilder config)
            {
                var builtConfig = config.Build();
    
                string vaultUrl = builtConfig["Vault:Url"] ?? "";
                string clientId = builtConfig["Vault:ClientId"] ?? "";
                string tenantId = builtConfig["Vault:TenantId"] ?? "";
                string secret = builtConfig["Vault:ClientSecret"] ?? "";
    
                Console.WriteLine("Adding AzureKeyVault Support");
    
                CheckIfValueIsProvided(vaultUrl, nameof(vaultUrl), sensitiveValue: false);
                CheckIfValueIsProvided(clientId, nameof(clientId), sensitiveValue: true);
                CheckIfValueIsProvided(tenantId, nameof(tenantId), sensitiveValue: true);
                CheckIfValueIsProvided(secret, nameof(secret), sensitiveValue: true);
    
                config.AddAzureKeyVault(new Uri(vaultUrl), new ClientSecretCredential(tenantId, clientId, secret));
    
                Console.WriteLine("- Vault configured");
            }
    
            private static void CheckIfValueIsProvided(string value, string parameterName, bool sensitiveValue)
            {
                if (string.IsNullOrEmpty(value))
                {
                    Console.WriteLine($"Fatal: - {parameterName} not found");
                    throw new Exception($"Fatal: {parameterName} not found");
                }
                else
                {
                    //To assist troubleshooting in production, we print out the first character for each config value
                    if (sensitiveValue)
                        Console.WriteLine($"- {parameterName}: {value.Substring(0, 1)}...");
                    else
                        Console.WriteLine($"- {parameterName}: {value}");
                }
            }
    
    
            /// <summary>
            /// Load a certificate (with private key) from Azure Key Vault
            ///
            /// Getting a certificate with private key is a bit of a pain, but the code below solves it.
            /// 
            /// Get the private key for Key Vault certificate
            /// https://github.com/heaths/azsdk-sample-getcert
            /// 
            /// See also these GitHub issues: 
            /// https://github.com/Azure/azure-sdk-for-net/issues/12742
            /// https://github.com/Azure/azure-sdk-for-net/issues/12083
            /// </summary>
            /// <param name="config"></param>
            /// <param name="certificateName"></param>
            /// <returns></returns>
            public static X509Certificate2 LoadCertificate(IConfiguration config, string certificateName)
            {
                string vaultUrl = config["Vault:Url"] ?? "";
                string clientId = config["Vault:ClientId"] ?? "";
                string tenantId = config["Vault:TenantId"] ?? "";
                string secret = config["Vault:ClientSecret"] ?? "";
    
                Console.WriteLine($"Loading certificate '{certificateName}' from Azure Key Vault");
    
                var credentials = new ClientSecretCredential(tenantId: tenantId, clientId: clientId, clientSecret: secret);
                var certClient = new CertificateClient(new Uri(vaultUrl), credentials);
                var secretClient = new SecretClient(new Uri(vaultUrl), credentials);
    
                var cert = GetCertificateAsync(certClient, secretClient, certificateName);
    
                Console.WriteLine("Certificate loaded");
                return cert;
            }
    
    
            /// <summary>
            /// Helper method to get a certificate
            /// 
            /// Source https://github.com/heaths/azsdk-sample-getcert/blob/master/Program.cs
            /// </summary>
            /// <param name="certificateClient"></param>
            /// <param name="secretClient"></param>
            /// <param name="certificateName"></param>
            /// <returns></returns>
            private static X509Certificate2 GetCertificateAsync(CertificateClient certificateClient,
                                                                    SecretClient secretClient,
                                                                    string certificateName)
            {
    
                KeyVaultCertificateWithPolicy certificate = certificateClient.GetCertificate(certificateName);
    
                // Return a certificate with only the public key if the private key is not exportable.
                if (certificate.Policy?.Exportable != true)
                {
                    return new X509Certificate2(certificate.Cer);
                }
    
                // Parse the secret ID and version to retrieve the private key.
                string[] segments = certificate.SecretId.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
                if (segments.Length != 3)
                {
                    throw new InvalidOperationException($"Number of segments is incorrect: {segments.Length}, URI: {certificate.SecretId}");
                }
    
                string secretName = segments[1];
                string secretVersion = segments[2];
    
                KeyVaultSecret secret = secretClient.GetSecret(secretName, secretVersion);
    
                // For PEM, you'll need to extract the base64-encoded message body.
                // .NET 5.0 preview introduces the System.Security.Cryptography.PemEncoding class to make this easier.
                if ("application/x-pkcs12".Equals(secret.Properties.ContentType, StringComparison.InvariantCultureIgnoreCase))
                {
                    byte[] pfx = Convert.FromBase64String(secret.Value);
                    return new X509Certificate2(pfx);
                }
    
                throw new NotSupportedException($"Only PKCS#12 is supported. Found Content-Type: {secret.Properties.ContentType}");
            }
        }
    }
    

    In your code, you do this:

           var rsa = new RSAParameters
            {
                Modulus = keyVaultKey.Key.N,
                Exponent = keyVaultKey.Key.E
            };
    

    This is only converting the public key, not the private key parts. IdentityServer needs the full private key, not just the public key parts.

    You would probably, need to include the other parameters as well, like:

    var rsa = new RSAParameters
    {
        Modulus = keyVaultKey.Key.N,
        Exponent = keyVaultKey.Key.E,
        D = keyVaultKey.Key.D,           // Private Exponent
        P = keyVaultKey.Key.P,           // First Prime Factor
        Q = keyVaultKey.Key.Q,           // Second Prime Factor
        DP = keyVaultKey.Key.DP,         // First Factor's CRT Exponent
        DQ = keyVaultKey.Key.DQ,         // Second Factor's CRT Exponent
        InverseQ = keyVaultKey.Key.QI    // CRT Coefficient
    };
    

    My suggestion is to use OpenSSL to create the keys, using it like this:

    openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -aes256 -out rsa-private-key.pem 
    

    Optionally, if you are curious, you can extract the public key from the above key:

    openssl rsa -in rsa-private-key.pem –pubout -out rsa-public-key.pem 
    

    To create a certificate from the private key:

    openssl req -new -x509 -key rsa-private-key.pem -days 365 -subj "/CN=MyRSACert" -out rsa-cert.crt