Skip to content

Commit 7e99eea

Browse files
authored
Support key deletion in Data Protection (#53860)
* Add deletion APIs to IKeyManager and IXmlRepository
1 parent 6c73c63 commit 7e99eea

File tree

15 files changed

+815
-17
lines changed

15 files changed

+815
-17
lines changed

src/DataProtection/DataProtection/src/Error.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,9 @@ public static InvalidOperationException KeyRingProvider_RefreshFailedOnOtherThre
102102
{
103103
return new InvalidOperationException(Resources.KeyRingProvider_RefreshFailedOnOtherThread, inner);
104104
}
105+
106+
public static NotSupportedException XmlKeyManager_DoesNotSupportKeyDeletion()
107+
{
108+
return new NotSupportedException(Resources.XmlKeyManager_DoesNotSupportKeyDeletion);
109+
}
105110
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Threading;
8+
9+
namespace Microsoft.AspNetCore.DataProtection.KeyManagement;
10+
11+
/// <summary>
12+
/// An extension of <see cref="IKeyManager"/> that supports key deletion.
13+
/// </summary>
14+
public interface IDeletableKeyManager : IKeyManager
15+
{
16+
/// <summary>
17+
/// Indicates whether this key manager and the underlying <see cref="Repositories.IXmlRepository"/> support key deletion.
18+
/// </summary>
19+
/// <seealso cref="DeleteKeys"/>
20+
bool CanDeleteKeys { get; }
21+
22+
/// <summary>
23+
/// Deletes keys matching a predicate.
24+
///
25+
/// Use with caution as deleting active keys will normally cause data loss.
26+
/// </summary>
27+
/// <param name="shouldDelete">
28+
/// A predicate applied to each key.
29+
/// Returning true will cause the key to be deleted.
30+
/// </param>
31+
/// <returns>
32+
/// True if all attempted deletions succeeded.
33+
/// </returns>
34+
/// <remarks>
35+
/// Deletion is stronger than revocation. A revoked key is retained and can even be (forcefully) applied.
36+
/// A deleted key is indistinguishable from a key that never existed.
37+
///
38+
/// Generally, keys should only be deleted to save space. If space is not a concern, keys
39+
/// should be revoked or allowed to expire instead.
40+
///
41+
/// This method will not mutate existing IKey instances. After calling this method,
42+
/// all existing IKey instances should be discarded, and GetAllKeys should be called again.
43+
/// </remarks>
44+
/// <exception cref="NotSupportedException">
45+
/// If <see cref="CanDeleteKeys"/> is false.
46+
/// </exception>
47+
bool DeleteKeys(Func<IKey, bool> shouldDelete);
48+
}

src/DataProtection/DataProtection/src/KeyManagement/XmlKeyManager.cs

Lines changed: 105 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -164,19 +164,38 @@ private static string DateTimeOffsetToFilenameSafeString(DateTimeOffset dateTime
164164
public IReadOnlyCollection<IKey> GetAllKeys()
165165
{
166166
var allElements = KeyRepository.GetAllElements();
167+
var processed = ProcessAllElements(allElements, out _);
168+
return processed.OfType<IKey>().ToList().AsReadOnly();
169+
}
170+
171+
/// <summary>
172+
/// Returns an array paralleling <paramref name="allElements"/> but:
173+
/// 1. Key elements become IKeys (with revocation data)
174+
/// 2. KeyId-based revocations become Guids
175+
/// 3. Date-based revocations become DateTimeOffsets
176+
/// 4. Unknown elements become null
177+
/// </summary>
178+
private object?[] ProcessAllElements(IReadOnlyCollection<XElement> allElements, out DateTimeOffset? mostRecentMassRevocationDate)
179+
{
180+
var elementCount = allElements.Count;
181+
182+
var results = new object?[elementCount];
167183

168-
// We aggregate all the information we read into three buckets
169-
Dictionary<Guid, Key> keyIdToKeyMap = new Dictionary<Guid, Key>();
184+
Dictionary<Guid, Key> keyIdToKeyMap = [];
170185
HashSet<Guid>? revokedKeyIds = null;
171-
DateTimeOffset? mostRecentMassRevocationDate = null;
172186

187+
mostRecentMassRevocationDate = null;
188+
189+
var pos = 0;
173190
foreach (var element in allElements)
174191
{
192+
object? result;
175193
if (element.Name == KeyElementName)
176194
{
177195
// ProcessKeyElement can return null in the case of failure, and if this happens we'll move on.
178196
// Still need to throw if we see duplicate keys with the same id.
179197
var key = ProcessKeyElement(element);
198+
result = key;
180199
if (key != null)
181200
{
182201
if (keyIdToKeyMap.ContainsKey(key.KeyId))
@@ -189,19 +208,20 @@ public IReadOnlyCollection<IKey> GetAllKeys()
189208
else if (element.Name == RevocationElementName)
190209
{
191210
var revocationInfo = ProcessRevocationElement(element);
192-
if (revocationInfo is Guid)
211+
result = revocationInfo;
212+
if (revocationInfo is Guid revocationGuid)
193213
{
194214
// a single key was revoked
195-
if (revokedKeyIds == null)
215+
revokedKeyIds ??= [];
216+
if (!revokedKeyIds.Add(revocationGuid))
196217
{
197-
revokedKeyIds = new HashSet<Guid>();
218+
_logger.KeyRevokedMultipleTimes(revocationGuid);
198219
}
199-
revokedKeyIds.Add((Guid)revocationInfo);
200220
}
201221
else
202222
{
203223
// all keys as of a certain date were revoked
204-
DateTimeOffset thisMassRevocationDate = (DateTimeOffset)revocationInfo;
224+
var thisMassRevocationDate = (DateTimeOffset)revocationInfo;
205225
if (!mostRecentMassRevocationDate.HasValue || mostRecentMassRevocationDate < thisMassRevocationDate)
206226
{
207227
mostRecentMassRevocationDate = thisMassRevocationDate;
@@ -212,16 +232,18 @@ public IReadOnlyCollection<IKey> GetAllKeys()
212232
{
213233
// Skip unknown elements.
214234
_logger.UnknownElementWithNameFoundInKeyringSkipping(element.Name);
235+
result = null;
215236
}
237+
238+
results[pos++] = result;
216239
}
217240

218241
// Apply individual revocations
219-
if (revokedKeyIds != null)
242+
if (revokedKeyIds is not null)
220243
{
221-
foreach (Guid revokedKeyId in revokedKeyIds)
244+
foreach (var revokedKeyId in revokedKeyIds)
222245
{
223-
keyIdToKeyMap.TryGetValue(revokedKeyId, out var key);
224-
if (key != null)
246+
if (keyIdToKeyMap.TryGetValue(revokedKeyId, out var key))
225247
{
226248
key.SetRevoked();
227249
_logger.MarkedKeyAsRevokedInTheKeyring(revokedKeyId);
@@ -252,7 +274,7 @@ public IReadOnlyCollection<IKey> GetAllKeys()
252274
}
253275

254276
// And we're finished!
255-
return keyIdToKeyMap.Values.ToList().AsReadOnly();
277+
return results;
256278
}
257279

258280
/// <inheritdoc/>
@@ -385,6 +407,76 @@ public void RevokeKey(Guid keyId, string? reason = null)
385407
reason: reason);
386408
}
387409

410+
/// <inheritdoc/>
411+
public bool CanDeleteKeys => KeyRepository is IDeletableXmlRepository;
412+
413+
/// <inheritdoc/>
414+
public bool DeleteKeys(Func<IKey, bool> shouldDelete)
415+
{
416+
if (KeyRepository is not IDeletableXmlRepository xmlRepositoryWithDeletion)
417+
{
418+
throw Error.XmlKeyManager_DoesNotSupportKeyDeletion();
419+
}
420+
421+
return xmlRepositoryWithDeletion.DeleteElements((deletableElements) =>
422+
{
423+
// It is important to delete key elements before the corresponding revocation elements,
424+
// in case the deletion fails part way - we don't want to accidentally unrevoke a key
425+
// and then not delete it.
426+
// Start at a non-zero value just to make it a little clearer in the debugger that it
427+
// was set explicitly.
428+
const int deletionOrderKey = 1;
429+
const int deletionOrderRevocation = 2;
430+
const int deletionOrderMassRevocation = 3;
431+
432+
var deletableElementsArray = deletableElements.ToArray();
433+
434+
var allElements = deletableElements.Select(d => d.Element).ToArray();
435+
var processed = ProcessAllElements(allElements, out var mostRecentMassRevocationDate);
436+
437+
var allKeyIds = new HashSet<Guid>();
438+
var deletedKeyIds = new HashSet<Guid>();
439+
440+
for (var i = 0; i < deletableElementsArray.Length; i++)
441+
{
442+
var obj = processed[i];
443+
if (obj is IKey key)
444+
{
445+
var keyId = key.KeyId;
446+
allKeyIds.Add(keyId);
447+
448+
if (shouldDelete(key))
449+
{
450+
_logger.DeletingKey(keyId);
451+
deletedKeyIds.Add(keyId);
452+
deletableElementsArray[i].DeletionOrder = deletionOrderKey;
453+
}
454+
}
455+
else if (obj is DateTimeOffset massRevocationDate)
456+
{
457+
if (massRevocationDate < mostRecentMassRevocationDate)
458+
{
459+
// Delete superceded mass revocation elements
460+
deletableElementsArray[i].DeletionOrder = deletionOrderMassRevocation;
461+
}
462+
}
463+
}
464+
465+
// Separate loop since deletedKeyIds and allKeyIds need to have been populated.
466+
for (var i = 0; i < deletableElementsArray.Length; i++)
467+
{
468+
if (processed[i] is Guid revocationId)
469+
{
470+
// Delete individual revocations of keys that don't (still) exist
471+
if (deletedKeyIds.Contains(revocationId) || !allKeyIds.Contains(revocationId))
472+
{
473+
deletableElementsArray[i].DeletionOrder = deletionOrderRevocation;
474+
}
475+
}
476+
}
477+
});
478+
}
479+
388480
private void TriggerAndResetCacheExpirationToken([CallerMemberName] string? opName = null, bool suppressLogging = false)
389481
{
390482
if (!suppressLogging)

src/DataProtection/DataProtection/src/LoggingExtensions.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,25 @@ private static bool IsLogLevelEnabledCore([NotNullWhen(true)] ILogger? logger, L
255255

256256
[LoggerMessage(73, LogLevel.Debug, "Key {KeyId:B} method {MethodName} failed. Retrying.", EventName = "RetryingMethodOfKeyAfterFailure")]
257257
public static partial void RetryingMethodOfKeyAfterFailure(this ILogger logger, Guid keyId, string methodName, Exception exception);
258+
259+
[LoggerMessage(74, LogLevel.Debug, "Deleting file '{FileName}'.", EventName = "DeletingFile")]
260+
public static partial void DeletingFile(this ILogger logger, string fileName);
261+
262+
[LoggerMessage(75, LogLevel.Error, "Failed to delete file '{FileName}'. Not attempting further deletions.", EventName = "FailedToDeleteFile")]
263+
public static partial void FailedToDeleteFile(this ILogger logger, string fileName, Exception exception);
264+
265+
[LoggerMessage(76, LogLevel.Debug, "Deleting registry key '{RegistryKeyName}', value '{Value}'.", EventName = "RemovingDataFromRegistryKeyValue")]
266+
public static partial void RemovingDataFromRegistryKeyValue(this ILogger logger, RegistryKey registryKeyName, string value);
267+
268+
[LoggerMessage(77, LogLevel.Error, "Failed to delete registry key '{RegistryKeyName}', value '{ValueName}'. Not attempting further deletions.", EventName = "FailedToRemoveDataFromRegistryKeyValue")]
269+
public static partial void FailedToRemoveDataFromRegistryKeyValue(this ILogger logger, RegistryKey registryKeyName, string valueName, Exception exception);
270+
271+
[LoggerMessage(78, LogLevel.Trace, "Found multiple revocation entries for key {KeyId:B}.", EventName = "KeyRevokedMultipleTimes")]
272+
public static partial void KeyRevokedMultipleTimes(this ILogger logger, Guid keyId);
273+
274+
[LoggerMessage(79, LogLevel.Trace, "Ignoring revocation of keys created before {OlderDate:u} in favor of revocation of keys created before {NewerDate:u}.", EventName = "DateBasedRevocationSuperseded")]
275+
public static partial void DateBasedRevocationSuperseded(this ILogger logger, DateTimeOffset olderDate, DateTimeOffset newerDate);
276+
277+
[LoggerMessage(80, LogLevel.Debug, "Deleting key {KeyId:B}.", EventName = "DeletingKey")]
278+
public static partial void DeletingKey(this ILogger logger, Guid keyId);
258279
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,14 @@
11
#nullable enable
2+
Microsoft.AspNetCore.DataProtection.KeyManagement.IDeletableKeyManager
3+
Microsoft.AspNetCore.DataProtection.KeyManagement.IDeletableKeyManager.CanDeleteKeys.get -> bool
4+
Microsoft.AspNetCore.DataProtection.KeyManagement.IDeletableKeyManager.DeleteKeys(System.Func<Microsoft.AspNetCore.DataProtection.KeyManagement.IKey!, bool>! shouldDelete) -> bool
5+
Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.CanDeleteKeys.get -> bool
6+
Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager.DeleteKeys(System.Func<Microsoft.AspNetCore.DataProtection.KeyManagement.IKey!, bool>! shouldDelete) -> bool
7+
Microsoft.AspNetCore.DataProtection.Repositories.IDeletableElement
8+
Microsoft.AspNetCore.DataProtection.Repositories.IDeletableElement.DeletionOrder.get -> int?
9+
Microsoft.AspNetCore.DataProtection.Repositories.IDeletableElement.DeletionOrder.set -> void
10+
Microsoft.AspNetCore.DataProtection.Repositories.IDeletableElement.Element.get -> System.Xml.Linq.XElement!
11+
Microsoft.AspNetCore.DataProtection.Repositories.IDeletableXmlRepository
12+
Microsoft.AspNetCore.DataProtection.Repositories.IDeletableXmlRepository.DeleteElements(System.Action<System.Collections.Generic.IReadOnlyCollection<Microsoft.AspNetCore.DataProtection.Repositories.IDeletableElement!>!>! chooseElements) -> bool
13+
virtual Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository.DeleteElements(System.Action<System.Collections.Generic.IReadOnlyCollection<Microsoft.AspNetCore.DataProtection.Repositories.IDeletableElement!>!>! chooseElements) -> bool
14+
virtual Microsoft.AspNetCore.DataProtection.Repositories.RegistryXmlRepository.DeleteElements(System.Action<System.Collections.Generic.IReadOnlyCollection<Microsoft.AspNetCore.DataProtection.Repositories.IDeletableElement!>!>! chooseElements) -> bool

src/DataProtection/DataProtection/src/Repositories/EphemeralXmlRepository.cs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace Microsoft.AspNetCore.DataProtection.Repositories;
1414
/// An ephemeral XML repository backed by process memory. This class must not be used for
1515
/// anything other than dev scenarios as the keys will not be persisted to storage.
1616
/// </summary>
17-
internal sealed class EphemeralXmlRepository : IXmlRepository
17+
internal sealed class EphemeralXmlRepository : IDeletableXmlRepository
1818
{
1919
private readonly List<XElement> _storedElements = new List<XElement>();
2020

@@ -54,4 +54,62 @@ public void StoreElement(XElement element, string friendlyName)
5454
_storedElements.Add(cloned);
5555
}
5656
}
57+
58+
/// <inheritdoc/>
59+
public bool DeleteElements(Action<IReadOnlyCollection<IDeletableElement>> chooseElements)
60+
{
61+
ArgumentNullThrowHelper.ThrowIfNull(chooseElements);
62+
63+
var deletableElements = new List<DeletableElement>();
64+
65+
lock (_storedElements)
66+
{
67+
foreach (var storedElement in _storedElements)
68+
{
69+
// Make a deep copy so caller doesn't inadvertently modify it.
70+
deletableElements.Add(new DeletableElement(storedElement, new XElement(storedElement)));
71+
}
72+
}
73+
74+
chooseElements(deletableElements);
75+
76+
var elementsToDelete = deletableElements
77+
.Where(e => e.DeletionOrder.HasValue)
78+
.OrderBy(e => e.DeletionOrder.GetValueOrDefault());
79+
80+
lock (_storedElements)
81+
{
82+
foreach (var deletableElement in elementsToDelete)
83+
{
84+
var storedElement = deletableElement.StoredElement;
85+
var index = _storedElements.FindIndex(e => ReferenceEquals(e, storedElement));
86+
if (index >= 0) // Might not find it if the collection has changed since we started.
87+
{
88+
// It would be more efficient to remove the larger indices first, but deletion order
89+
// is important for correctness.
90+
_storedElements.RemoveAt(index);
91+
}
92+
}
93+
}
94+
95+
return true;
96+
}
97+
98+
private sealed class DeletableElement : IDeletableElement
99+
{
100+
public DeletableElement(XElement storedElement, XElement element)
101+
{
102+
StoredElement = storedElement;
103+
Element = element;
104+
}
105+
106+
/// <inheritdoc/>
107+
public XElement Element { get; }
108+
109+
/// <summary>The <see cref="XElement"/> from which <see cref="Element"/> was cloned.</summary>
110+
public XElement StoredElement { get; }
111+
112+
/// <inheritdoc/>
113+
public int? DeletionOrder { get; set; }
114+
}
57115
}

0 commit comments

Comments
 (0)