Skip to content

Commit ae6ab1a

Browse files
committed
Allow retries in DefaultKeyResolver.CanCreateAuthenticatedEncryptor
This code is trying to ensure that the selected key can be decrypted (i.e. is usable). It may fail if, for example, Azure KeyVault is unreachable due to connectivity issues. If it fails, there's a log message and then an immediately-activated key will be generated. An immediately-activated key can cause problems for sessions making requests to multiple app instances and those problems won't obviously be connected to the (almost silent) failure in CanCreateAuthenticatedEncryptor. Rather than effectively swallowing such errors, we should allow some retries. Part of dotnet#52678, which is part of dotnet#36157
1 parent 39e429f commit ae6ab1a

File tree

4 files changed

+189
-18
lines changed

4 files changed

+189
-18
lines changed

src/DataProtection/DataProtection/src/KeyManagement/DefaultKeyResolver.cs

+56-16
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Diagnostics;
67
using System.Linq;
8+
using System.Threading;
79
using Microsoft.AspNetCore.Cryptography;
810
using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption;
911
using Microsoft.AspNetCore.DataProtection.KeyManagement.Internal;
@@ -28,6 +30,9 @@ internal sealed class DefaultKeyResolver : IDefaultKeyResolver
2830
/// </remarks>
2931
private readonly TimeSpan _keyPropagationWindow;
3032

33+
private readonly int _maxDecryptRetries;
34+
private readonly TimeSpan _decryptRetryDelay;
35+
3136
private readonly ILogger _logger;
3237

3338
/// <summary>
@@ -41,33 +46,65 @@ internal sealed class DefaultKeyResolver : IDefaultKeyResolver
4146
/// </remarks>
4247
private readonly TimeSpan _maxServerToServerClockSkew;
4348

44-
public DefaultKeyResolver()
45-
: this(NullLoggerFactory.Instance)
49+
public DefaultKeyResolver(IOptions<KeyManagementOptions> keyManagementOptions)
50+
: this(keyManagementOptions, NullLoggerFactory.Instance)
4651
{ }
4752

48-
public DefaultKeyResolver(ILoggerFactory loggerFactory)
53+
public DefaultKeyResolver(IOptions<KeyManagementOptions> keyManagementOptions, ILoggerFactory loggerFactory)
4954
{
5055
_keyPropagationWindow = KeyManagementOptions.KeyPropagationWindow;
5156
_maxServerToServerClockSkew = KeyManagementOptions.MaxServerClockSkew;
57+
_maxDecryptRetries = keyManagementOptions.Value.MaximumDefaultKeyResolverRetries;
58+
_decryptRetryDelay = keyManagementOptions.Value.DefaultKeyResolverRetryDelay;
5259
_logger = loggerFactory.CreateLogger<DefaultKeyResolver>();
5360
}
5461

55-
private bool CanCreateAuthenticatedEncryptor(IKey key)
62+
private bool CanCreateAuthenticatedEncryptor(IKey key, ref int retriesRemaining)
5663
{
57-
try
64+
List<Exception>? exceptions = null;
65+
while (true)
5866
{
59-
var encryptorInstance = key.CreateEncryptor();
60-
if (encryptorInstance == null)
67+
try
68+
{
69+
var encryptorInstance = key.CreateEncryptor();
70+
if (encryptorInstance is null)
71+
{
72+
// This generally indicates a key (descriptor) without a corresponding IAuthenticatedEncryptorFactory,
73+
// which is not something that can succeed on retry - return immediately
74+
_logger.KeyIsIneligibleToBeTheDefaultKeyBecauseItsMethodFailed(
75+
key.KeyId,
76+
nameof(IKey.CreateEncryptor),
77+
new InvalidOperationException($"{nameof(IKey.CreateEncryptor)} returned null."));
78+
return false;
79+
}
80+
81+
return true;
82+
}
83+
catch (Exception ex)
6184
{
62-
CryptoUtil.Fail<IAuthenticatedEncryptor>("CreateEncryptorInstance returned null.");
85+
if (exceptions is null)
86+
{
87+
// The first failure doesn't count as a retry
88+
exceptions = new();
89+
}
90+
else
91+
{
92+
retriesRemaining--;
93+
}
94+
95+
exceptions.Add(ex);
96+
97+
if (retriesRemaining == 0)
98+
{
99+
_logger.KeyIsIneligibleToBeTheDefaultKeyBecauseItsMethodFailed(key.KeyId, nameof(IKey.CreateEncryptor), new AggregateException(exceptions));
100+
return false;
101+
}
102+
103+
_logger.RetryingMethodOfKeyAfterFailure(key.KeyId, nameof(IKey.CreateEncryptor), ex);
63104
}
64105

65-
return true;
66-
}
67-
catch (Exception ex)
68-
{
69-
_logger.KeyIsIneligibleToBeTheDefaultKeyBecauseItsMethodFailed(key.KeyId, nameof(IKey.CreateEncryptor), ex);
70-
return false;
106+
// Don't retry immediately
107+
Thread.Sleep(_decryptRetryDelay);
71108
}
72109
}
73110

@@ -94,12 +131,14 @@ where key.CreationDate > propagationCutoff
94131
orderby key.ActivationDate ascending, key.KeyId ascending
95132
select key).FirstOrDefault();
96133

134+
var decryptRetriesRemaining = _maxDecryptRetries;
135+
97136
if (preferredDefaultKey != null)
98137
{
99138
_logger.ConsideringKeyWithExpirationDateAsDefaultKey(preferredDefaultKey.KeyId, preferredDefaultKey.ExpirationDate);
100139

101140
// if the key has been revoked or is expired, it is no longer a candidate
102-
if (preferredDefaultKey.IsRevoked || preferredDefaultKey.IsExpired(now) || !CanCreateAuthenticatedEncryptor(preferredDefaultKey))
141+
if (preferredDefaultKey.IsRevoked || preferredDefaultKey.IsExpired(now) || !CanCreateAuthenticatedEncryptor(preferredDefaultKey, ref decryptRetriesRemaining))
103142
{
104143
_logger.KeyIsNoLongerUnderConsiderationAsDefault(preferredDefaultKey.KeyId);
105144
}
@@ -123,13 +162,14 @@ where key.CreationDate > propagationCutoff
123162
// keep trying until we find one that works.
124163
var unrevokedKeys = allKeys.Where(key => !key.IsRevoked);
125164
fallbackKey = (from key in (from key in unrevokedKeys
165+
where !ReferenceEquals(key, preferredDefaultKey) // Don't reconsider it as a fallback
126166
where key.CreationDate <= propagationCutoff
127167
orderby key.CreationDate descending
128168
select key).Concat(from key in unrevokedKeys
129169
where key.CreationDate > propagationCutoff
130170
orderby key.CreationDate ascending
131171
select key)
132-
where CanCreateAuthenticatedEncryptor(key)
172+
where CanCreateAuthenticatedEncryptor(key, ref decryptRetriesRemaining)
133173
select key).FirstOrDefault();
134174

135175
_logger.RepositoryContainsNoViableDefaultKey();

src/DataProtection/DataProtection/src/KeyManagement/KeyManagementOptions.cs

+19
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,25 @@ internal static TimeSpan MaxServerClockSkew
9494
}
9595
}
9696

97+
/// <summary>
98+
/// During each default key resolution, if a key decryption attempt fails,
99+
/// it can be retried, as long as the total number of retries across all keys
100+
/// does not exceed this value.
101+
/// </summary>
102+
/// <remarks>
103+
/// Settable for testing.
104+
/// </remarks>
105+
internal int MaximumDefaultKeyResolverRetries { get; set; } = 10;
106+
107+
/// <summary>
108+
/// Wait this long before each default key resolution decryption retry.
109+
/// <seealso cref="MaximumDefaultKeyResolverRetries"/>
110+
/// </summary>
111+
/// <remarks>
112+
/// Settable for testing.
113+
/// </remarks>
114+
internal TimeSpan DefaultKeyResolverRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
115+
97116
/// <summary>
98117
/// Controls the lifetime (number of days before expiration)
99118
/// for newly-generated keys.

src/DataProtection/DataProtection/src/LoggingExtensions.cs

+3
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,7 @@ private static bool IsLogLevelEnabledCore([NotNullWhen(true)] ILogger? logger, L
252252

253253
[LoggerMessage(72, LogLevel.Error, "The key ring's default data protection key {KeyId:B} has been revoked.", EventName = "KeyRingDefaultKeyIsRevoked")]
254254
public static partial void KeyRingDefaultKeyIsRevoked(this ILogger logger, Guid keyId);
255+
256+
[LoggerMessage(73, LogLevel.Debug, "Key {KeyId:B} method {MethodName} failed. Retrying.", EventName = "RetryingMethodOfKeyAfterFailure")]
257+
public static partial void RetryingMethodOfKeyAfterFailure(this ILogger logger, Guid keyId, string methodName, Exception exception);
255258
}

src/DataProtection/DataProtection/test/Microsoft.AspNetCore.DataProtection.Tests/KeyManagement/DefaultKeyResolverTests.cs

+111-2
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,113 @@ public void ResolveDefaultKeyPolicy_OlderUnpropagatedKeyPreferred()
291291
Assert.False(resolution.ShouldGenerateNewKey);
292292
}
293293

294+
[Fact]
295+
public void CreateEncryptor_NoRetryOnNullReturn()
296+
{
297+
// Arrange
298+
var resolver = CreateDefaultKeyResolver();
299+
300+
var now = ParseDateTimeOffset("2010-01-01 00:00:00Z");
301+
302+
var mockKey = new Mock<IKey>();
303+
mockKey.Setup(o => o.KeyId).Returns(Guid.NewGuid());
304+
mockKey.Setup(o => o.CreationDate).Returns(now.AddDays(-3)); // Propagated
305+
mockKey.Setup(o => o.ActivationDate).Returns(now.AddDays(-1)); // Activated
306+
mockKey.Setup(o => o.ExpirationDate).Returns(now.AddDays(14)); // Unexpired
307+
mockKey.Setup(o => o.IsRevoked).Returns(false); // Unrevoked
308+
mockKey.Setup(o => o.CreateEncryptor()).Returns((IAuthenticatedEncryptor)null); // Anomalous null return
309+
310+
// Act
311+
var resolution = resolver.ResolveDefaultKeyPolicy(now, [mockKey.Object]);
312+
313+
// Assert
314+
Assert.Null(resolution.DefaultKey);
315+
Assert.Null(resolution.FallbackKey);
316+
Assert.True(resolution.ShouldGenerateNewKey);
317+
318+
mockKey.Verify(o => o.CreateEncryptor(), Times.Once); // Not retried
319+
}
320+
321+
[Fact]
322+
public void CreateEncryptor_FirstAttemptIsNotARetry()
323+
{
324+
// Arrange
325+
var options = Options.Create(new KeyManagementOptions()
326+
{
327+
MaximumDefaultKeyResolverRetries = 3,
328+
DefaultKeyResolverRetryDelay = TimeSpan.Zero,
329+
});
330+
331+
var resolver = new DefaultKeyResolver(options, NullLoggerFactory.Instance);
332+
333+
var now = ParseDateTimeOffset("2010-01-01 00:00:00Z");
334+
335+
var creation1 = now.AddDays(-3);
336+
var activation1 = creation1.AddDays(2);
337+
var expiration1 = creation1.AddDays(90);
338+
339+
// Newer but still propagated => preferred
340+
var creation2 = creation1.AddHours(1);
341+
var activation2 = activation1.AddHours(1);
342+
var expiration2 = expiration1.AddHours(1);
343+
344+
var mockKey1 = CreateMockKey(activation1, expiration1, creation1, isRevoked: false, createEncryptorThrows: false);
345+
var mockKey2 = CreateMockKey(activation2, expiration2, creation2, isRevoked: false, createEncryptorThrows: true); // Uses up all the retries
346+
347+
// Act
348+
var resolution = resolver.ResolveDefaultKeyPolicy(now, [mockKey1.Object, mockKey2.Object]);
349+
350+
// Assert
351+
Assert.Null(resolution.DefaultKey);
352+
Assert.Same(mockKey1.Object, resolution.FallbackKey);
353+
Assert.True(resolution.ShouldGenerateNewKey);
354+
355+
mockKey1.Verify(o => o.CreateEncryptor(), Times.Once); // 1 try
356+
mockKey2.Verify(o => o.CreateEncryptor(), Times.Exactly(4)); // 1 try plus max (3) retries
357+
}
358+
359+
[Fact]
360+
public void CreateEncryptor_SucceedsOnRetry()
361+
{
362+
// Arrange
363+
var options = Options.Create(new KeyManagementOptions()
364+
{
365+
MaximumDefaultKeyResolverRetries = 3,
366+
DefaultKeyResolverRetryDelay = TimeSpan.Zero,
367+
});
368+
369+
var resolver = new DefaultKeyResolver(options, NullLoggerFactory.Instance);
370+
371+
var now = ParseDateTimeOffset("2010-01-01 00:00:00Z");
372+
373+
var creation = now.AddDays(-3);
374+
var activation = creation.AddDays(2);
375+
var expiration = creation.AddDays(90);
376+
377+
var mockKey = CreateMockKey(activation, expiration, creation);
378+
mockKey
379+
.Setup(o => o.CreateEncryptor())
380+
.Returns(() =>
381+
{
382+
// Added to list before the callback is called
383+
if (mockKey.Invocations.Count(i => i.Method.Name == nameof(IKey.CreateEncryptor)) == 1)
384+
{
385+
throw new Exception("This method fails.");
386+
}
387+
return new Mock<IAuthenticatedEncryptor>().Object;
388+
});
389+
390+
// Act
391+
var resolution = resolver.ResolveDefaultKeyPolicy(now, [mockKey.Object]);
392+
393+
// Assert
394+
Assert.Same(mockKey.Object, resolution.DefaultKey);
395+
mockKey.Verify(o => o.CreateEncryptor(), Times.Exactly(2)); // 1 try plus 1 retry
396+
}
397+
294398
private static IDefaultKeyResolver CreateDefaultKeyResolver()
295399
{
296-
return new DefaultKeyResolver(NullLoggerFactory.Instance);
400+
return new DefaultKeyResolver(Options.Create(new KeyManagementOptions()), NullLoggerFactory.Instance);
297401
}
298402

299403
private static IKey CreateKey(string activationDate, string expirationDate, string creationDate = null, bool isRevoked = false, bool createEncryptorThrows = false)
@@ -302,6 +406,11 @@ private static IKey CreateKey(string activationDate, string expirationDate, stri
302406
}
303407

304408
private static IKey CreateKey(DateTimeOffset activationDate, DateTimeOffset expirationDate, DateTimeOffset? creationDate = null, bool isRevoked = false, bool createEncryptorThrows = false)
409+
{
410+
return CreateMockKey(activationDate, expirationDate, creationDate, isRevoked, createEncryptorThrows).Object;
411+
}
412+
413+
private static Mock<IKey> CreateMockKey(DateTimeOffset activationDate, DateTimeOffset expirationDate, DateTimeOffset? creationDate = null, bool isRevoked = false, bool createEncryptorThrows = false)
305414
{
306415
var mockKey = new Mock<IKey>();
307416
mockKey.Setup(o => o.KeyId).Returns(Guid.NewGuid());
@@ -318,7 +427,7 @@ private static IKey CreateKey(DateTimeOffset activationDate, DateTimeOffset expi
318427
mockKey.Setup(o => o.CreateEncryptor()).Returns(Mock.Of<IAuthenticatedEncryptor>());
319428
}
320429

321-
return mockKey.Object;
430+
return mockKey;
322431
}
323432

324433
private static DateTimeOffset ParseDateTimeOffset(string dto)

0 commit comments

Comments
 (0)