2020年4月11日 星期六

[ASP.NET Core] Identity Server 4 – Signing credential


 ASP.NET Core   Identity Server 4   Signing Credential   Key rollover  






Introduction


Signing Credential in Identity Server 4 is an asymmetric key pair which is used for signing/validating JWTs.
In development mode, we can easily create/reuse the Signing Credential by

var builder = services.AddIdentityServer();

// Signing credential
if (this.env.IsDevelopment())
{
    builder.AddDeveloperSigningCredential();
}

The above codes will check if there is tempkey.rsa (Development Signing Credential) in your Idsrv4’s root path and create a new one if it doesn’t exists.

However, when the Development Signing Credential changes, we will encounter the problem that the signed JWTs by old (private) key will be invalid when being validated by the new (public) key. So it’s recommended to use the SSL/TLS certificate or use your own RSA key pair, and do the key rollover.



Key rollover means:

1.  Create a new Signing Credential that is used to ISSUE/VALIDATE JWT.
2.  Add old Signing Credentials as the Validation Keys to ONLY VALIDATE JWT.
3.  Retire the old Signing Credentials if we don’t need to validate the JWTs signed by them anymore. (This wont be discussed in this article).




In this tutorial, we will replace the Development Signing Credential in these ways:

From
Ho to do key rollover
Note
Certificate
Manually update the certificate
Some cert provider can renew cert automatically, like Let’s encrypt.
You can combine the renew process to your CI.
File
Manually/Automatically
The key can be renewed automatically when having a Registry Server and integrate in your CI. This tutorial will only has the manually way.
Redis
Automatically
You can store the key in Database instead of Redis.


Environment


Docker 18.05.0-ce
.NET Core SDK 3.1.201
IdentityServer4 3.1.2
IdentityModel 4.0.0



Implement


The source code is on my Github.

Signing Credential from certificate (Linux container)

The Resource Server must have the Authentication/Authorization configuration. (Reference: [ASP.NET Core] Identity Server 4 – Secure Web API)
Notice that the Audience is “MyBackendApi2”.

First we have to create the certificate by OpenSSL, skip this step if you already have one.

(Optional) Create certificate

$ openssl req -newkey rsa:4096 -nodes -sha256 -keyout certs/Docker.key -x509 -days 3650 -out certs/Docker.crt
$ openssl pkcs12 -export -out certs/Docker.pfx -inkey certs/Docker.key -in certs/Docker.crt

For more self-signed certificate, see [ASP.Net Core] Self-signed SSL certificate.


Startup.cs: ConfigureServices

Add the certificate as Signing Credential as following, notice that I put the cert under certs/.

public void ConfigureServices(IServiceCollection services)
{
       // … skip other codes

var builder = services.AddIdentityServer(options =>
{
            // … set options here
      });

       var rootPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Certs");
      var cert = new X509Certificate2(Path.Combine(rootPath, "Docker.pfx"), string.Empty);
      builder.AddSigningCredential(cert);
}


We can also add the old certificate as the validation key like this,

// Add validation keys if any
builder.AddValidationKey(old_cert);




Signing Credential from certificate (Windows)

First, lets import the certificate into Windows Certificate Store by Certificate Management Store.










After importing the cert, we need to know the Thumbprint of it to get the right certificate in our code.

Right click on the cert -> Properties




Copy the value of Thumbprint in [Details] tab,





Now we can use it as Signing Credential in Idsrv.


Startup.cs: ConfigureServices

Add the certificate as Signing Credential from Windows Certificate Store as following,

public void ConfigureServices(IServiceCollection services)
{
       // … skip other codes

var builder = services.AddIdentityServer(options =>
{
            // … set options here
      });

       using (var certStore = new X509Store(StoreName.My, StoreLocation.LocalMachine))
      {
            certStore.Open(OpenFlags.ReadOnly);
            var certCollection = certStore.Certificates.Find(
                        X509FindType.FindByThumbprint,
                        "3c99282xxxxxxxxxxxxxxxxxxxxxxxxx"// Change this with the thumbprint of your certifiacte
                        validOnly: false);

            if (certCollection.Count > 0)
            {
               cert = certCollection[0];
               builder.AddSigningCredential(cert);
            }
     }

      // Add validation keys if any
// builder.AddValidationKey(old_cert);
}


PS. I put the above codes as an extension method here from class: IdentityServerBuilderExtensions.



Signing Credential from Redis

We will use 2 Redis keys to store the keys (Signing credentials):

Redis Key
Type
Description
Content format
SigningCredential
Strings
The Signing Credential for issuing and validating JWT
Object in JSON
SigningCredentialDeprecated
Strings
Deprecated Signing Credentials for validating JWT
Array in JSON


The Signing Credential(s) will be stored as object or array of custom type: SigningCredential,

Model: SigningCredential.cs

public class SigningCredential
{
        /// <summary>
        /// Key ID
        /// </summary>
        public string KeyId { getset; }

        /// <summary>
        /// RSA parameters
        /// </summary>
        public RSAParameters Parameters { getset; }

        /// <summary>
        /// Expire on
        /// </summary>
        public DateTimeOffset ExpireOn { getset; }
}

This model is similar to the private class: TemporaryRsaKey in Crypto.cs from Idsrv4’s source code.
The only difference is we have the new property ExpireOn, which is used to check if the key needs to be renewed.





PS. The TemporaryRsaKey class is the text format in tempkey.rsa (Development Signing Credential).






How to create a new RSA Key pair as the Signing Credential?

We can use the same logic of IdentityServerBuilderExtensionsCrypto.AddDeveloperSigningCredential() in Crypto.cs from Idsrv4’s source code,

using IdentityServer4;
using IdentityServer4.Configuration;
using IdentityServer4.Models;

var key = CryptoHelper.CreateRsaSecurityKey();
RSAParameters parameters = key.Rsa == null ?
                   parameters = key.Parameters :
                   key.Rsa.ExportParameters(includePrivateParameters: true);

var expireOn = DateTimeOffset.UtcNow.AddYears(1);

var credential = new SigningCredential
{
      Parameters = parameters,
      KeyId = key.KeyId,
      ExpireOn = expireOn
};



Now we can implement the full logics with these steps:

1.  Check Redis’s current Signing Credential (from Redis key: SigningCredential) is valid or expired.s
2.  If current Signing Credential is valid, go to step 4.
3.  If current Signing Credential is missing or expired, move the old one to Redis key: SigningCredentialDeprecated and create a new one.
4.  Register the Signing Credential (from Redis key: SigningCredential) to Idsrv4.
5.  Register the Signing Credentials (from Redis key: SigningCredentialDeprecated) as the validation keys to Idsrv4.


IdentityServerBuilderExtensions.cs

public static IIdentityServerBuilder AddSigningCredentialByRedis(
            this IIdentityServerBuilder builder, AppSettings appSettings)
{
            // Variables
            const int DEFAULT_EXPIRY_YEAR = 1;
            var utcNow = DateTimeOffset.UtcNow;
            var redisKeyWorkingSk = CacheKeyFactory.SigningCredential();
            var redisKeyDeprecatedSk = CacheKeyFactory.SigningCredential(isDeprecated: true);

            // RSA key object
            Microsoft.IdentityModel.Tokens.RsaSecurityKey key = null// The Key for Idsrv

            // Signing credetial object from Redis
            SigningCredential credential = null// The Signing credential stored in Redis
            List<SigningCredential> deprecatedCredentials = null// The Deprecated Signing credentials stored in Redis

            using (ICacheService redis = new RedisService(appSettings))
            {
                bool isSigningCredentialExists = redis.GetCache(redisKeyWorkingSk, out credential);

                if (isSigningCredentialExists && credential.ExpireOn >= utcNow)
                {
                    // Use the RSA key pair stored in redis
                    key = CryptoHelper.CreateRsaSecurityKey(credential.Parameters, credential.KeyId);
                }
                else if (isSigningCredentialExists && credential.ExpireOn < utcNow)
                {
                    #region Move the expired Signing credential to Redis's Decprecated-Signing-Credential key

                    _ = redis.GetCache(redisKeyDeprecatedSk, out deprecatedCredentials);

                    if (deprecatedCredentials == null)
                    {
                        deprecatedCredentials = new List<SigningCredential>();
                    }

                    deprecatedCredentials.Add(credential);

                    redis.SaveCache(redisKeyDeprecatedSk, deprecatedCredentials);
                    #endregion

                    #region Clear the expired Signing credential from Redis's Signing-Credential key

                    redis.ClearCache(redisKeyWorkingSk);
                    #endregion

                    // Set flag as False
                    isSigningCredentialExists = false;
                }

                #region If no available Signing credial, create a new one

                if (!isSigningCredentialExists)
                {
                    key = CryptoHelper.CreateRsaSecurityKey();

                    RSAParameters parameters = key.Rsa == null ?
                        parameters = key.Parameters :
                        key.Rsa.ExportParameters(includePrivateParameters: true);

                    var expireOn = appSettings.Global?.SigningCredential?.DefaultExpiry == null ?
                                    utcNow.AddYears(DEFAULT_EXPIRY_YEAR) :
                                    appSettings.Global.SigningCredential.DefaultExpiry.GetExpireOn();

                    credential = new SigningCredential
                    {
                        Parameters = parameters,
                        KeyId = key.KeyId,
                        ExpireOn = expireOn
                    };

                    // Save to Redis
                    redis.SaveCache(redisKeyWorkingSk, credential);
                }
                #endregion

                // Add the key as the Signing credential for Idsrv
                builder.AddSigningCredential(key, IdentityServerConstants.RsaSigningAlgorithm.RS256);

                // Also add the expired key for clients' old tokens
                if (redis.GetCache(redisKeyDeprecatedSk, out deprecatedCredentials))
                {
                    IList<SecurityKeyInfo> deprecatedKeyInfos = new List<SecurityKeyInfo>();
                    deprecatedCredentials.ForEach(dc =>
                    {
                        var deprecatedKeyInfo = new SecurityKeyInfo
                        {
                            Key = CryptoHelper.CreateRsaSecurityKey(dc.Parameters, dc.KeyId),
                            SigningAlgorithm = SecurityAlgorithms.RsaSha256
                        };
                        deprecatedKeyInfos.Add(deprecatedKeyInfo);
                    });

                    builder.AddValidationKey(deprecatedKeyInfos.ToArray());
                }
            }

            return builder;
}


And don’t forget to update Startup: ConfigureServices!


Startup.cs: ConfigureServices

public void ConfigureServices(IServiceCollection services)
{
       // … skip other codes

var builder = services.AddIdentityServer(options =>
{
            // … set options here
      });

       builder.AddSigningCredentialByRedis(this.appSettings);
}



Demo

After starting the Auth Server, we will find that a new Signing Credential is stored in Redis.





Next time we restart the Auth Server, the expired Signing Credential will be removed and create a new one.





Signing Credential from Files

We will use 2 files to store the keys (Signing credentials):

Redis Key
Path
Description
Content format
SigningCredential.rsa
Keys/
The Signing Credential for issuing and validating JWT
Object in JSON
DeprecatedSigningCredentials.rsa
Keys/
Deprecated Signing Credentials for validating JWT
Array in JSON


The way to generate/store Signing Credential is almost as same as using Redis, except that it cannot renew key automatically.



IdentityServerBuilderExtensions.cs

public static class IdentityServerBuilderExtensions
{
    public static IIdentityServerBuilder AddSigningCredentialsByFile(
            this IIdentityServerBuilder builder, AppSettings appSettings)
    {
            // Variables
            const int DEFAULT_EXPIRY_YEAR = 1;
            const string DIR_NAME_KEYS = "Keys";
            const string FILE_NAME_WORKING_SC = "SigningCredential.rsa";
            const string FILE_NAME_DEPRECATED_SC = "DeprecatedSigningCredentials.rsa";
            var rootDir = Path.Combine(AppContext.BaseDirectory, DIR_NAME_KEYS);
            var workingScDir = Path.Combine(rootDir, FILE_NAME_WORKING_SC);
            var deprecatedScDir = Path.Combine(rootDir, FILE_NAME_DEPRECATED_SC);
            var utcNow = DateTimeOffset.UtcNow;

            // RSA key object
            Microsoft.IdentityModel.Tokens.RsaSecurityKey key = null// The Key for Idsrv

            // Signing credetial object
            SigningCredential credential = null// The Signing credential stored in file
            List<SigningCredential> deprecatedCredentials = null// The Deprecated Signing credentials stored in file

            #region Add exist or new Signing Credentials

            var strWorkingSc = FileUtils.ReadFileAsync(workingScDir).Result;
            var strDeprecatedScs = FileUtils.ReadFileAsync(deprecatedScDir).Result;
            if (!string.IsNullOrEmpty(strWorkingSc))
            {
                // Use the RSA key pair stored in file
                credential = JsonConvert.DeserializeObject<SigningCredential>(strWorkingSc);
                key = CryptoHelper.CreateRsaSecurityKey(credential.Parameters, credential.KeyId);

                // Warning if key expires
                if (credential.ExpireOn < utcNow)
                {
                    logger.Warn($"Auth Server's Signing Credential (ID: {credential.KeyId}) is expired at {credential.ExpireOn.ToLocalTime()}!");
                }
            }
            else
            {
                // Create new RSA key pair
                key = CryptoHelper.CreateRsaSecurityKey();

                RSAParameters parameters = key.Rsa == null ?
                    parameters = key.Parameters :
                    key.Rsa.ExportParameters(includePrivateParameters: true);

                var expireOn = appSettings.Global?.SigningCredential?.DefaultExpiry == null ?
                    utcNow.AddYears(DEFAULT_EXPIRY_YEAR) :
                    appSettings.Global.SigningCredential.DefaultExpiry.GetExpireOn();

                credential = new SigningCredential
                {
                    Parameters = parameters,
                    KeyId = key.KeyId,
                    ExpireOn = expireOn
                };

                // Save to fiile
                FileUtils.SaveFileAsync(workingScDir, JsonConvert.SerializeObject(credential)).Wait();
            }

            // Add the key as the Signing credential for Idsrv
            builder.AddSigningCredential(key, IdentityServerConstants.RsaSigningAlgorithm.RS256);
            #endregion

            #region Add Deprecated Signing Credentials for clients' old tokens

            deprecatedCredentials = string.IsNullOrEmpty(strDeprecatedScs) ? new List<SigningCredential>() : JsonConvert.DeserializeObject<List<SigningCredential>>(strDeprecatedScs);

            IList<SecurityKeyInfo> deprecatedKeyInfos = new List<SecurityKeyInfo>();
            deprecatedCredentials.ForEach(dc =>
            {
                var deprecatedKeyInfo = new SecurityKeyInfo
                {
                    Key = CryptoHelper.CreateRsaSecurityKey(dc.Parameters, dc.KeyId),
                    SigningAlgorithm = SecurityAlgorithms.RsaSha256
                };
                deprecatedKeyInfos.Add(deprecatedKeyInfo);
            });

            builder.AddValidationKey(deprecatedKeyInfos.ToArray());

            #endregion

            return builder;
    }
}



Startup.cs: ConfigureServices

public void ConfigureServices(IServiceCollection services)
{
       // … skip other codes

var builder = services.AddIdentityServer(options =>
{
            // … set options here
      });

       builder.AddSigningCredentialsByFile(this.appSettings);
}



Demo


After starting the Auth Server, we will find that a new Signing Credential is stored in Keys/SigningCredential.rsa.




We can put the expired Signing Credential(s) as the validation key(s) as following.






Source Code




Reference








沒有留言:

張貼留言