Skip to content

Commit 67ee7f1

Browse files
github-actions[bot]amcaseyTratcher
authored
[release/8.0] Introduce a read-only mode for data protection keyring consumers (#54266)
* Introduce a read-only mode for data protection keyring consumers When multiple app instances consume the same keyring, they all try to rotate it, leading to races. This change introduces an IConfiguration property (usually set as an env var) that puts data protection in a read-only mode. The expectation is that writing will be done by a separate (i.e. non-app-instance) component. Part of #52915 * Add simple tests * Handle an empty path * Add logging * Increase positive-case log level Co-authored-by: Chris Ross <[email protected]> * Fix style warning * Add an AddDataProtection test * Add a negative AddDataProtection test * Use a directory that exists in ConfigureReadOnly_ExplicitRepository --------- Co-authored-by: Andrew Casey <[email protected]> Co-authored-by: Andrew Casey <[email protected]> Co-authored-by: Chris Ross <[email protected]>
1 parent 7524549 commit 67ee7f1

File tree

5 files changed

+392
-0
lines changed

5 files changed

+392
-0
lines changed

src/DataProtection/DataProtection/src/DataProtectionServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ private static void AddDataProtectionServices(IServiceCollection services)
6767

6868
services.TryAddEnumerable(
6969
ServiceDescriptor.Singleton<IConfigureOptions<KeyManagementOptions>, KeyManagementOptionsSetup>());
70+
services.TryAddEnumerable(
71+
ServiceDescriptor.Singleton<IPostConfigureOptions<KeyManagementOptions>, KeyManagementOptionsPostSetup>());
7072
services.TryAddEnumerable(
7173
ServiceDescriptor.Transient<IConfigureOptions<DataProtectionOptions>, DataProtectionOptionsSetup>());
7274

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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.IO;
6+
using System.Xml.Linq;
7+
using Microsoft.AspNetCore.DataProtection.KeyManagement;
8+
using Microsoft.AspNetCore.DataProtection.Repositories;
9+
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
10+
using Microsoft.Extensions.Configuration;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Options;
13+
14+
namespace Microsoft.AspNetCore.DataProtection.Internal;
15+
16+
/// <summary>
17+
/// Performs additional <see cref="KeyManagementOptions" /> configuration, after the user's configuration has been applied.
18+
/// </summary>
19+
/// <remarks>
20+
/// In practice, this type is used to set key management to readonly mode if an environment variable is set and the user
21+
/// has not explicitly configured data protection.
22+
/// </remarks>
23+
internal sealed class KeyManagementOptionsPostSetup : IPostConfigureOptions<KeyManagementOptions>
24+
{
25+
/// <remarks>
26+
/// Settable as `ReadOnlyDataProtectionKeyDirectory`, `DOTNET_ReadOnlyDataProtectionKeyDirectory`,
27+
/// or `ASPNETCORE_ReadOnlyDataProtectionKeyDirectory`, in descending order of precedence.
28+
/// </remarks>
29+
internal const string ReadOnlyDataProtectionKeyDirectoryKey = "ReadOnlyDataProtectionKeyDirectory";
30+
31+
private readonly string? _keyDirectoryPath;
32+
private readonly ILoggerFactory? _loggerFactory; // Null iff _keyDirectoryPath is null
33+
private readonly ILogger<KeyManagementOptionsPostSetup>? _logger; // Null iff _keyDirectoryPath is null
34+
35+
public KeyManagementOptionsPostSetup()
36+
{
37+
// If there's no IConfiguration, there's no _keyDirectoryPath and this type will do nothing.
38+
// This is mostly a convenience for tests since ASP.NET Core apps will have an IConfiguration.
39+
}
40+
41+
public KeyManagementOptionsPostSetup(IConfiguration configuration, ILoggerFactory loggerFactory)
42+
{
43+
var dirPath = configuration[ReadOnlyDataProtectionKeyDirectoryKey];
44+
if (string.IsNullOrEmpty(dirPath))
45+
{
46+
return;
47+
}
48+
49+
_keyDirectoryPath = dirPath;
50+
_loggerFactory = loggerFactory;
51+
_logger = loggerFactory.CreateLogger<KeyManagementOptionsPostSetup>();
52+
}
53+
54+
void IPostConfigureOptions<KeyManagementOptions>.PostConfigure(string? name, KeyManagementOptions options)
55+
{
56+
if (_keyDirectoryPath is null)
57+
{
58+
// There's no logger, so we couldn't log if we wanted to
59+
return;
60+
}
61+
62+
var logger = _logger!;
63+
64+
if (name != Options.DefaultName)
65+
{
66+
logger.IgnoringReadOnlyConfigurationForNonDefaultOptions(ReadOnlyDataProtectionKeyDirectoryKey, name);
67+
return;
68+
}
69+
70+
// If Data Protection has not been configured, then set it up according to the environment variable
71+
if (options is { XmlRepository: null, XmlEncryptor: null })
72+
{
73+
var keyDirectory = new DirectoryInfo(_keyDirectoryPath);
74+
75+
logger.UsingReadOnlyKeyConfiguration(keyDirectory.FullName);
76+
77+
options.AutoGenerateKeys = false;
78+
options.XmlEncryptor = InvalidEncryptor.Instance;
79+
options.XmlRepository = new ReadOnlyFileSystemXmlRepository(keyDirectory, _loggerFactory!);
80+
}
81+
else if (options.XmlRepository is not null)
82+
{
83+
logger.NotUsingReadOnlyKeyConfigurationBecauseOfRepository();
84+
}
85+
else
86+
{
87+
logger.NotUsingReadOnlyKeyConfigurationBecauseOfEncryptor();
88+
}
89+
}
90+
91+
private sealed class InvalidEncryptor : IXmlEncryptor
92+
{
93+
public static readonly IXmlEncryptor Instance = new InvalidEncryptor();
94+
95+
private InvalidEncryptor()
96+
{
97+
}
98+
99+
EncryptedXmlInfo IXmlEncryptor.Encrypt(XElement plaintextElement)
100+
{
101+
throw new InvalidOperationException("Keys access is set up as read-only, so nothing should be encrypting");
102+
}
103+
}
104+
105+
private sealed class ReadOnlyFileSystemXmlRepository : FileSystemXmlRepository
106+
{
107+
public ReadOnlyFileSystemXmlRepository(DirectoryInfo directory, ILoggerFactory loggerFactory)
108+
: base(directory, loggerFactory)
109+
{
110+
}
111+
112+
public override void StoreElement(XElement element, string friendlyName)
113+
{
114+
throw new InvalidOperationException("Keys access is set up as read-only, so nothing should be storing keys");
115+
}
116+
}
117+
}

src/DataProtection/DataProtection/src/LoggingExtensions.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,16 @@ private static bool IsLogLevelEnabledCore([NotNullWhen(true)] ILogger? logger, L
237237

238238
[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")]
239239
public static partial void UsingEphemeralFileSystemLocationInContainer(this ILogger logger, string path);
240+
241+
[LoggerMessage(61, LogLevel.Trace, "Ignoring configuration '{PropertyName}' for options instance '{OptionsName}'", EventName = "IgnoringReadOnlyConfigurationForNonDefaultOptions")]
242+
public static partial void IgnoringReadOnlyConfigurationForNonDefaultOptions(this ILogger logger, string propertyName, string? optionsName);
243+
244+
[LoggerMessage(62, LogLevel.Information, "Enabling read-only key access with repository directory '{Path}'", EventName = "UsingReadOnlyKeyConfiguration")]
245+
public static partial void UsingReadOnlyKeyConfiguration(this ILogger logger, string path);
246+
247+
[LoggerMessage(63, LogLevel.Debug, "Not enabling read-only key access because an XML repository has been specified", EventName = "NotUsingReadOnlyKeyConfigurationBecauseOfRepository")]
248+
public static partial void NotUsingReadOnlyKeyConfigurationBecauseOfRepository(this ILogger logger);
249+
250+
[LoggerMessage(64, LogLevel.Debug, "Not enabling read-only key access because an XML encryptor has been specified", EventName = "NotUsingReadOnlyKeyConfigurationBecauseOfEncryptor")]
251+
public static partial void NotUsingReadOnlyKeyConfigurationBecauseOfEncryptor(this ILogger logger);
240252
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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.Xml.Linq;
5+
using Microsoft.AspNetCore.DataProtection.KeyManagement;
6+
using Microsoft.AspNetCore.DataProtection.Repositories;
7+
using Microsoft.AspNetCore.DataProtection.XmlEncryption;
8+
using Microsoft.Extensions.Configuration;
9+
using Microsoft.Extensions.Logging.Abstractions;
10+
using Microsoft.Extensions.Options;
11+
12+
namespace Microsoft.AspNetCore.DataProtection.Internal;
13+
14+
public class KeyManagementOptionsPostSetupTest
15+
{
16+
private static readonly string keyDir = new DirectoryInfo("/testpath").FullName;
17+
private static readonly XElement xElement = new("element");
18+
19+
[Fact]
20+
public void ConfigureReadOnly()
21+
{
22+
var config = new ConfigurationBuilder().AddInMemoryCollection(
23+
[
24+
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
25+
]).Build();
26+
27+
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);
28+
29+
var options = new KeyManagementOptions();
30+
31+
setup.PostConfigure(Options.DefaultName, options);
32+
33+
AssertReadOnly(options, keyDir);
34+
}
35+
36+
[Fact]
37+
public void ConfigureReadOnly_NonDefaultInstance()
38+
{
39+
var config = new ConfigurationBuilder().AddInMemoryCollection(
40+
[
41+
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
42+
]).Build();
43+
44+
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);
45+
46+
var options = new KeyManagementOptions();
47+
48+
setup.PostConfigure(Options.DefaultName + 1, options);
49+
50+
AssertNotReadOnly(options, keyDir);
51+
52+
Assert.True(options.AutoGenerateKeys);
53+
}
54+
55+
[Fact]
56+
public void ConfigureReadOnly_EmptyDirPath()
57+
{
58+
var config = new ConfigurationBuilder().AddInMemoryCollection(
59+
[
60+
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, ""),
61+
]).Build();
62+
63+
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);
64+
65+
var options = new KeyManagementOptions();
66+
67+
setup.PostConfigure(Options.DefaultName, options);
68+
69+
AssertNotReadOnly(options, keyDir);
70+
71+
Assert.True(options.AutoGenerateKeys);
72+
}
73+
74+
[Fact]
75+
public void ConfigureReadOnly_ExplicitRepository()
76+
{
77+
var config = new ConfigurationBuilder().AddInMemoryCollection(
78+
[
79+
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
80+
]).Build();
81+
82+
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);
83+
84+
var xmlDir = Directory.CreateTempSubdirectory();
85+
try
86+
{
87+
var options = new KeyManagementOptions()
88+
{
89+
XmlRepository = new FileSystemXmlRepository(xmlDir, NullLoggerFactory.Instance),
90+
};
91+
92+
setup.PostConfigure(Options.DefaultName, options);
93+
94+
AssertNotReadOnly(options, keyDir);
95+
96+
Assert.True(options.AutoGenerateKeys);
97+
}
98+
finally
99+
{
100+
xmlDir.Delete(recursive: true);
101+
}
102+
}
103+
104+
[Fact]
105+
public void ConfigureReadOnly_ExplicitEncryptor()
106+
{
107+
var config = new ConfigurationBuilder().AddInMemoryCollection(
108+
[
109+
new KeyValuePair<string, string>(KeyManagementOptionsPostSetup.ReadOnlyDataProtectionKeyDirectoryKey, keyDir),
110+
]).Build();
111+
112+
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);
113+
114+
var options = new KeyManagementOptions()
115+
{
116+
XmlEncryptor = new NullXmlEncryptor(),
117+
};
118+
119+
setup.PostConfigure(Options.DefaultName, options);
120+
121+
AssertNotReadOnly(options, keyDir);
122+
123+
Assert.True(options.AutoGenerateKeys);
124+
}
125+
126+
[Fact]
127+
public void NotConfigured_NoProperty()
128+
{
129+
var config = new ConfigurationBuilder().AddInMemoryCollection().Build();
130+
131+
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup(config, NullLoggerFactory.Instance);
132+
133+
var options = new KeyManagementOptions();
134+
135+
setup.PostConfigure(Options.DefaultName, options);
136+
137+
AssertNotReadOnly(options, keyDir);
138+
139+
Assert.True(options.AutoGenerateKeys);
140+
}
141+
142+
[Fact]
143+
public void NotConfigured_NoIConfiguration()
144+
{
145+
IPostConfigureOptions<KeyManagementOptions> setup = new KeyManagementOptionsPostSetup();
146+
147+
var options = new KeyManagementOptions();
148+
149+
setup.PostConfigure(Options.DefaultName, options);
150+
151+
AssertNotReadOnly(options, keyDir);
152+
153+
Assert.True(options.AutoGenerateKeys);
154+
}
155+
156+
private static void AssertReadOnly(KeyManagementOptions options, string keyDir)
157+
{
158+
// Effect 1: No key generation
159+
Assert.False(options.AutoGenerateKeys);
160+
161+
var repository = options.XmlRepository as FileSystemXmlRepository;
162+
Assert.NotNull(repository);
163+
164+
// Effect 2: Location from configuration
165+
Assert.Equal(keyDir, repository.Directory.FullName);
166+
167+
// Effect 3: No writing
168+
Assert.Throws<InvalidOperationException>(() => repository.StoreElement(xElement, friendlyName: null));
169+
170+
// Effect 4: No key encryption
171+
Assert.NotNull(options.XmlEncryptor);
172+
Assert.Throws<InvalidOperationException>(() => options.XmlEncryptor.Encrypt(xElement));
173+
}
174+
175+
private static void AssertNotReadOnly(KeyManagementOptions options, string keyDir)
176+
{
177+
// Missing effect 1: No key generation
178+
Assert.True(options.AutoGenerateKeys);
179+
180+
var repository = options.XmlRepository;
181+
if (repository is not null)
182+
{
183+
// Missing effect 2: Location from configuration
184+
Assert.NotEqual(keyDir, (repository as FileSystemXmlRepository)?.Directory.FullName);
185+
186+
// Missing effect 3: No writing
187+
repository.StoreElement(xElement, friendlyName: null);
188+
}
189+
190+
var encryptor = options.XmlEncryptor;
191+
if (encryptor is not null)
192+
{
193+
// Missing effect 4: No key encryption
194+
options.XmlEncryptor.Encrypt(xElement);
195+
}
196+
}
197+
}

0 commit comments

Comments
 (0)