From 4f2288c3dac356cf9e4ba2beb456a8d6824a19af Mon Sep 17 00:00:00 2001 From: Levi B Date: Wed, 11 Mar 2015 18:56:46 -0700 Subject: [PATCH] Introduce concept of a "fallback key" This key is used if there is no preferred default key and the developer has disabled automatic key generation. This will keep the service from falling over if the keys are not rolled and they all expire. --- .../KeyManagement/DefaultKeyResolution.cs | 7 ++++ .../KeyManagement/DefaultKeyResolver.cs | 17 +++++++- .../KeyManagement/KeyRingProvider.cs | 39 ++++++++++++------- .../KeyManagement/DefaultKeyResolverTests.cs | 38 +++++++++++++++++- .../KeyManagement/KeyRingProviderTests.cs | 38 ++++++++++++++++++ 5 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolution.cs b/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolution.cs index 63f035b057..42cc7c2741 100644 --- a/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolution.cs +++ b/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolution.cs @@ -12,6 +12,13 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement /// public IKey DefaultKey; + /// + /// The fallback key, which should be used only if the caller is configured not to + /// honor the property. This property may + /// be null if there is no viable fallback key. + /// + public IKey FallbackKey; + /// /// 'true' if a new key should be persisted to the keyring, 'false' otherwise. /// This value may be 'true' even if a valid default key was found. diff --git a/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolver.cs b/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolver.cs index 66beedb862..34c64e134e 100644 --- a/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolver.cs +++ b/src/Microsoft.AspNet.DataProtection/KeyManagement/DefaultKeyResolver.cs @@ -46,11 +46,11 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement public DefaultKeyResolution ResolveDefaultKeyPolicy(DateTimeOffset now, IEnumerable allKeys) { DefaultKeyResolution retVal = default(DefaultKeyResolution); - retVal.DefaultKey = FindDefaultKey(now, allKeys, out retVal.ShouldGenerateNewKey); + retVal.DefaultKey = FindDefaultKey(now, allKeys, out retVal.FallbackKey, out retVal.ShouldGenerateNewKey); return retVal; } - private IKey FindDefaultKey(DateTimeOffset now, IEnumerable allKeys, out bool callerShouldGenerateNewKey) + private IKey FindDefaultKey(DateTimeOffset now, IEnumerable allKeys, out IKey fallbackKey, out bool callerShouldGenerateNewKey) { // find the preferred default key (allowing for server-to-server clock skew) var preferredDefaultKey = (from key in allKeys @@ -97,10 +97,23 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement _logger.LogVerbose("Default key expiration imminent and repository contains no viable successor. Caller should generate a successor."); } + 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. + fallbackKey = (from key in (from key in allKeys + where key.CreationDate <= now - _keyPropagationWindow + orderby key.CreationDate descending + select key).Concat(from key in allKeys + orderby key.CreationDate ascending + select key) + where !key.IsRevoked + select key).FirstOrDefault(); if (_logger.IsVerboseLevelEnabled()) { diff --git a/src/Microsoft.AspNet.DataProtection/KeyManagement/KeyRingProvider.cs b/src/Microsoft.AspNet.DataProtection/KeyManagement/KeyRingProvider.cs index 475ffda929..7aafa50a03 100644 --- a/src/Microsoft.AspNet.DataProtection/KeyManagement/KeyRingProvider.cs +++ b/src/Microsoft.AspNet.DataProtection/KeyManagement/KeyRingProvider.cs @@ -65,10 +65,12 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement throw CryptoUtil.Fail("Policy resolution states that a new key should be added to the key ring, even after a call to CreateNewKey."); } - if (defaultKeyPolicy.DefaultKey == null) + // We have been asked to generate a new key, but auto-generation of keys has been disabled. + // We need to use the fallback key or fail. + if (!_keyManagementOptions.AutoGenerateKeys) { - // We cannot continue if we have no default key and auto-generation of keys is disabled. - if (!_keyManagementOptions.AutoGenerateKeys) + var keyToUse = defaultKeyPolicy.DefaultKey ?? defaultKeyPolicy.FallbackKey; + if (keyToUse == null) { if (_logger.IsErrorLevelEnabled()) { @@ -76,7 +78,18 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement } throw new InvalidOperationException(Resources.KeyRingProvider_NoDefaultKey_AutoGenerateDisabled); } + else + { + if (_logger.IsWarningLevelEnabled()) + { + _logger.LogWarning("Policy resolution states that a new key should be added to the key ring, but automatic generation of keys is disabled. Using fallback key '{0:D}' with expiration {1:u} as default key.", keyToUse.KeyId, keyToUse.ExpirationDate); + } + return CreateCacheableKeyRingCoreStep2(now, cacheExpirationToken, keyToUse, allKeys); + } + } + if (defaultKeyPolicy.DefaultKey == null) + { // The case where there's no default key is the easiest scenario, since it // means that we need to create a new key with immediate activation. _keyManager.CreateNewKey(activationDate: now, expirationDate: now + _keyManagementOptions.NewKeyLifetime); @@ -84,16 +97,6 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement } else { - // If auto-generation of keys is disabled, we cannot call CreateNewKey. - if (!_keyManagementOptions.AutoGenerateKeys) - { - if (_logger.IsWarningLevelEnabled()) - { - _logger.LogWarning("Policy resolution states that a new key should be added to the key ring, but automatic generation of keys is disabled."); - } - return CreateCacheableKeyRingCoreStep2(now, cacheExpirationToken, defaultKeyPolicy.DefaultKey, allKeys); - } - // If there is a default key, then the new key we generate should become active upon // expiration of the default key. The new key lifetime is measured from the creation // date (now), not the activation date. @@ -104,19 +107,25 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement private CacheableKeyRing CreateCacheableKeyRingCoreStep2(DateTimeOffset now, CancellationToken cacheExpirationToken, IKey defaultKey, IEnumerable allKeys) { + Debug.Assert(defaultKey != null); + if (_logger.IsVerboseLevelEnabled()) { _logger.LogVerbose("Using key '{0:D}' as the default key.", defaultKey.KeyId); } + DateTimeOffset nextAutoRefreshTime = now + GetRefreshPeriodWithJitter(_keyManagementOptions.KeyRingRefreshPeriod); + // The cached keyring should expire at the earliest of (default key expiration, next auto-refresh time). // Since the refresh period and safety window are not user-settable, we can guarantee that there's at // least one auto-refresh between the start of the safety window and the key's expiration date. // This gives us an opportunity to update the key ring before expiration, and it prevents multiple - // servers in a cluster from trying to update the key ring simultaneously. + // servers in a cluster from trying to update the key ring simultaneously. Special case: if the default + // key's expiration date is in the past, then we know we're using a fallback key and should disregard + // its expiration date in favor of the next auto-refresh time. return new CacheableKeyRing( expirationToken: cacheExpirationToken, - expirationTime: Min(defaultKey.ExpirationDate, now + GetRefreshPeriodWithJitter(_keyManagementOptions.KeyRingRefreshPeriod)), + expirationTime: (defaultKey.ExpirationDate <= now) ? nextAutoRefreshTime : Min(defaultKey.ExpirationDate, nextAutoRefreshTime), defaultKey: defaultKey, allKeys: allKeys); } diff --git a/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/DefaultKeyResolverTests.cs b/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/DefaultKeyResolverTests.cs index 3eed6faa19..9f755c2816 100644 --- a/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/DefaultKeyResolverTests.cs +++ b/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/DefaultKeyResolverTests.cs @@ -167,6 +167,41 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement Assert.False(resolution.ShouldGenerateNewKey); } + [Fact] + public void ResolveDefaultKeyPolicy_FallbackKey_SelectsLatestBeforePriorPropagationWindow() + { + // Arrange + var resolver = CreateDefaultKeyResolver(); + var key1 = CreateKey("2010-01-01 00:00:00Z", "2010-01-01 00:00:00Z", creationDate: "2000-01-01 00:00:00Z"); + var key2 = CreateKey("2010-01-01 00:00:00Z", "2010-01-01 00:00:00Z", creationDate: "2000-01-02 00:00:00Z"); + var key3 = CreateKey("2010-01-01 00:00:00Z", "2010-01-01 00:00:00Z", creationDate: "2000-01-03 00:00:00Z", isRevoked: true); + var key4 = CreateKey("2010-01-01 00:00:00Z", "2010-01-01 00:00:00Z", creationDate: "2000-01-04 00:00:00Z"); + + // Act + var resolution = resolver.ResolveDefaultKeyPolicy("2000-01-05 00:00:00Z", key1, key2, key3, key4); + + // Assert + Assert.Same(key2, resolution.FallbackKey); + Assert.True(resolution.ShouldGenerateNewKey); + } + + [Fact] + public void ResolveDefaultKeyPolicy_FallbackKey_NoNonRevokedKeysBeforePriorPropagationWindow_SelectsEarliestNonRevokedKey() + { + // Arrange + var resolver = CreateDefaultKeyResolver(); + var key1 = CreateKey("2010-01-01 00:00:00Z", "2010-01-01 00:00:00Z", creationDate: "2000-01-03 00:00:00Z", isRevoked: true); + var key2 = CreateKey("2010-01-01 00:00:00Z", "2010-01-01 00:00:00Z", creationDate: "2000-01-04 00:00:00Z"); + var key3 = CreateKey("2010-01-01 00:00:00Z", "2010-01-01 00:00:00Z", creationDate: "2000-01-05 00:00:00Z"); + + // Act + var resolution = resolver.ResolveDefaultKeyPolicy("2000-01-05 00:00:00Z", key1, key2, key3); + + // Assert + Assert.Same(key2, resolution.FallbackKey); + Assert.True(resolution.ShouldGenerateNewKey); + } + private static IDefaultKeyResolver CreateDefaultKeyResolver() { return new DefaultKeyResolver( @@ -175,10 +210,11 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement services: null); } - private static IKey CreateKey(string activationDate, string expirationDate, bool isRevoked = false) + private static IKey CreateKey(string activationDate, string expirationDate, string creationDate = null, bool isRevoked = false) { var mockKey = new Mock(); mockKey.Setup(o => o.KeyId).Returns(Guid.NewGuid()); + mockKey.Setup(o => o.CreationDate).Returns((creationDate != null) ? DateTimeOffset.ParseExact(creationDate, "u", CultureInfo.InvariantCulture) : DateTimeOffset.MinValue); mockKey.Setup(o => o.ActivationDate).Returns(DateTimeOffset.ParseExact(activationDate, "u", CultureInfo.InvariantCulture)); mockKey.Setup(o => o.ExpirationDate).Returns(DateTimeOffset.ParseExact(expirationDate, "u", CultureInfo.InvariantCulture)); mockKey.Setup(o => o.IsRevoked).Returns(isRevoked); diff --git a/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/KeyRingProviderTests.cs b/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/KeyRingProviderTests.cs index 6bb7ab2c0c..ee896e917f 100644 --- a/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/KeyRingProviderTests.cs +++ b/test/Microsoft.AspNet.DataProtection.Test/KeyManagement/KeyRingProviderTests.cs @@ -262,6 +262,44 @@ namespace Microsoft.AspNet.DataProtection.KeyManagement Assert.Equal(new[] { "GetCacheExpirationToken", "GetAllKeys", "ResolveDefaultKeyPolicy" }, callSequence); } + [Fact] + public void CreateCacheableKeyRing_GenerationRequired_WithFallbackKey_KeyGenerationDisabled_DoesNotCreateDefaultKey() + { + // Arrange + var callSequence = new List(); + var expirationCts = new CancellationTokenSource(); + + var now = StringToDateTime("2016-02-01 00:00:00Z"); + var key1 = CreateKey("2015-03-01 00:00:00Z", "2015-03-01 00:00:00Z"); + var allKeys = new[] { key1 }; + + var keyRingProvider = SetupCreateCacheableKeyRingTestAndCreateKeyManager( + callSequence: callSequence, + getCacheExpirationTokenReturnValues: new[] { expirationCts.Token }, + getAllKeysReturnValues: new[] { allKeys }, + createNewKeyCallbacks: null, // empty + resolveDefaultKeyPolicyReturnValues: new[] + { + Tuple.Create((DateTimeOffset)now, (IEnumerable)allKeys, new DefaultKeyResolution() + { + FallbackKey = key1, + ShouldGenerateNewKey = true + }) + }, + keyManagementOptions: new KeyManagementOptions() { AutoGenerateKeys = false }); + + // Act + var cacheableKeyRing = keyRingProvider.GetCacheableKeyRing(now); + + // Assert + Assert.Equal(key1.KeyId, cacheableKeyRing.KeyRing.DefaultKeyId); + AssertWithinJitterRange(cacheableKeyRing.ExpirationTimeUtc, now); + Assert.True(CacheableKeyRing.IsValid(cacheableKeyRing, now)); + expirationCts.Cancel(); + Assert.False(CacheableKeyRing.IsValid(cacheableKeyRing, now)); + Assert.Equal(new[] { "GetCacheExpirationToken", "GetAllKeys", "ResolveDefaultKeyPolicy" }, callSequence); + } + private static ICacheableKeyRingProvider SetupCreateCacheableKeyRingTestAndCreateKeyManager( IList callSequence, IEnumerable getCacheExpirationTokenReturnValues,