File: KeyManagement\DefaultKeyResolver.cs
Web Access
Project: src\src\DataProtection\DataProtection\src\Microsoft.AspNetCore.DataProtection.csproj (Microsoft.AspNetCore.DataProtection)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Cryptography;
using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption;
using Microsoft.AspNetCore.DataProtection.KeyManagement.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
 
namespace Microsoft.AspNetCore.DataProtection.KeyManagement;
 
/// <summary>
/// Implements policy for resolving the default key from a candidate keyring.
/// </summary>
internal sealed class DefaultKeyResolver : IDefaultKeyResolver
{
#if NETFRAMEWORK
    // Sadly, numerical AppContext data support was not added until 4.7 and we target 4.6
    private const string MaximumTotalDefaultKeyResolverRetriesSwitchKey = "Microsoft.AspNetCore.DataProtection.KeyManagement.DisableDefaultKeyResolverRetries";
#else
    private const string MaximumTotalDefaultKeyResolverRetriesDataKey = "Microsoft.AspNetCore.DataProtection.KeyManagement.MaximumTotalDefaultKeyResolverRetries";
#endif
 
    /// <summary>
    /// The window of time before the key expires when a new key should be created
    /// and persisted to the keyring to ensure uninterrupted service.
    /// </summary>
    /// <remarks>
    /// If the propagation time is 5 days and the current key expires within 5 days,
    /// a new key will be generated.
    /// </remarks>
    private readonly TimeSpan _keyPropagationWindow;
 
    private readonly int _maxDecryptRetries;
    private readonly TimeSpan _decryptRetryDelay;
 
    private readonly ILogger _logger;
 
    /// <summary>
    /// The maximum skew that is allowed between servers.
    /// This is used to allow newly-created keys to be used across servers even though
    /// their activation dates might be a few minutes into the future.
    /// </summary>
    /// <remarks>
    /// If the max skew is 5 minutes and the best matching candidate default key has
    /// an activation date of less than 5 minutes in the future, we'll use it.
    /// </remarks>
    private readonly TimeSpan _maxServerToServerClockSkew;
 
    public DefaultKeyResolver(IOptions<KeyManagementOptions> keyManagementOptions)
        : this(keyManagementOptions, NullLoggerFactory.Instance)
    { }
 
    public DefaultKeyResolver(IOptions<KeyManagementOptions> keyManagementOptions, ILoggerFactory loggerFactory)
    {
        _keyPropagationWindow = KeyManagementOptions.KeyPropagationWindow;
        _maxServerToServerClockSkew = KeyManagementOptions.MaxServerClockSkew;
        _maxDecryptRetries = GetMaxDecryptRetriesFromAppContext() ?? keyManagementOptions.Value.MaximumTotalDefaultKeyResolverRetries;
        _decryptRetryDelay = keyManagementOptions.Value.DefaultKeyResolverRetryDelay;
        _logger = loggerFactory.CreateLogger<DefaultKeyResolver>();
    }
 
    private static int? GetMaxDecryptRetriesFromAppContext()
    {
#if NETFRAMEWORK
        // Force to zero if retries are disabled.  Otherwise, use the configured value
        return AppContext.TryGetSwitch(MaximumTotalDefaultKeyResolverRetriesSwitchKey, out var areRetriesDisabled) && areRetriesDisabled ? 0 : null;
#else
        var data = AppContext.GetData(MaximumTotalDefaultKeyResolverRetriesDataKey);
 
        // Programmatically-configured values are usually ints
        if (data is int count)
        {
            return count;
        }
 
        // msbuild-configured values are usually strings
        if (data is string countStr && int.TryParse(countStr, out var parsed))
        {
            return parsed;
        }
 
        return null;
#endif
    }
 
    private bool CanCreateAuthenticatedEncryptor(IKey key, ref int retriesRemaining)
    {
        List<Exception>? exceptions = null;
        while (true)
        {
            try
            {
                var encryptorInstance = key.CreateEncryptor();
                if (encryptorInstance is null)
                {
                    // This generally indicates a key (descriptor) without a corresponding IAuthenticatedEncryptorFactory,
                    // which is not something that can succeed on retry - return immediately
                    _logger.KeyIsIneligibleToBeTheDefaultKeyBecauseItsMethodFailed(
                        key.KeyId,
                        nameof(IKey.CreateEncryptor),
                        new InvalidOperationException($"{nameof(IKey.CreateEncryptor)} returned null."));
                    return false;
                }
 
                return true;
            }
            catch (Exception ex)
            {
                if (retriesRemaining > 0)
                {
                    if (exceptions is null)
                    {
                        // The first failure doesn't count as a retry
                        exceptions = [];
                    }
                    else
                    {
                        retriesRemaining--;
                    }
 
                    exceptions.Add(ex);
                }
 
                if (retriesRemaining <= 0) // Guard against infinite loops from programmer errors
                {
                    _logger.KeyIsIneligibleToBeTheDefaultKeyBecauseItsMethodFailed(key.KeyId, nameof(IKey.CreateEncryptor), exceptions is null ? ex : new AggregateException(exceptions));
                    return false;
                }
 
                _logger.RetryingMethodOfKeyAfterFailure(key.KeyId, nameof(IKey.CreateEncryptor), ex);
            }
 
            // Reset the descriptor to allow for a retry
            (key as Key)?.ResetDescriptor();
 
            // Don't retry immediately - allow a little time for the transient problem to clear
            Thread.Sleep(_decryptRetryDelay);
        }
    }
 
    private IKey? FindDefaultKey(DateTimeOffset now, IEnumerable<IKey> allKeys, out IKey? fallbackKey)
    {
        // find the preferred default key (allowing for server-to-server clock skew)
 
        // Note: another approach would be to prefer the *least-recently* activated key if none
        // are old enough to have been propagated.  We tried that and reverted it because it
        // made us less resilient against bad keys.  This way, we'll reject a bad key and generate
        // a replacement and then the next instance to run will pick up the replacement, rather
        // than the bad key.  It did have the conceptual advantage of being more similar to the
        // fallback code below and the hypothetical advantage of making it easier for instances
        // to choose the same key in the event of a race (though we never managed to show that
        // empirically.  See also https://github.com/dotnet/aspnetcore/issues/57137.
        var preferredDefaultKey = (from key in allKeys
                                   where key.ActivationDate <= now + _maxServerToServerClockSkew
                                   orderby key.ActivationDate descending, key.KeyId ascending
                                   select key).FirstOrDefault();
 
        var decryptRetriesRemaining = _maxDecryptRetries;
 
        if (preferredDefaultKey != null)
        {
            _logger.ConsideringKeyWithExpirationDateAsDefaultKey(preferredDefaultKey.KeyId, preferredDefaultKey.ExpirationDate);
 
            // if the key has been revoked or is expired, it is no longer a candidate
            if (preferredDefaultKey.IsRevoked || preferredDefaultKey.IsExpired(now) || !CanCreateAuthenticatedEncryptor(preferredDefaultKey, ref decryptRetriesRemaining))
            {
                _logger.KeyIsNoLongerUnderConsiderationAsDefault(preferredDefaultKey.KeyId);
            }
            else
            {
                fallbackKey = null;
                return preferredDefaultKey;
            }
        }
 
        // If we got this far, the caller must generate a key now.
        // We should locate a fallback key, which is a key that can be used to protect payloads if
        // the caller is configured not to generate a new key. We should try to make sure the fallback
        // key has propagated to all callers (so its creation date should be before the previous
        // propagation period), and we cannot use revoked keys. The fallback key may be expired.
 
        // Note that the two sort orders are opposite: we want the *newest* key that's old enough
        // (to have been propagated) or the *oldest* key that's too new.
 
        // Unlike for the preferred key, we don't choose a fallback key and then reject it if
        // CanCreateAuthenticatedEncryptor is false.  We want to end up with *some* key, so we
        // keep trying until we find one that works.
        var unrevokedKeys = allKeys.Where(key => !key.IsRevoked);
        fallbackKey = (from key in (from key in unrevokedKeys
                                    where !ReferenceEquals(key, preferredDefaultKey) // Don't reconsider it as a fallback
                                    where key.CreationDate <= now - _keyPropagationWindow
                                    orderby key.CreationDate descending
                                    select key).Concat(from key in unrevokedKeys
                                                       where key.CreationDate > now - _keyPropagationWindow
                                                       orderby key.CreationDate ascending
                                                       select key)
                       where CanCreateAuthenticatedEncryptor(key, ref decryptRetriesRemaining)
                       select key).FirstOrDefault();
 
        _logger.RepositoryContainsNoViableDefaultKey();
 
        return null;
    }
 
    public DefaultKeyResolution ResolveDefaultKeyPolicy(DateTimeOffset now, IEnumerable<IKey> allKeys)
    {
        var retVal = default(DefaultKeyResolution);
        var defaultKey = FindDefaultKey(now, allKeys, out retVal.FallbackKey);
        retVal.DefaultKey = defaultKey;
        retVal.ShouldGenerateNewKey = defaultKey is null;
        return retVal;
    }
}