Skip to content

Commit d1f540f

Browse files
robgrameCopilot
andcommitted
feat(security): introduce IKekProvider abstraction (Azure prep, Phase 1)
Foundation for the Azure-native KEK provider work: extracts a stable abstraction over KEK wrap/unwrap so the existing PBKDF2 path and a future Azure Key Vault path can be selected per slot without changing DataKeyService. New in Core/Security/: - IKekProvider: async Wrap/Unwrap with slot AAD binding and KekSecret variants (PassphraseSecret, RecoveryEntropySecret); persisted KekSlotMetadata carries provider name + (optional) PBKDF2 salt/iter + (future) Key Vault key URI. - PbKdf2KekProvider: drop-in implementation that delegates to the existing DataKeyWrapper + KekDerivation primitives, producing byte-identical wraps so existing DB rows continue to unwrap correctly. - KekProviderRegistry: name lookup for unwrap (provider chosen from persisted metadata) and capability-based default selection for new wraps (host DI order controls preference; Azure host will put KeyVaultKekProvider first). DI wiring: - Worker and Web hosts register PbKdf2KekProvider + KekProviderRegistry. - DataKeyService is NOT routed through the registry yet — that comes in Phase 2 alongside KeyVaultKekProvider so the refactor can be validated with both providers in hand. Tests (+14, total now 64 green): - Round-trip for both slots - BACKWARD COMPAT: provider unwraps wraps produced by the legacy static helpers (proves no DB migration is needed for existing on-prem installs) - Wrong passphrase / wrong slot AAD / mismatched provider metadata all throw CryptographicException or ArgumentException as appropriate - Registry: name resolution, default-for-slot selection, duplicate rejection Build green on both BitLockerKeyMonitor.slnx and BitLockerKeyMonitor.Azure.slnx. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aeac172 commit d1f540f

6 files changed

Lines changed: 467 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
namespace BitLockerKeyMonitor.Core.Security;
2+
3+
/// <summary>
4+
/// Identifies the slot a wrap is bound to. Re-uses the same enum semantics as
5+
/// <see cref="DataKeyWrapper.WrapSlot"/> so the slot AAD remains consistent between
6+
/// the primitive (AES-GCM wrap with slot-bound AAD) and the high-level provider API.
7+
/// </summary>
8+
public enum KekWrapSlot
9+
{
10+
Passphrase = 0,
11+
Recovery = 1
12+
}
13+
14+
/// <summary>
15+
/// Secret material supplied by the caller to a <see cref="IKekProvider"/>. The provider
16+
/// pattern-matches on the concrete type. Providers MAY support a subset (e.g. the future
17+
/// KeyVault provider will reject <see cref="PassphraseSecret"/> because the KEK is held
18+
/// in Key Vault and unwrapped via Managed Identity, not derived from a passphrase).
19+
/// </summary>
20+
public abstract record KekSecret;
21+
22+
/// <summary>Operator-supplied passphrase (UTF-8). Used for the operational slot on on-prem.</summary>
23+
public sealed record PassphraseSecret(string Passphrase) : KekSecret;
24+
25+
/// <summary>256-bit recovery entropy decoded from a BIP39 24-word mnemonic. Used for the recovery slot.</summary>
26+
public sealed record RecoveryEntropySecret(ReadOnlyMemory<byte> Entropy) : KekSecret;
27+
28+
/// <summary>
29+
/// Opaque metadata describing how a slot was wrapped, persisted in
30+
/// <see cref="Models.DbEncryptionState"/> alongside the wrapped bytes. Required to unwrap
31+
/// later. The provider name disambiguates which <see cref="IKekProvider"/> in the registry
32+
/// produced the wrap (and therefore knows how to read its metadata).
33+
///
34+
/// Phase 1 (PBKDF2): <see cref="Salt"/> + <see cref="Iterations"/> are populated, <see cref="KeyId"/> is null.
35+
/// Phase 2 (KeyVault): <see cref="KeyId"/> is populated with the versioned key URI, the others may be null.
36+
/// </summary>
37+
/// <param name="ProviderName">Stable identifier (e.g. "PBKDF2-SHA256", "AzureKeyVault-RSA-OAEP-256").</param>
38+
/// <param name="Salt">PBKDF2 salt, or null for non-KDF providers.</param>
39+
/// <param name="Iterations">PBKDF2 iteration count, or null for non-KDF providers.</param>
40+
/// <param name="KeyId">Key Vault versioned key identifier URI for KV providers, or null.</param>
41+
public sealed record KekSlotMetadata(
42+
string ProviderName,
43+
byte[]? Salt,
44+
int? Iterations,
45+
string? KeyId);
46+
47+
/// <summary>Result of wrapping a DEK. Both fields MUST be persisted to enable later unwrap.</summary>
48+
public sealed record KekWrapResult(byte[] WrappedBytes, KekSlotMetadata Metadata);
49+
50+
/// <summary>
51+
/// Abstraction for KEK-based DEK wrapping/unwrapping. Lets the same
52+
/// <see cref="Services.DataKeyService"/> orchestrator support multiple KEK custodians:
53+
///
54+
/// <list type="bullet">
55+
/// <item>PBKDF2 provider (on-prem default): derives KEK from passphrase or recovery entropy, AES-GCM wraps the DEK.</item>
56+
/// <item>Azure Key Vault provider (Azure-native, Phase 2): RSA wrap/unwrap via Managed Identity; KEK never leaves Key Vault.</item>
57+
/// </list>
58+
///
59+
/// The interface is async because Key Vault calls are network-bound; PBKDF2 implementations
60+
/// MAY return completed tasks synchronously (CPU-bound).
61+
/// </summary>
62+
public interface IKekProvider
63+
{
64+
/// <summary>Stable identifier matching <see cref="KekSlotMetadata.ProviderName"/>.</summary>
65+
string ProviderName { get; }
66+
67+
/// <summary>Whether this provider can serve the given slot with the given secret type. The registry uses this to pick the right provider per operation.</summary>
68+
bool CanHandle(KekWrapSlot slot, KekSecret secret);
69+
70+
/// <summary>
71+
/// Wrap a freshly-generated DEK. Returns the bytes to persist plus the metadata that
72+
/// must also be persisted so a later call to <see cref="UnwrapAsync"/> can succeed.
73+
/// </summary>
74+
Task<KekWrapResult> WrapAsync(byte[] dek, KekWrapSlot slot, KekSecret secret, CancellationToken ct = default);
75+
76+
/// <summary>
77+
/// Unwrap the DEK using the previously-persisted wrap + metadata + the caller-supplied secret.
78+
/// Throws <see cref="System.Security.Cryptography.CryptographicException"/> on tag/MAC failure
79+
/// (wrong secret, tampered wrap, wrong slot, wrong provider metadata).
80+
/// </summary>
81+
Task<byte[]> UnwrapAsync(byte[] wrappedBytes, KekWrapSlot slot, KekSlotMetadata metadata, KekSecret secret, CancellationToken ct = default);
82+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
namespace BitLockerKeyMonitor.Core.Security;
2+
3+
/// <summary>
4+
/// Registry for the set of <see cref="IKekProvider"/> implementations available in the host.
5+
/// <see cref="Services.DataKeyService"/> uses it to:
6+
///
7+
/// <list type="bullet">
8+
/// <item>Pick a provider for a NEW wrap (based on slot + secret type, via <see cref="GetDefaultFor"/>).</item>
9+
/// <item>Resolve the provider that produced an EXISTING wrap from the persisted provider name
10+
/// (<see cref="Get(string)"/>) so the right metadata interpretation is applied during unwrap.</item>
11+
/// </list>
12+
///
13+
/// Hosts register concrete providers in DI; the registry composes them.
14+
/// </summary>
15+
public interface IKekProviderRegistry
16+
{
17+
/// <summary>Resolve a provider by its <see cref="IKekProvider.ProviderName"/>. Throws if unknown.</summary>
18+
IKekProvider Get(string providerName);
19+
20+
/// <summary>
21+
/// Pick the provider responsible for wrapping a NEW slot given the supplied secret type.
22+
/// On-prem hosts return PbKdf2KekProvider for both slots; Azure hosts return KeyVault for
23+
/// the operational slot and PBKDF2 for the recovery slot (BIP39 break-glass preserved).
24+
/// </summary>
25+
IKekProvider GetDefaultFor(KekWrapSlot slot, KekSecret secret);
26+
}
27+
28+
/// <summary>
29+
/// Simple registry built from a fixed sequence of providers (DI list). The FIRST provider in the
30+
/// sequence whose <see cref="IKekProvider.CanHandle"/> returns true is considered the default
31+
/// for a (slot, secret) pair — so host startup controls preference order by DI ordering.
32+
/// </summary>
33+
public sealed class KekProviderRegistry : IKekProviderRegistry
34+
{
35+
private readonly Dictionary<string, IKekProvider> _byName;
36+
private readonly IReadOnlyList<IKekProvider> _ordered;
37+
38+
public KekProviderRegistry(IEnumerable<IKekProvider> providers)
39+
{
40+
ArgumentNullException.ThrowIfNull(providers);
41+
_ordered = providers.ToList();
42+
_byName = new Dictionary<string, IKekProvider>(StringComparer.Ordinal);
43+
foreach (var p in _ordered)
44+
{
45+
if (_byName.ContainsKey(p.ProviderName))
46+
throw new InvalidOperationException($"Duplicate IKekProvider registered with name '{p.ProviderName}'.");
47+
_byName.Add(p.ProviderName, p);
48+
}
49+
if (_byName.Count == 0)
50+
throw new InvalidOperationException("At least one IKekProvider must be registered.");
51+
}
52+
53+
public IKekProvider Get(string providerName)
54+
{
55+
ArgumentException.ThrowIfNullOrEmpty(providerName);
56+
if (_byName.TryGetValue(providerName, out var provider))
57+
return provider;
58+
throw new InvalidOperationException(
59+
$"No IKekProvider registered for provider name '{providerName}'. Registered providers: {string.Join(", ", _byName.Keys)}.");
60+
}
61+
62+
public IKekProvider GetDefaultFor(KekWrapSlot slot, KekSecret secret)
63+
{
64+
ArgumentNullException.ThrowIfNull(secret);
65+
foreach (var p in _ordered)
66+
{
67+
if (p.CanHandle(slot, secret))
68+
return p;
69+
}
70+
throw new InvalidOperationException(
71+
$"No registered IKekProvider can handle slot={slot} with secret type {secret.GetType().Name}.");
72+
}
73+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
namespace BitLockerKeyMonitor.Core.Security;
2+
3+
/// <summary>
4+
/// Default on-prem KEK provider: derives the KEK with PBKDF2-SHA256 from either the operational
5+
/// passphrase or the 256-bit recovery entropy, then wraps the DEK with AES-256-GCM via
6+
/// <see cref="DataKeyWrapper"/>. Produces byte-identical output to the pre-refactor implementation
7+
/// so existing DB rows continue to unwrap correctly without any migration.
8+
///
9+
/// <para>
10+
/// Iteration counts: operational passphrase uses <see cref="KekDerivation.DefaultPassphraseIterations"/>
11+
/// (600k, OWASP-2024 calibrated to ~200-400 ms on server CPUs). Recovery entropy uses
12+
/// <see cref="KekDerivation.DefaultRecoveryIterations"/> (100k — the entropy already carries 256
13+
/// bits so KDF stretching is cosmetic).
14+
/// </para>
15+
/// </summary>
16+
public sealed class PbKdf2KekProvider : IKekProvider
17+
{
18+
public string ProviderName => KekDerivation.PbKdf2Sha256;
19+
20+
public bool CanHandle(KekWrapSlot slot, KekSecret secret) => secret switch
21+
{
22+
PassphraseSecret => slot == KekWrapSlot.Passphrase,
23+
RecoveryEntropySecret => slot == KekWrapSlot.Recovery,
24+
_ => false
25+
};
26+
27+
public Task<KekWrapResult> WrapAsync(byte[] dek, KekWrapSlot slot, KekSecret secret, CancellationToken ct = default)
28+
{
29+
ct.ThrowIfCancellationRequested();
30+
if (dek is null || dek.Length != DataKeyWrapper.DekSize)
31+
throw new ArgumentException($"DEK must be {DataKeyWrapper.DekSize} bytes.", nameof(dek));
32+
33+
var salt = KekDerivation.GenerateSalt();
34+
var (kek, iterations) = DeriveKek(secret, salt);
35+
try
36+
{
37+
var wrappedBytes = DataKeyWrapper.Wrap(dek, kek, ToWrapperSlot(slot));
38+
var metadata = new KekSlotMetadata(
39+
ProviderName: ProviderName,
40+
Salt: salt,
41+
Iterations: iterations,
42+
KeyId: null);
43+
return Task.FromResult(new KekWrapResult(wrappedBytes, metadata));
44+
}
45+
finally
46+
{
47+
System.Security.Cryptography.CryptographicOperations.ZeroMemory(kek);
48+
}
49+
}
50+
51+
public Task<byte[]> UnwrapAsync(byte[] wrappedBytes, KekWrapSlot slot, KekSlotMetadata metadata, KekSecret secret, CancellationToken ct = default)
52+
{
53+
ct.ThrowIfCancellationRequested();
54+
if (wrappedBytes is null) throw new ArgumentNullException(nameof(wrappedBytes));
55+
if (metadata is null) throw new ArgumentNullException(nameof(metadata));
56+
if (!string.Equals(metadata.ProviderName, ProviderName, StringComparison.Ordinal))
57+
throw new ArgumentException($"Metadata provider '{metadata.ProviderName}' does not match this provider '{ProviderName}'.", nameof(metadata));
58+
if (metadata.Salt is null || metadata.Salt.Length != KekDerivation.SaltSize)
59+
throw new ArgumentException($"PBKDF2 unwrap requires a {KekDerivation.SaltSize}-byte salt in metadata.", nameof(metadata));
60+
if (metadata.Iterations is null || metadata.Iterations.Value < 1)
61+
throw new ArgumentException("PBKDF2 unwrap requires a positive iteration count in metadata.", nameof(metadata));
62+
63+
var kek = DeriveKek(secret, metadata.Salt, metadata.Iterations.Value);
64+
try
65+
{
66+
var dek = DataKeyWrapper.Unwrap(wrappedBytes, kek, ToWrapperSlot(slot));
67+
return Task.FromResult(dek);
68+
}
69+
finally
70+
{
71+
System.Security.Cryptography.CryptographicOperations.ZeroMemory(kek);
72+
}
73+
}
74+
75+
private static (byte[] kek, int iterations) DeriveKek(KekSecret secret, byte[] salt) => secret switch
76+
{
77+
PassphraseSecret p => (
78+
KekDerivation.DeriveFromPassphrase(p.Passphrase, salt, KekDerivation.DefaultPassphraseIterations),
79+
KekDerivation.DefaultPassphraseIterations),
80+
RecoveryEntropySecret r => (
81+
KekDerivation.DeriveFromRecoveryEntropy(r.Entropy.Span, salt, KekDerivation.DefaultRecoveryIterations),
82+
KekDerivation.DefaultRecoveryIterations),
83+
_ => throw new NotSupportedException($"PbKdf2KekProvider does not support secret type '{secret?.GetType().Name ?? "<null>"}'.")
84+
};
85+
86+
private static byte[] DeriveKek(KekSecret secret, byte[] salt, int iterations) => secret switch
87+
{
88+
PassphraseSecret p => KekDerivation.DeriveFromPassphrase(p.Passphrase, salt, iterations),
89+
RecoveryEntropySecret r => KekDerivation.DeriveFromRecoveryEntropy(r.Entropy.Span, salt, iterations),
90+
_ => throw new NotSupportedException($"PbKdf2KekProvider does not support secret type '{secret?.GetType().Name ?? "<null>"}'.")
91+
};
92+
93+
private static DataKeyWrapper.WrapSlot ToWrapperSlot(KekWrapSlot slot) => slot switch
94+
{
95+
KekWrapSlot.Passphrase => DataKeyWrapper.WrapSlot.Passphrase,
96+
KekWrapSlot.Recovery => DataKeyWrapper.WrapSlot.Recovery,
97+
_ => throw new ArgumentOutOfRangeException(nameof(slot))
98+
};
99+
}

src/BitLockerKeyMonitor.Web/Program.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@
151151
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Services.Interfaces.IDataKeyService,
152152
BitLockerKeyMonitor.Core.Services.DataKeyService>();
153153

154+
// KEK provider registry (mirror Worker registration so Web/Worker behave identically
155+
// for any future wrap/unwrap operation initiated from the portal).
156+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Security.IKekProvider,
157+
BitLockerKeyMonitor.Core.Security.PbKdf2KekProvider>();
158+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Security.IKekProviderRegistry,
159+
BitLockerKeyMonitor.Core.Security.KekProviderRegistry>();
160+
154161
// Blazor
155162
builder.Services.AddCascadingAuthenticationState();
156163
builder.Services.AddRazorComponents()

src/BitLockerKeyMonitor.Worker/Program.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@
7878
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Services.Interfaces.IDataKeyService,
7979
BitLockerKeyMonitor.Core.Services.DataKeyService>();
8080

81+
// KEK provider registry. On-prem hosts (Worker, Web) only register the PBKDF2 provider;
82+
// the Azure-native Functions host will additionally register KeyVaultKekProvider so the
83+
// operational slot can be wrapped by an HSM-backed key. The registry is consumed by
84+
// DataKeyService when the slot wrap/unwrap codepaths are routed through IKekProvider.
85+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Security.IKekProvider,
86+
BitLockerKeyMonitor.Core.Security.PbKdf2KekProvider>();
87+
builder.Services.AddSingleton<BitLockerKeyMonitor.Core.Security.IKekProviderRegistry,
88+
BitLockerKeyMonitor.Core.Security.KekProviderRegistry>();
89+
8190
// HttpClient + Polly v8 resilience pipeline for Microsoft Graph.
8291
//
8392
// NOTE on retry settings source-of-truth:

0 commit comments

Comments
 (0)