Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[release/8.0] Introduce a read-only mode for data protection keyring consumers #54266

Merged
merged 9 commits into from
Mar 6, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ private static void AddDataProtectionServices(IServiceCollection services)

services.TryAddEnumerable(
ServiceDescriptor.Singleton<IConfigureOptions<KeyManagementOptions>, KeyManagementOptionsSetup>());
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPostConfigureOptions<KeyManagementOptions>, KeyManagementOptionsPostSetup>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IConfigureOptions<DataProtectionOptions>, DataProtectionOptionsSetup>());

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// 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.IO;
using System.Xml.Linq;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.Repositories;
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Microsoft.AspNetCore.DataProtection.Internal;

/// <summary>
/// Performs additional <see cref="KeyManagementOptions" /> configuration, after the user's configuration has been applied.
/// </summary>
/// <remarks>
/// In practice, this type is used to set key management to readonly mode if an environment variable is set and the user
/// has not explicitly configured data protection.
/// </remarks>
internal sealed class KeyManagementOptionsPostSetup : IPostConfigureOptions<KeyManagementOptions>
{
/// <remarks>
/// Settable as `ReadOnlyDataProtectionKeyDirectory`, `DOTNET_ReadOnlyDataProtectionKeyDirectory`,
/// or `ASPNETCORE_ReadOnlyDataProtectionKeyDirectory`, in descending order of precedence.
/// </remarks>
internal const string ReadOnlyDataProtectionKeyDirectoryKey = "ReadOnlyDataProtectionKeyDirectory";

private readonly string? _keyDirectoryPath;
private readonly ILoggerFactory? _loggerFactory; // Null iff _keyDirectoryPath is null
private readonly ILogger<KeyManagementOptionsPostSetup>? _logger; // Null iff _keyDirectoryPath is null

public KeyManagementOptionsPostSetup()
{
// If there's no IConfiguration, there's no _keyDirectoryPath and this type will do nothing.
// This is mostly a convenience for tests since ASP.NET Core apps will have an IConfiguration.
}

public KeyManagementOptionsPostSetup(IConfiguration configuration, ILoggerFactory loggerFactory)
{
var dirPath = configuration[ReadOnlyDataProtectionKeyDirectoryKey];
if (string.IsNullOrEmpty(dirPath))
{
return;
}

_keyDirectoryPath = dirPath;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<KeyManagementOptionsPostSetup>();
}

void IPostConfigureOptions<KeyManagementOptions>.PostConfigure(string? name, KeyManagementOptions options)
{
if (_keyDirectoryPath is null)
{
// There's no logger, so we couldn't log if we wanted to
return;
}

var logger = _logger!;

if (name != Options.DefaultName)
{
logger.IgnoringReadOnlyConfigurationForNonDefaultOptions(ReadOnlyDataProtectionKeyDirectoryKey, name);
return;
}

// If Data Protection has not been configured, then set it up according to the environment variable
if (options is { XmlRepository: null, XmlEncryptor: null })
{
var keyDirectory = new DirectoryInfo(_keyDirectoryPath);

logger.UsingReadOnlyKeyConfiguration(keyDirectory.FullName);

options.AutoGenerateKeys = false;
options.XmlEncryptor = InvalidEncryptor.Instance;
options.XmlRepository = new ReadOnlyFileSystemXmlRepository(keyDirectory, _loggerFactory!);
}
else if (options.XmlRepository is not null)
{
logger.NotUsingReadOnlyKeyConfigurationBecauseOfRepository();
}
else
{
logger.NotUsingReadOnlyKeyConfigurationBecauseOfEncryptor();
}
}

private sealed class InvalidEncryptor : IXmlEncryptor
{
public static readonly IXmlEncryptor Instance = new InvalidEncryptor();

private InvalidEncryptor()
{
}

EncryptedXmlInfo IXmlEncryptor.Encrypt(XElement plaintextElement)
{
throw new InvalidOperationException("Keys access is set up as read-only, so nothing should be encrypting");
}
}

private sealed class ReadOnlyFileSystemXmlRepository : FileSystemXmlRepository
{
public ReadOnlyFileSystemXmlRepository(DirectoryInfo directory, ILoggerFactory loggerFactory)
: base(directory, loggerFactory)
{
}

public override void StoreElement(XElement element, string friendlyName)
{
throw new InvalidOperationException("Keys access is set up as read-only, so nothing should be storing keys");
}
}
}
12 changes: 12 additions & 0 deletions src/DataProtection/DataProtection/src/LoggingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,16 @@ private static bool IsLogLevelEnabledCore([NotNullWhen(true)] ILogger? logger, L

[LoggerMessage(60, LogLevel.Warning, "Storing keys in a directory '{path}' that may not be persisted outside of the container. Protected data will be unavailable when container is destroyed. For more information go to https://aka.ms/aspnet/dataprotectionwarning", EventName = "UsingEphemeralFileSystemLocationInContainer")]
public static partial void UsingEphemeralFileSystemLocationInContainer(this ILogger logger, string path);

[LoggerMessage(61, LogLevel.Trace, "Ignoring configuration '{PropertyName}' for options instance '{OptionsName}'", EventName = "IgnoringReadOnlyConfigurationForNonDefaultOptions")]
public static partial void IgnoringReadOnlyConfigurationForNonDefaultOptions(this ILogger logger, string propertyName, string? optionsName);

[LoggerMessage(62, LogLevel.Information, "Enabling read-only key access with repository directory '{Path}'", EventName = "UsingReadOnlyKeyConfiguration")]
public static partial void UsingReadOnlyKeyConfiguration(this ILogger logger, string path);

[LoggerMessage(63, LogLevel.Debug, "Not enabling read-only key access because an XML repository has been specified", EventName = "NotUsingReadOnlyKeyConfigurationBecauseOfRepository")]
public static partial void NotUsingReadOnlyKeyConfigurationBecauseOfRepository(this ILogger logger);

[LoggerMessage(64, LogLevel.Debug, "Not enabling read-only key access because an XML encryptor has been specified", EventName = "NotUsingReadOnlyKeyConfigurationBecauseOfEncryptor")]
public static partial void NotUsingReadOnlyKeyConfigurationBecauseOfEncryptor(this ILogger logger);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Xml.Linq;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.Repositories;
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace Microsoft.AspNetCore.DataProtection.Internal;

public class KeyManagementOptionsPostSetupTest
{
private static readonly string keyDir = new DirectoryInfo("/testpath").FullName;
private static readonly XElement xElement = new("element");

[Fact]
public void ConfigureReadOnly()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(
[
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
]).Build();

IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);

var options = new KeyManagementOptions();

setup.PostConfigure(Options.DefaultName, options);

AssertReadOnly(options, keyDir);
}

[Fact]
public void ConfigureReadOnly_NonDefaultInstance()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(
[
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
]).Build();

IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);

var options = new KeyManagementOptions();

setup.PostConfigure(Options.DefaultName + 1, options);

AssertNotReadOnly(options, keyDir);

Assert.True(options.AutoGenerateKeys);
}

[Fact]
public void ConfigureReadOnly_EmptyDirPath()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(
[
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, ""),
]).Build();

IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);

var options = new KeyManagementOptions();

setup.PostConfigure(Options.DefaultName, options);

AssertNotReadOnly(options, keyDir);

Assert.True(options.AutoGenerateKeys);
}

[Fact]
public void ConfigureReadOnly_ExplicitRepository()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(
[
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
]).Build();

IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);

var xmlDir = Directory.CreateTempSubdirectory();
try
{
var options = new KeyManagementOptions()
{
XmlRepository = new FileSystemXmlRepository(xmlDir, NullLoggerFactory.Instance),
};

setup.PostConfigure(Options.DefaultName, options);

AssertNotReadOnly(options, keyDir);

Assert.True(options.AutoGenerateKeys);
}
finally
{
xmlDir.Delete(recursive: true);
}
}

[Fact]
public void ConfigureReadOnly_ExplicitEncryptor()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(
[
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
]).Build();

IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);

var options = new KeyManagementOptions()
{
XmlEncryptor = new NullXmlEncryptor(),
};

setup.PostConfigure(Options.DefaultName, options);

AssertNotReadOnly(options, keyDir);

Assert.True(options.AutoGenerateKeys);
}

[Fact]
public void NotConfigured_NoProperty()
{
var config = new ConfigurationBuilder().AddInMemoryCollection().Build();

IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);

var options = new KeyManagementOptions();

setup.PostConfigure(Options.DefaultName, options);

AssertNotReadOnly(options, keyDir);

Assert.True(options.AutoGenerateKeys);
}

[Fact]
public void NotConfigured_NoIConfiguration()
{
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup();

var options = new KeyManagementOptions();

setup.PostConfigure(Options.DefaultName, options);

AssertNotReadOnly(options, keyDir);

Assert.True(options.AutoGenerateKeys);
}

private static void AssertReadOnly(KeyManagementOptions options, string keyDir)
{
// Effect 1: No key generation
Assert.False(options.AutoGenerateKeys);

var repository = options.XmlRepository as FileSystemXmlRepository;
Assert.NotNull(repository);

// Effect 2: Location from configuration
Assert.Equal(keyDir, repository.Directory.FullName);

// Effect 3: No writing
Assert.Throws<InvalidOperationException>(() => repository.StoreElement(xElement, friendlyName: null));

// Effect 4: No key encryption
Assert.NotNull(options.XmlEncryptor);
Assert.Throws<InvalidOperationException>(() => options.XmlEncryptor.Encrypt(xElement));
}

private static void AssertNotReadOnly(KeyManagementOptions options, string keyDir)
{
// Missing effect 1: No key generation
Assert.True(options.AutoGenerateKeys);

var repository = options.XmlRepository;
if (repository is not null)
{
// Missing effect 2: Location from configuration
Assert.NotEqual(keyDir, (repository as FileSystemXmlRepository)?.Directory.FullName);

// Missing effect 3: No writing
repository.StoreElement(xElement, friendlyName: null);
}

var encryptor = options.XmlEncryptor;
if (encryptor is not null)
{
// Missing effect 4: No key encryption
options.XmlEncryptor.Encrypt(xElement);
}
}
}
Loading
Loading