488 lines
22 KiB
C#
488 lines
22 KiB
C#
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
|
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Xml;
|
|
using System.Xml.Linq;
|
|
using Microsoft.AspNet.Cryptography;
|
|
using Microsoft.AspNet.DataProtection.AuthenticatedEncryption.ConfigurationModel;
|
|
using Microsoft.AspNet.DataProtection.Repositories;
|
|
using Microsoft.AspNet.DataProtection.XmlEncryption;
|
|
using Microsoft.Framework.DependencyInjection;
|
|
using Microsoft.Framework.Internal;
|
|
using Microsoft.Framework.Logging;
|
|
|
|
using static System.FormattableString;
|
|
|
|
namespace Microsoft.AspNet.DataProtection.KeyManagement
|
|
{
|
|
/// <summary>
|
|
/// A key manager backed by an <see cref="IXmlRepository"/>.
|
|
/// </summary>
|
|
public sealed class XmlKeyManager : IKeyManager, IInternalXmlKeyManager
|
|
{
|
|
// Used for serializing elements to persistent storage
|
|
internal static readonly XName KeyElementName = "key";
|
|
internal static readonly XName IdAttributeName = "id";
|
|
internal static readonly XName VersionAttributeName = "version";
|
|
internal static readonly XName CreationDateElementName = "creationDate";
|
|
internal static readonly XName ActivationDateElementName = "activationDate";
|
|
internal static readonly XName ExpirationDateElementName = "expirationDate";
|
|
internal static readonly XName DescriptorElementName = "descriptor";
|
|
internal static readonly XName DeserializerTypeAttributeName = "deserializerType";
|
|
internal static readonly XName RevocationElementName = "revocation";
|
|
internal static readonly XName RevocationDateElementName = "revocationDate";
|
|
internal static readonly XName ReasonElementName = "reason";
|
|
|
|
private const string RevokeAllKeysValue = "*";
|
|
|
|
private readonly IActivator _activator;
|
|
private readonly IAuthenticatedEncryptorConfiguration _authenticatedEncryptorConfiguration;
|
|
private readonly IInternalXmlKeyManager _internalKeyManager;
|
|
private readonly IKeyEscrowSink _keyEscrowSink;
|
|
private readonly ILogger _logger;
|
|
|
|
private CancellationTokenSource _cacheExpirationTokenSource;
|
|
|
|
/// <summary>
|
|
/// Creates an <see cref="XmlKeyManager"/>.
|
|
/// </summary>
|
|
/// <param name="repository">The repository where keys are stored.</param>
|
|
/// <param name="configuration">Configuration for newly-created keys.</param>
|
|
/// <param name="services">A provider of optional services.</param>
|
|
public XmlKeyManager(
|
|
[NotNull] IXmlRepository repository,
|
|
[NotNull] IAuthenticatedEncryptorConfiguration configuration,
|
|
IServiceProvider services)
|
|
{
|
|
KeyEncryptor = services.GetService<IXmlEncryptor>(); // optional
|
|
KeyRepository = repository;
|
|
|
|
_activator = services.GetActivator(); // returns non-null
|
|
_authenticatedEncryptorConfiguration = configuration;
|
|
_internalKeyManager = services.GetService<IInternalXmlKeyManager>() ?? this;
|
|
_keyEscrowSink = services.GetKeyEscrowSink(); // not required
|
|
_logger = services.GetLogger<XmlKeyManager>(); // not required
|
|
TriggerAndResetCacheExpirationToken(suppressLogging: true);
|
|
}
|
|
|
|
internal XmlKeyManager(IServiceProvider services)
|
|
{
|
|
// First, see if an explicit encryptor or repository was specified.
|
|
// If either was specified, then we won't use the fallback.
|
|
KeyEncryptor = services.GetService<IXmlEncryptor>(); // optional
|
|
KeyRepository = (KeyEncryptor != null)
|
|
? services.GetRequiredService<IXmlRepository>() // required if encryptor is specified
|
|
: services.GetService<IXmlRepository>(); // optional if encryptor not specified
|
|
|
|
// If the repository is missing, then we get both the encryptor and the repository from the fallback.
|
|
// If the fallback is missing, the final call to GetRequiredService below will throw.
|
|
if (KeyRepository == null)
|
|
{
|
|
var defaultKeyServices = services.GetService<IDefaultKeyServices>();
|
|
KeyEncryptor = defaultKeyServices?.GetKeyEncryptor(); // optional
|
|
KeyRepository = defaultKeyServices?.GetKeyRepository() ?? services.GetRequiredService<IXmlRepository>();
|
|
}
|
|
|
|
_activator = services.GetActivator(); // returns non-null
|
|
_authenticatedEncryptorConfiguration = services.GetRequiredService<IAuthenticatedEncryptorConfiguration>();
|
|
_internalKeyManager = services.GetService<IInternalXmlKeyManager>() ?? this;
|
|
_keyEscrowSink = services.GetKeyEscrowSink(); // not required
|
|
_logger = services.GetLogger<XmlKeyManager>(); // not required
|
|
TriggerAndResetCacheExpirationToken(suppressLogging: true);
|
|
}
|
|
|
|
internal IXmlEncryptor KeyEncryptor { get; }
|
|
|
|
internal IXmlRepository KeyRepository { get; }
|
|
|
|
public IKey CreateNewKey(DateTimeOffset activationDate, DateTimeOffset expirationDate)
|
|
{
|
|
return _internalKeyManager.CreateNewKey(
|
|
keyId: Guid.NewGuid(),
|
|
creationDate: DateTimeOffset.UtcNow,
|
|
activationDate: activationDate,
|
|
expirationDate: expirationDate);
|
|
}
|
|
|
|
private static string DateTimeOffsetToFilenameSafeString(DateTimeOffset dateTime)
|
|
{
|
|
// similar to the XML format for dates, but with punctuation stripped
|
|
return dateTime.UtcDateTime.ToString("yyyyMMddTHHmmssFFFFFFFZ");
|
|
}
|
|
|
|
public IReadOnlyCollection<IKey> GetAllKeys()
|
|
{
|
|
var allElements = KeyRepository.GetAllElements();
|
|
|
|
// We aggregate all the information we read into three buckets
|
|
Dictionary<Guid, KeyBase> keyIdToKeyMap = new Dictionary<Guid, KeyBase>();
|
|
HashSet<Guid> revokedKeyIds = null;
|
|
DateTimeOffset? mostRecentMassRevocationDate = null;
|
|
|
|
foreach (var element in allElements)
|
|
{
|
|
if (element.Name == KeyElementName)
|
|
{
|
|
// ProcessKeyElement can return null in the case of failure, and if this happens we'll move on.
|
|
// Still need to throw if we see duplicate keys with the same id.
|
|
KeyBase key = ProcessKeyElement(element);
|
|
if (key != null)
|
|
{
|
|
if (keyIdToKeyMap.ContainsKey(key.KeyId))
|
|
{
|
|
throw Error.XmlKeyManager_DuplicateKey(key.KeyId);
|
|
}
|
|
keyIdToKeyMap[key.KeyId] = key;
|
|
}
|
|
}
|
|
else if (element.Name == RevocationElementName)
|
|
{
|
|
object revocationInfo = ProcessRevocationElement(element);
|
|
if (revocationInfo is Guid)
|
|
{
|
|
// a single key was revoked
|
|
if (revokedKeyIds == null)
|
|
{
|
|
revokedKeyIds = new HashSet<Guid>();
|
|
}
|
|
revokedKeyIds.Add((Guid)revocationInfo);
|
|
}
|
|
else
|
|
{
|
|
// all keys as of a certain date were revoked
|
|
DateTimeOffset thisMassRevocationDate = (DateTimeOffset)revocationInfo;
|
|
if (!mostRecentMassRevocationDate.HasValue || mostRecentMassRevocationDate < thisMassRevocationDate)
|
|
{
|
|
mostRecentMassRevocationDate = thisMassRevocationDate;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Skip unknown elements.
|
|
if (_logger.IsWarningLevelEnabled())
|
|
{
|
|
_logger.LogWarningF($"Unknown element with name '{element.Name}' found in keyring, skipping.");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply individual revocations
|
|
if (revokedKeyIds != null)
|
|
{
|
|
foreach (Guid revokedKeyId in revokedKeyIds)
|
|
{
|
|
KeyBase key;
|
|
keyIdToKeyMap.TryGetValue(revokedKeyId, out key);
|
|
if (key != null)
|
|
{
|
|
key.SetRevoked();
|
|
if (_logger.IsVerboseLevelEnabled())
|
|
{
|
|
_logger.LogVerboseF($"Marked key {revokedKeyId:B} as revoked in the keyring.");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (_logger.IsWarningLevelEnabled())
|
|
{
|
|
_logger.LogWarningF($"Tried to process revocation of key {revokedKeyId:B}, but no such key was found in keyring. Skipping.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply mass revocations
|
|
if (mostRecentMassRevocationDate.HasValue)
|
|
{
|
|
foreach (var key in keyIdToKeyMap.Values)
|
|
{
|
|
if (key.CreationDate <= mostRecentMassRevocationDate)
|
|
{
|
|
key.SetRevoked();
|
|
if (_logger.IsVerboseLevelEnabled())
|
|
{
|
|
_logger.LogVerboseF($"Marked key {key.KeyId:B} as revoked in the keyring.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// And we're finished!
|
|
return keyIdToKeyMap.Values.ToList().AsReadOnly();
|
|
}
|
|
|
|
public CancellationToken GetCacheExpirationToken()
|
|
{
|
|
return Interlocked.CompareExchange(ref _cacheExpirationTokenSource, null, null).Token;
|
|
}
|
|
|
|
private KeyBase ProcessKeyElement(XElement keyElement)
|
|
{
|
|
Debug.Assert(keyElement.Name == KeyElementName);
|
|
|
|
try
|
|
{
|
|
// Read metadata and prepare the key for deferred instantiation
|
|
Guid keyId = (Guid)keyElement.Attribute(IdAttributeName);
|
|
DateTimeOffset creationDate = (DateTimeOffset)keyElement.Element(CreationDateElementName);
|
|
DateTimeOffset activationDate = (DateTimeOffset)keyElement.Element(ActivationDateElementName);
|
|
DateTimeOffset expirationDate = (DateTimeOffset)keyElement.Element(ExpirationDateElementName);
|
|
|
|
if (_logger.IsVerboseLevelEnabled())
|
|
{
|
|
_logger.LogVerboseF($"Found key {keyId:B}.");
|
|
}
|
|
|
|
return new DeferredKey(
|
|
keyId: keyId,
|
|
creationDate: creationDate,
|
|
activationDate: activationDate,
|
|
expirationDate: expirationDate,
|
|
keyManager: _internalKeyManager,
|
|
keyElement: keyElement);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
WriteKeyDeserializationErrorToLog(ex, keyElement);
|
|
|
|
// Don't include this key in the key ring
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// returns a Guid (for specific keys) or a DateTimeOffset (for all keys created on or before a specific date)
|
|
private object ProcessRevocationElement(XElement revocationElement)
|
|
{
|
|
Debug.Assert(revocationElement.Name == RevocationElementName);
|
|
|
|
try
|
|
{
|
|
string keyIdAsString = (string)revocationElement.Element(KeyElementName).Attribute(IdAttributeName);
|
|
if (keyIdAsString == RevokeAllKeysValue)
|
|
{
|
|
// this is a mass revocation of all keys as of the specified revocation date
|
|
DateTimeOffset massRevocationDate = (DateTimeOffset)revocationElement.Element(RevocationDateElementName);
|
|
if (_logger.IsVerboseLevelEnabled())
|
|
{
|
|
_logger.LogVerboseF($"Found revocation of all keys created prior to {massRevocationDate:u}.");
|
|
}
|
|
return massRevocationDate;
|
|
}
|
|
else
|
|
{
|
|
// only one key is being revoked
|
|
Guid keyId = XmlConvert.ToGuid(keyIdAsString);
|
|
if (_logger.IsVerboseLevelEnabled())
|
|
{
|
|
_logger.LogVerboseF($"Found revocation of key {keyId:B}.");
|
|
}
|
|
return keyId;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Any exceptions that occur are fatal - we don't want to continue if we cannot process
|
|
// revocation information.
|
|
if (_logger.IsErrorLevelEnabled())
|
|
{
|
|
_logger.LogErrorF(ex, $"An exception occurred while processing the revocation element '{revocationElement}'. Cannot continue keyring processing.");
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public void RevokeAllKeys(DateTimeOffset revocationDate, string reason = null)
|
|
{
|
|
// <revocation version="1">
|
|
// <revocationDate>...</revocationDate>
|
|
// <!-- ... -->
|
|
// <key id="*" />
|
|
// <reason>...</reason>
|
|
// </revocation>
|
|
|
|
if (_logger.IsInformationLevelEnabled())
|
|
{
|
|
_logger.LogInformationF($"Revoking all keys as of {revocationDate:u} for reason '{reason}'.");
|
|
}
|
|
|
|
var revocationElement = new XElement(RevocationElementName,
|
|
new XAttribute(VersionAttributeName, 1),
|
|
new XElement(RevocationDateElementName, revocationDate),
|
|
new XComment(" All keys created before the revocation date are revoked. "),
|
|
new XElement(KeyElementName,
|
|
new XAttribute(IdAttributeName, RevokeAllKeysValue)),
|
|
new XElement(ReasonElementName, reason));
|
|
|
|
// Persist it to the underlying repository and trigger the cancellation token
|
|
string friendlyName = "revocation-" + DateTimeOffsetToFilenameSafeString(revocationDate);
|
|
KeyRepository.StoreElement(revocationElement, friendlyName);
|
|
TriggerAndResetCacheExpirationToken();
|
|
}
|
|
|
|
public void RevokeKey(Guid keyId, string reason = null)
|
|
{
|
|
_internalKeyManager.RevokeSingleKey(
|
|
keyId: keyId,
|
|
revocationDate: DateTimeOffset.UtcNow,
|
|
reason: reason);
|
|
}
|
|
|
|
private void TriggerAndResetCacheExpirationToken([CallerMemberName] string opName = null, bool suppressLogging = false)
|
|
{
|
|
if (!suppressLogging && _logger.IsVerboseLevelEnabled())
|
|
{
|
|
_logger.LogVerboseF($"Key cache expiration token triggered by '{opName}' operation.");
|
|
}
|
|
|
|
Interlocked.Exchange(ref _cacheExpirationTokenSource, new CancellationTokenSource())?.Cancel();
|
|
}
|
|
|
|
private void WriteKeyDeserializationErrorToLog(Exception error, XElement keyElement)
|
|
{
|
|
// Ideally we'd suppress the error since it might contain sensitive information, but it would be too difficult for
|
|
// an administrator to diagnose the issue if we hide this information. Instead we'll log the error to the error
|
|
// log and the raw <key> element to the debug log. This works for our out-of-box XML decryptors since they don't
|
|
// include sensitive information in the exception message.
|
|
|
|
if (_logger.IsErrorLevelEnabled())
|
|
{
|
|
// write sanitized <key> element
|
|
_logger.LogErrorF(error, $"An exception occurred while processing the key element '{keyElement.WithoutChildNodes()}'.");
|
|
}
|
|
|
|
if (_logger.IsDebugLevelEnabled())
|
|
{
|
|
// write full <key> element
|
|
_logger.LogDebugF(error, $"An exception occurred while processing the key element '{keyElement}'.");
|
|
}
|
|
}
|
|
|
|
IKey IInternalXmlKeyManager.CreateNewKey(Guid keyId, DateTimeOffset creationDate, DateTimeOffset activationDate, DateTimeOffset expirationDate)
|
|
{
|
|
// <key id="{guid}" version="1">
|
|
// <creationDate>...</creationDate>
|
|
// <activationDate>...</activationDate>
|
|
// <expirationDate>...</expirationDate>
|
|
// <descriptor deserializerType="{typeName}">
|
|
// ...
|
|
// </descriptor>
|
|
// </key>
|
|
|
|
if (_logger.IsInformationLevelEnabled())
|
|
{
|
|
_logger.LogInformationF($"Creating key {keyId:B} with creation date {creationDate:u}, activation date {activationDate:u}, and expiration date {expirationDate:u}.");
|
|
}
|
|
|
|
var newDescriptor = _authenticatedEncryptorConfiguration.CreateNewDescriptor()
|
|
?? CryptoUtil.Fail<IAuthenticatedEncryptorDescriptor>("CreateNewDescriptor returned null.");
|
|
var descriptorXmlInfo = newDescriptor.ExportToXml();
|
|
|
|
if (_logger.IsVerboseLevelEnabled())
|
|
{
|
|
_logger.LogVerboseF($"Descriptor deserializer type for key {keyId:B} is '{descriptorXmlInfo.DeserializerType.AssemblyQualifiedName}'.");
|
|
}
|
|
|
|
// build the <key> element
|
|
var keyElement = new XElement(KeyElementName,
|
|
new XAttribute(IdAttributeName, keyId),
|
|
new XAttribute(VersionAttributeName, 1),
|
|
new XElement(CreationDateElementName, creationDate),
|
|
new XElement(ActivationDateElementName, activationDate),
|
|
new XElement(ExpirationDateElementName, expirationDate),
|
|
new XElement(DescriptorElementName,
|
|
new XAttribute(DeserializerTypeAttributeName, descriptorXmlInfo.DeserializerType.AssemblyQualifiedName),
|
|
descriptorXmlInfo.SerializedDescriptorElement));
|
|
|
|
// If key escrow policy is in effect, write the *unencrypted* key now.
|
|
if (_logger.IsVerboseLevelEnabled())
|
|
{
|
|
if (_keyEscrowSink != null)
|
|
{
|
|
_logger.LogVerboseF($"Key escrow sink found. Writing key {keyId:B} to escrow.");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogVerboseF($"No key escrow sink found. Not writing key {keyId:B} to escrow.");
|
|
}
|
|
}
|
|
_keyEscrowSink?.Store(keyId, keyElement);
|
|
|
|
// If an XML encryptor has been configured, protect secret key material now.
|
|
if (KeyEncryptor == null && _logger.IsWarningLevelEnabled())
|
|
{
|
|
_logger.LogWarningF($"No XML encryptor configured. Key {keyId:B} may be persisted to storage in unencrypted form.");
|
|
}
|
|
var possiblyEncryptedKeyElement = KeyEncryptor?.EncryptIfNecessary(keyElement) ?? keyElement;
|
|
|
|
// Persist it to the underlying repository and trigger the cancellation token.
|
|
string friendlyName = Invariant($"key-{keyId:D}");
|
|
KeyRepository.StoreElement(possiblyEncryptedKeyElement, friendlyName);
|
|
TriggerAndResetCacheExpirationToken();
|
|
|
|
// And we're done!
|
|
return new Key(
|
|
keyId: keyId,
|
|
creationDate: creationDate,
|
|
activationDate: activationDate,
|
|
expirationDate: expirationDate,
|
|
descriptor: newDescriptor);
|
|
}
|
|
|
|
IAuthenticatedEncryptorDescriptor IInternalXmlKeyManager.DeserializeDescriptorFromKeyElement(XElement keyElement)
|
|
{
|
|
try
|
|
{
|
|
// Figure out who will be deserializing this
|
|
XElement descriptorElement = keyElement.Element(DescriptorElementName);
|
|
string descriptorDeserializerTypeName = (string)descriptorElement.Attribute(DeserializerTypeAttributeName);
|
|
|
|
// Decrypt the descriptor element and pass it to the descriptor for consumption
|
|
XElement unencryptedInputToDeserializer = descriptorElement.Elements().Single().DecryptElement(_activator);
|
|
var deserializerInstance = _activator.CreateInstance<IAuthenticatedEncryptorDescriptorDeserializer>(descriptorDeserializerTypeName);
|
|
var descriptorInstance = deserializerInstance.ImportFromXml(unencryptedInputToDeserializer);
|
|
|
|
return descriptorInstance ?? CryptoUtil.Fail<IAuthenticatedEncryptorDescriptor>("ImportFromXml returned null.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
WriteKeyDeserializationErrorToLog(ex, keyElement);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
void IInternalXmlKeyManager.RevokeSingleKey(Guid keyId, DateTimeOffset revocationDate, string reason)
|
|
{
|
|
// <revocation version="1">
|
|
// <revocationDate>...</revocationDate>
|
|
// <key id="{guid}" />
|
|
// <reason>...</reason>
|
|
// </revocation>
|
|
|
|
if (_logger.IsInformationLevelEnabled())
|
|
{
|
|
_logger.LogInformationF($"Revoking key {keyId:B} at {revocationDate:u} for reason '{reason}'.");
|
|
}
|
|
|
|
var revocationElement = new XElement(RevocationElementName,
|
|
new XAttribute(VersionAttributeName, 1),
|
|
new XElement(RevocationDateElementName, revocationDate),
|
|
new XElement(KeyElementName,
|
|
new XAttribute(IdAttributeName, keyId)),
|
|
new XElement(ReasonElementName, reason));
|
|
|
|
// Persist it to the underlying repository and trigger the cancellation token
|
|
string friendlyName = Invariant($"revocation-{keyId:D}");
|
|
KeyRepository.StoreElement(revocationElement, friendlyName);
|
|
TriggerAndResetCacheExpirationToken();
|
|
}
|
|
}
|
|
}
|