Skip to content

Commit c4d9df3

Browse files
author
Roman Golovanov
committed
add X509StoreRsaKeyProvider
1 parent 0ed3063 commit c4d9df3

6 files changed

Lines changed: 583 additions & 3 deletions

File tree

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ Property-level encryption for Entity Framework Core 8, 9, and 10. Mark the prope
88
- **Use it for:** PII, notes, tokens, small secrets, and values the database should never see in plaintext
99
- **Entity experience:** normal CLR properties for transparent reads, or `EncryptedValue<T>` when you want explicit async decryption
1010
- **Crypto shape:** AES-256-GCM payload encryption, a fresh content-encryption key per encrypted value, AES-GCM key wrapping, and RSA-wrapped key-encryption keys
11-
- **Key management:** file, in-memory, and Azure Key Vault RSA providers, plus in-memory or database-backed key-chain storage
11+
- **Key management:** file, OS certificate store, in-memory, and Azure Key Vault RSA providers, plus in-memory or database-backed key-chain storage
1212

1313
## Why This Package
1414

1515
Many EF Core encryption approaches stop at the first step: convert a property to ciphertext on save and back to plaintext on read. This package also handles the parts that usually become application-specific security plumbing:
1616

1717
- **Envelope encryption out of the box.** Each encrypted value gets its own content-encryption key. Content keys are wrapped by per-purpose key-encryption keys, and key-encryption keys are wrapped by an RSA provider.
1818
- **Key purposes and rotation.** Use separate key chains for different data classes, such as `email`, `notes`, or `tokens`, and rotate new writes without losing access to old rows.
19-
- **Production master key locations.** Keep the RSA wrapping key in a PEM file for self-hosted apps, in Azure Key Vault when the private key should stay outside the host, or in memory for tests and demos.
19+
- **Production master key locations.** Keep the RSA wrapping key in a PEM file, an OS certificate store, in Azure Key Vault when the private key should stay outside the host, or in memory for tests and demos.
2020
- **Database-backed key chain.** Store wrapped key records beside the application database, with one active key per purpose.
2121
- **Two entity styles.** Use ordinary CLR properties when transparency matters, or `EncryptedValue<T>` when you want decryption to be explicit and async at the call site.
2222
- **Typed values, not only strings.** Supported values include primitives, `string`, `byte[]`, `DateTime`, `DateTimeOffset`, `Guid`, enums, and nullable variants.
@@ -162,6 +162,24 @@ services.AddEncryptedProperties(cfg => cfg
162162

163163
The file provider creates the PEM file if it does not exist. Back it up and protect it like any other application secret.
164164

165+
### OS Certificate Store
166+
167+
```csharp
168+
services.AddEncryptedProperties(cfg => cfg
169+
.WithX509StoreRsaKeyProvider(options =>
170+
{
171+
options.CurrentCertificateThumbprint = "00112233445566778899AABBCCDDEEFF00112233";
172+
})
173+
.WithDatabaseKeyChain(SqlClientFactory.Instance, connectionString)
174+
.WithKeyChainPreloadOnStartup());
175+
```
176+
177+
By default, the provider reads from `CurrentUser\My`. New KEKs are wrapped with the configured current certificate thumbprint, and the stored KEK record keeps a self-describing RSA key ID such as `x509store:CurrentUser:My:{thumbprint}`. Historical KEKs unwrap by the stored thumbprint, so keep old certificates and their private keys in the store while any KEKs still reference them.
178+
179+
For Windows services, `LocalMachine\My` can be used when the service identity has private-key access. Prefer CNG-backed RSA certificates; older CAPI keys may not support `RSA-OAEP-256`. On Linux, prefer `CurrentUser\My`; `LocalMachine\My` is not a portable place for private-key certificates in .NET.
180+
181+
The provider does not export private keys from store.
182+
165183
### Azure Key Vault
166184

167185
```csharp
@@ -240,6 +258,8 @@ The key-chain table enforces one active KEK per purpose with a filtered unique i
240258

241259
Keep the RSA key stable. If the file key is deleted, replaced, or a different Key Vault key is configured, previously stored key-chain records may no longer unwrap.
242260

261+
For the OS certificate store provider, rotate RSA wrapping keys by provisioning a new certificate with a private key, deploying its thumbprint as `CurrentCertificateThumbprint`, and allowing KEK rotation to create new active KEKs. Keep previous certificates available for decrypt and startup preload until no `EncryptedPropertyKeks.RsaKeyId` values reference their thumbprints.
262+
243263
The library rotates data-encryption keys, but it does not automatically rotate the RSA master key.
244264

245265
### Plaintext Change Tracking

src/EfCore.EncryptedProperties/Extensions/EncryptedPropertiesServiceBuilder.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,19 @@ public EncryptedPropertiesServiceBuilder WithAzureKeyVaultRsaKeyProvider(
122122
return this;
123123
}
124124

125+
public EncryptedPropertiesServiceBuilder WithX509StoreRsaKeyProvider(
126+
Action<X509StoreRsaKeyProviderOptions> configure)
127+
{
128+
ArgumentNullException.ThrowIfNull(configure);
129+
130+
var options = new X509StoreRsaKeyProviderOptions();
131+
configure(options);
132+
133+
ReplaceSingleton<IRsaKeyProvider>(new X509StoreRsaKeyProvider(options));
134+
_rsaKeyProviderConfigured = true;
135+
return this;
136+
}
137+
125138
public EncryptedPropertiesServiceBuilder WithInMemoryKeyChain()
126139
{
127140
ReplaceSingleton<IKeyChainStorage>(new InMemoryKeyChainStorage());
@@ -168,7 +181,7 @@ internal void Validate()
168181
{
169182
if (!_rsaKeyProviderConfigured)
170183
throw new InvalidOperationException(
171-
"An RSA key provider must be configured via WithInMemoryRsaKeyProvider, WithFileRsaKeyProvider, or WithAzureKeyVaultRsaKeyProvider.");
184+
"An RSA key provider must be configured via WithInMemoryRsaKeyProvider, WithFileRsaKeyProvider, WithAzureKeyVaultRsaKeyProvider, or WithX509StoreRsaKeyProvider.");
172185

173186
if (!_keyChainStorageConfigured)
174187
throw new InvalidOperationException(
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
using System.Security.Cryptography;
2+
using System.Security.Cryptography.X509Certificates;
3+
using System.Text;
4+
using EfCore.EncryptedProperties.Abstractions;
5+
6+
namespace EfCore.EncryptedProperties.Providers;
7+
8+
public sealed class X509StoreRsaKeyProvider : IRsaKeyProvider
9+
{
10+
private const string KeyIdPrefix = "x509store";
11+
private const string AlgorithmName = "RSA-OAEP-256";
12+
private readonly X509StoreRsaKeyProviderOptions _options;
13+
private readonly string _currentCertificateThumbprint;
14+
15+
public X509StoreRsaKeyProvider(X509StoreRsaKeyProviderOptions options)
16+
{
17+
ArgumentNullException.ThrowIfNull(options);
18+
19+
if (!Enum.IsDefined(options.StoreLocation))
20+
throw new ArgumentOutOfRangeException(nameof(options.StoreLocation), "Store location is not valid.");
21+
22+
var storeName = NormalizeStoreName(options.StoreName);
23+
_currentCertificateThumbprint = NormalizeThumbprint(
24+
options.CurrentCertificateThumbprint,
25+
nameof(options.CurrentCertificateThumbprint));
26+
27+
_options = new X509StoreRsaKeyProviderOptions
28+
{
29+
StoreLocation = options.StoreLocation,
30+
StoreName = storeName,
31+
CurrentCertificateThumbprint = _currentCertificateThumbprint,
32+
ValidateCurrentCertificateTime = options.ValidateCurrentCertificateTime,
33+
ValidateKeyUsage = options.ValidateKeyUsage
34+
};
35+
36+
KeyId = CreateKeyId(_options.StoreLocation, _options.StoreName, _currentCertificateThumbprint);
37+
}
38+
39+
public string KeyId { get; }
40+
public string Algorithm => AlgorithmName;
41+
42+
public ValueTask<RsaKeyWrapResult> WrapKeyAsync(
43+
byte[] plaintext,
44+
CancellationToken cancellationToken = default)
45+
{
46+
ArgumentNullException.ThrowIfNull(plaintext);
47+
cancellationToken.ThrowIfCancellationRequested();
48+
49+
using var certificate = FindCertificate(
50+
_options.StoreLocation,
51+
_options.StoreName,
52+
_currentCertificateThumbprint);
53+
54+
ValidateCurrentCertificate(certificate);
55+
56+
using var publicKey = certificate.GetRSAPublicKey()
57+
?? throw CreateCertificateException(
58+
_options.StoreLocation,
59+
_options.StoreName,
60+
_currentCertificateThumbprint,
61+
"does not have an RSA public key");
62+
63+
var ciphertext = publicKey.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
64+
return new ValueTask<RsaKeyWrapResult>(
65+
new RsaKeyWrapResult(ciphertext, KeyId, Algorithm));
66+
}
67+
68+
public ValueTask<byte[]> UnwrapKeyAsync(
69+
byte[] ciphertext,
70+
string rsaKeyId,
71+
CancellationToken cancellationToken = default)
72+
{
73+
ArgumentNullException.ThrowIfNull(ciphertext);
74+
cancellationToken.ThrowIfCancellationRequested();
75+
76+
var keyId = ParseKeyId(rsaKeyId);
77+
using var certificate = FindCertificate(keyId.StoreLocation, keyId.StoreName, keyId.Thumbprint);
78+
using var privateKey = GetPrivateKey(certificate, keyId.StoreLocation, keyId.StoreName, keyId.Thumbprint);
79+
80+
var plaintext = privateKey.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
81+
return new ValueTask<byte[]>(plaintext);
82+
}
83+
84+
private void ValidateCurrentCertificate(X509Certificate2 certificate)
85+
{
86+
using var privateKey = GetPrivateKey(
87+
certificate,
88+
_options.StoreLocation,
89+
_options.StoreName,
90+
_currentCertificateThumbprint);
91+
92+
if (_options.ValidateCurrentCertificateTime)
93+
ValidateCertificateTime(certificate);
94+
95+
if (_options.ValidateKeyUsage)
96+
ValidateCertificateKeyUsage(certificate);
97+
}
98+
99+
private static RSA GetPrivateKey(
100+
X509Certificate2 certificate,
101+
StoreLocation storeLocation,
102+
string storeName,
103+
string thumbprint)
104+
{
105+
if (!certificate.HasPrivateKey)
106+
{
107+
throw CreateCertificateException(
108+
storeLocation,
109+
storeName,
110+
thumbprint,
111+
"does not have an RSA private key");
112+
}
113+
114+
try
115+
{
116+
return certificate.GetRSAPrivateKey()
117+
?? throw CreateCertificateException(
118+
storeLocation,
119+
storeName,
120+
thumbprint,
121+
"does not have an RSA private key");
122+
}
123+
catch (CryptographicException ex)
124+
{
125+
throw CreateCertificateException(
126+
storeLocation,
127+
storeName,
128+
thumbprint,
129+
"has an RSA private key, but it could not be opened",
130+
ex);
131+
}
132+
}
133+
134+
private void ValidateCertificateTime(X509Certificate2 certificate)
135+
{
136+
var now = DateTimeOffset.UtcNow;
137+
var notBefore = new DateTimeOffset(certificate.NotBefore.ToUniversalTime(), TimeSpan.Zero);
138+
var notAfter = new DateTimeOffset(certificate.NotAfter.ToUniversalTime(), TimeSpan.Zero);
139+
140+
if (now < notBefore || now > notAfter)
141+
{
142+
throw CreateCertificateException(
143+
_options.StoreLocation,
144+
_options.StoreName,
145+
_currentCertificateThumbprint,
146+
$"is not valid at the current time. Valid from {notBefore:O} to {notAfter:O}");
147+
}
148+
}
149+
150+
private void ValidateCertificateKeyUsage(X509Certificate2 certificate)
151+
{
152+
var keyUsage = certificate.Extensions
153+
.OfType<X509KeyUsageExtension>()
154+
.FirstOrDefault();
155+
156+
if (keyUsage is null)
157+
return;
158+
159+
const X509KeyUsageFlags allowed =
160+
X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment;
161+
162+
if ((keyUsage.KeyUsages & allowed) == 0)
163+
{
164+
throw CreateCertificateException(
165+
_options.StoreLocation,
166+
_options.StoreName,
167+
_currentCertificateThumbprint,
168+
"does not allow key encipherment or data encipherment");
169+
}
170+
}
171+
172+
private static X509Certificate2 FindCertificate(
173+
StoreLocation storeLocation,
174+
string storeName,
175+
string thumbprint)
176+
{
177+
using var store = new X509Store(storeName, storeLocation);
178+
try
179+
{
180+
store.Open(OpenFlags.ReadOnly);
181+
}
182+
catch (CryptographicException ex)
183+
{
184+
throw new InvalidOperationException(
185+
$"Could not open X.509 certificate store '{FormatStoreName(storeLocation, storeName)}'.",
186+
ex);
187+
}
188+
189+
var matches = store.Certificates.Find(
190+
X509FindType.FindByThumbprint,
191+
thumbprint,
192+
validOnly: false);
193+
194+
if (matches.Count == 0)
195+
{
196+
throw new InvalidOperationException(
197+
$"Certificate '{thumbprint}' was not found in X.509 certificate store '{FormatStoreName(storeLocation, storeName)}'.");
198+
}
199+
200+
return new X509Certificate2(matches[0]);
201+
}
202+
203+
private static ParsedKeyId ParseKeyId(string rsaKeyId)
204+
{
205+
if (string.IsNullOrWhiteSpace(rsaKeyId))
206+
throw new ArgumentException("RSA key ID cannot be null or whitespace.", nameof(rsaKeyId));
207+
208+
var parts = rsaKeyId.Split(':');
209+
if (parts.Length != 4 || !string.Equals(parts[0], KeyIdPrefix, StringComparison.Ordinal))
210+
{
211+
throw new InvalidOperationException(
212+
$"RSA key ID '{rsaKeyId}' is not an X.509 store key ID. Expected '{KeyIdPrefix}:{{StoreLocation}}:{{StoreName}}:{{Thumbprint}}'.");
213+
}
214+
215+
if (!Enum.TryParse<StoreLocation>(parts[1], ignoreCase: true, out var storeLocation)
216+
|| !Enum.IsDefined(storeLocation))
217+
{
218+
throw new InvalidOperationException($"RSA key ID '{rsaKeyId}' contains an invalid X.509 store location.");
219+
}
220+
221+
var storeName = NormalizeStoreName(parts[2]);
222+
var thumbprint = NormalizeThumbprint(parts[3], nameof(rsaKeyId));
223+
return new ParsedKeyId(storeLocation, storeName, thumbprint);
224+
}
225+
226+
private static string CreateKeyId(
227+
StoreLocation storeLocation,
228+
string storeName,
229+
string thumbprint)
230+
=> $"{KeyIdPrefix}:{storeLocation}:{storeName}:{thumbprint}";
231+
232+
private static string NormalizeStoreName(string? storeName)
233+
{
234+
if (string.IsNullOrWhiteSpace(storeName))
235+
throw new ArgumentException("X.509 store name cannot be null or whitespace.", nameof(storeName));
236+
237+
if (storeName.Contains(':', StringComparison.Ordinal))
238+
throw new ArgumentException("X.509 store name cannot contain ':'.", nameof(storeName));
239+
240+
return storeName.Trim();
241+
}
242+
243+
private static string NormalizeThumbprint(string? thumbprint, string parameterName)
244+
{
245+
if (string.IsNullOrWhiteSpace(thumbprint))
246+
throw new ArgumentException("Certificate thumbprint cannot be null or whitespace.", parameterName);
247+
248+
var builder = new StringBuilder(40);
249+
foreach (var ch in thumbprint)
250+
{
251+
if (char.IsWhiteSpace(ch) || ch is ':' or '-')
252+
continue;
253+
254+
if (!Uri.IsHexDigit(ch))
255+
{
256+
throw new ArgumentException(
257+
"Certificate thumbprint must contain only hexadecimal characters, whitespace, ':' or '-'.",
258+
parameterName);
259+
}
260+
261+
builder.Append(char.ToUpperInvariant(ch));
262+
}
263+
264+
if (builder.Length != 40)
265+
{
266+
throw new ArgumentException(
267+
"Certificate thumbprint must be a SHA-1 thumbprint containing 40 hexadecimal characters.",
268+
parameterName);
269+
}
270+
271+
return builder.ToString();
272+
}
273+
274+
private static InvalidOperationException CreateCertificateException(
275+
StoreLocation storeLocation,
276+
string storeName,
277+
string thumbprint,
278+
string message,
279+
Exception? innerException = null)
280+
{
281+
return new InvalidOperationException(
282+
$"Certificate '{thumbprint}' in X.509 certificate store '{FormatStoreName(storeLocation, storeName)}' {message}.",
283+
innerException);
284+
}
285+
286+
private static string FormatStoreName(StoreLocation storeLocation, string storeName)
287+
=> $"{storeLocation}\\{storeName}";
288+
289+
private sealed record ParsedKeyId(
290+
StoreLocation StoreLocation,
291+
string StoreName,
292+
string Thumbprint);
293+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using System.Security.Cryptography.X509Certificates;
2+
3+
namespace EfCore.EncryptedProperties.Providers;
4+
5+
public sealed class X509StoreRsaKeyProviderOptions
6+
{
7+
public StoreLocation StoreLocation { get; set; } = StoreLocation.CurrentUser;
8+
public string StoreName { get; set; } = "My";
9+
public string? CurrentCertificateThumbprint { get; set; }
10+
public bool ValidateCurrentCertificateTime { get; set; } = true;
11+
public bool ValidateKeyUsage { get; set; } = true;
12+
}

0 commit comments

Comments
 (0)