Skip to content

Commit 1204cee

Browse files
robgrameCopilot
andcommitted
feat(security): KeyVaultKekProvider for Azure-native operational slot (Phase 2)
Implements the Azure-native KEK provider promised in docs/ENCRYPTION-BRIEFING.md as the operational slot of the envelope encryption scheme. The DEK wrap now lives in Azure Key Vault via RSA-OAEP-256 wrapKey, never leaving the HSM-friendly key boundary; the persisted KekSlotMetadata.KeyId pins each wrap to the exact key version that produced it, so future KEK rotation cannot orphan existing rows. Changes - Core/Security/IKekProvider.cs: add ManagedIdentitySecret record so the registry can route Azure callers to the KV provider via secret-type match. - Functions/Security/KeyVaultKekProvider.cs: new sealed IKekProvider implementation with a thin IKeyVaultCryptoFacade test seam. Real impl AzureKeyVaultCryptoFacade caches the current versioned CryptographyClient for wrap and instantiates a fresh client per Unwrap to honor version pinning. - Functions/Program.cs: DI wiring. KeyVaultKekProvider registered first so KekProviderRegistry.GetDefaultFor picks it for the operational slot when a ManagedIdentitySecret is supplied; PbKdf2KekProvider stays registered so the BIP39 recovery break-glass slot is preserved on Azure too. - Functions.csproj: bump Azure.Identity 1.13.2 -> 1.21.0 to align with the transitive constraint imposed by Core and ServiceBus extensions. - Functions.Tests/Security/KeyVaultKekProviderTests.cs: 10 new tests using an in-memory RSA-backed IKeyVaultCryptoFacade. Covers round-trip, algorithm/secret/length validation, foreign provider metadata rejection, malformed KeyId URI, tampered wrap (OAEP padding fail), wrong key version unwrap (rotation safety), CanHandle predicate, and registry ordering. On-prem behavior unchanged: Worker and Web only register PbKdf2KekProvider, which produces byte-identical wraps to the legacy DataKeyWrapper + KekDerivation static helpers (proven by Phase 1 tests). Verification - dotnet build BitLockerKeyMonitor.Azure.slnx: 0 errors, 0 warnings - dotnet test BitLockerKeyMonitor.Azure.slnx: 76 passed (64 Core + 11 Functions + 1 HybridAgent) - dotnet test BitLockerKeyMonitor.slnx: 64 passed (on-prem regression) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d1f540f commit 1204cee

5 files changed

Lines changed: 392 additions & 0 deletions

File tree

src/BitLockerKeyMonitor.Core/Security/IKekProvider.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ public sealed record PassphraseSecret(string Passphrase) : KekSecret;
2525
/// <summary>256-bit recovery entropy decoded from a BIP39 24-word mnemonic. Used for the recovery slot.</summary>
2626
public sealed record RecoveryEntropySecret(ReadOnlyMemory<byte> Entropy) : KekSecret;
2727

28+
/// <summary>
29+
/// Signals to the provider registry that no caller-supplied secret material is available —
30+
/// the provider must rely on ambient authentication (e.g. Azure Managed Identity resolving
31+
/// to Key Vault via DefaultAzureCredential). Used exclusively by the operational slot on
32+
/// the Azure-native variant; the recovery slot continues to use <see cref="RecoveryEntropySecret"/>
33+
/// so the BIP39 break-glass mechanism is preserved on Azure too.
34+
/// </summary>
35+
public sealed record ManagedIdentitySecret : KekSecret;
36+
2837
/// <summary>
2938
/// Opaque metadata describing how a slot was wrapped, persisted in
3039
/// <see cref="Models.DbEncryptionState"/> alongside the wrapped bytes. Required to unwrap

src/BitLockerKeyMonitor.Functions/BitLockerKeyMonitor.Functions.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
<ItemGroup>
1313
<FrameworkReference Include="Microsoft.AspNetCore.App" />
14+
<PackageReference Include="Azure.Identity" Version="1.21.0" />
1415
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.10.0" />
1516
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
1617
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.ServiceBus" Version="5.24.0" />

src/BitLockerKeyMonitor.Functions/Program.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
using Azure.Identity;
12
using Azure.Monitor.OpenTelemetry.Exporter;
3+
using BitLockerKeyMonitor.Core.Security;
4+
using BitLockerKeyMonitor.Functions.Security;
25
using Microsoft.Azure.Functions.Worker;
36
using Microsoft.Azure.Functions.Worker.Builder;
47
using Microsoft.Azure.Functions.Worker.OpenTelemetry;
@@ -17,4 +20,27 @@
1720
.UseAzureMonitorExporter();
1821
}
1922

23+
// ---------------- KEK provider registry ----------------
24+
// Order matters: KeyVaultKekProvider is registered FIRST so KekProviderRegistry.GetDefaultFor
25+
// picks it for the operational slot when the caller supplies a ManagedIdentitySecret.
26+
// PbKdf2KekProvider is kept so the recovery slot can still use the BIP39 break-glass flow
27+
// (decoded mnemonic -> RecoveryEntropySecret -> PBKDF2 KEK -> AES-GCM wrap).
28+
29+
var kvUri = builder.Configuration["Encryption:KeyVault:Uri"]
30+
?? Environment.GetEnvironmentVariable("BLKMON__ENCRYPTION_KEYVAULT_URI");
31+
var kekName = builder.Configuration["Encryption:KeyVault:KeyName"]
32+
?? Environment.GetEnvironmentVariable("BLKMON__ENCRYPTION_KEYVAULT_KEYNAME")
33+
?? "blkmon-kek";
34+
35+
if (!string.IsNullOrEmpty(kvUri))
36+
{
37+
builder.Services.AddSingleton<IKeyVaultCryptoFacade>(_ =>
38+
new AzureKeyVaultCryptoFacade(new Uri(kvUri), kekName, new DefaultAzureCredential()));
39+
builder.Services.AddSingleton<IKekProvider, KeyVaultKekProvider>();
40+
}
41+
42+
builder.Services.AddSingleton<IKekProvider, PbKdf2KekProvider>();
43+
builder.Services.AddSingleton<IKekProviderRegistry, KekProviderRegistry>();
44+
2045
builder.Build().Run();
46+
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
using System.Security.Cryptography;
2+
using Azure.Core;
3+
using Azure.Security.KeyVault.Keys;
4+
using Azure.Security.KeyVault.Keys.Cryptography;
5+
using BitLockerKeyMonitor.Core.Security;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace BitLockerKeyMonitor.Functions.Security;
9+
10+
/// <summary>
11+
/// Azure Key Vault implementation of <see cref="IKekProvider"/>: wraps and unwraps the DEK
12+
/// using an HSM-friendly RSA key that never leaves Key Vault. Used by the Azure-native variant
13+
/// for the operational slot; the recovery slot continues to use <see cref="PbKdf2KekProvider"/>
14+
/// so the BIP39 break-glass mechanism is preserved.
15+
///
16+
/// <para><b>Wire format</b>: the wrap is the raw byte[] returned by Key Vault's RSA-OAEP-256
17+
/// wrap operation (currently 384 bytes for RSA 3072). The persisted <see cref="KekSlotMetadata.KeyId"/>
18+
/// captures the FULL versioned key identifier URI so a future rotation of the KEK in Key Vault
19+
/// does not break unwrap of already-persisted DEK wraps (each wrap is pinned to the key version
20+
/// that produced it).</para>
21+
///
22+
/// <para><b>AAD</b>: RSA-OAEP is asymmetric and does not have an AAD concept like AES-GCM.
23+
/// Slot binding for the Azure operational slot is enforced via <see cref="KekSlotMetadata.ProviderName"/>
24+
/// instead — the registry refuses to mix providers across slots.</para>
25+
///
26+
/// <para><b>Authentication</b>: callers pass a <see cref="ManagedIdentitySecret"/> marker;
27+
/// the underlying TokenCredential (typically <c>DefaultAzureCredential</c>) is what actually
28+
/// authenticates to Key Vault. The marker exists so the registry can match providers by
29+
/// secret type and so the abstraction stays uniform across PBKDF2 and KV paths.</para>
30+
/// </summary>
31+
public sealed class KeyVaultKekProvider : IKekProvider
32+
{
33+
/// <summary>Stable algorithm identifier persisted in <see cref="KekSlotMetadata.ProviderName"/>.</summary>
34+
public const string Algorithm = "AzureKeyVault-RSA-OAEP-256";
35+
36+
private readonly IKeyVaultCryptoFacade _facade;
37+
private readonly ILogger<KeyVaultKekProvider> _logger;
38+
39+
public KeyVaultKekProvider(IKeyVaultCryptoFacade facade, ILogger<KeyVaultKekProvider> logger)
40+
{
41+
_facade = facade ?? throw new ArgumentNullException(nameof(facade));
42+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
43+
}
44+
45+
public string ProviderName => Algorithm;
46+
47+
public bool CanHandle(KekWrapSlot slot, KekSecret secret) => secret is ManagedIdentitySecret;
48+
49+
public async Task<KekWrapResult> WrapAsync(byte[] dek, KekWrapSlot slot, KekSecret secret, CancellationToken ct = default)
50+
{
51+
if (dek is null || dek.Length != DataKeyWrapper.DekSize)
52+
throw new ArgumentException($"DEK must be {DataKeyWrapper.DekSize} bytes.", nameof(dek));
53+
if (secret is not ManagedIdentitySecret)
54+
throw new NotSupportedException($"{nameof(KeyVaultKekProvider)} requires {nameof(ManagedIdentitySecret)}; got '{secret?.GetType().Name ?? "<null>"}'.");
55+
56+
var (wrappedBytes, keyVersionUri) = await _facade.WrapKeyAsync(dek, ct).ConfigureAwait(false);
57+
58+
_logger.LogInformation(
59+
"KeyVault wrap completed for slot {Slot}: wrap size {WrapBytes} bytes, pinned to key {KeyId}",
60+
slot, wrappedBytes.Length, keyVersionUri);
61+
62+
return new KekWrapResult(
63+
wrappedBytes,
64+
new KekSlotMetadata(
65+
ProviderName: ProviderName,
66+
Salt: null,
67+
Iterations: null,
68+
KeyId: keyVersionUri));
69+
}
70+
71+
public async Task<byte[]> UnwrapAsync(byte[] wrappedBytes, KekWrapSlot slot, KekSlotMetadata metadata, KekSecret secret, CancellationToken ct = default)
72+
{
73+
if (wrappedBytes is null || wrappedBytes.Length == 0) throw new ArgumentException("Wrapped bytes must be non-empty.", nameof(wrappedBytes));
74+
if (metadata is null) throw new ArgumentNullException(nameof(metadata));
75+
if (!string.Equals(metadata.ProviderName, ProviderName, StringComparison.Ordinal))
76+
throw new ArgumentException($"Metadata provider '{metadata.ProviderName}' does not match this provider '{ProviderName}'.", nameof(metadata));
77+
if (string.IsNullOrEmpty(metadata.KeyId))
78+
throw new ArgumentException("KeyVault unwrap requires a versioned KeyId URI in metadata.", nameof(metadata));
79+
if (secret is not ManagedIdentitySecret)
80+
throw new NotSupportedException($"{nameof(KeyVaultKekProvider)} requires {nameof(ManagedIdentitySecret)}; got '{secret?.GetType().Name ?? "<null>"}'.");
81+
82+
Uri keyVersionUri;
83+
try { keyVersionUri = new Uri(metadata.KeyId, UriKind.Absolute); }
84+
catch (UriFormatException ex)
85+
{
86+
throw new ArgumentException($"KeyId '{metadata.KeyId}' is not a valid absolute URI.", nameof(metadata), ex);
87+
}
88+
89+
var dek = await _facade.UnwrapKeyAsync(keyVersionUri, wrappedBytes, ct).ConfigureAwait(false);
90+
if (dek.Length != DataKeyWrapper.DekSize)
91+
{
92+
throw new CryptographicException(
93+
$"KeyVault unwrap returned {dek.Length} bytes; expected {DataKeyWrapper.DekSize}. Wrong KEK or corrupted wrap.");
94+
}
95+
return dek;
96+
}
97+
}
98+
99+
/// <summary>
100+
/// Thin abstraction over Azure Key Vault crypto operations. Exists so unit tests can substitute
101+
/// an in-memory RSA implementation without touching the network. The real implementation
102+
/// <see cref="AzureKeyVaultCryptoFacade"/> calls <see cref="CryptographyClient"/>.
103+
/// </summary>
104+
public interface IKeyVaultCryptoFacade
105+
{
106+
/// <summary>Wrap the supplied 32-byte DEK with the configured KEK in Key Vault. Returns the wrap bytes plus the FULL versioned key URI that was used.</summary>
107+
Task<(byte[] WrappedBytes, string KeyVersionUri)> WrapKeyAsync(byte[] dek, CancellationToken ct);
108+
109+
/// <summary>Unwrap using the EXACT key version that was used to produce the wrap. Pinning to the version is what makes KEK rotation safe.</summary>
110+
Task<byte[]> UnwrapKeyAsync(Uri keyVersionUri, byte[] wrappedBytes, CancellationToken ct);
111+
}
112+
113+
/// <summary>
114+
/// Real Key Vault facade. Caches the version-resolved <see cref="CryptographyClient"/> for the
115+
/// current key version so successive wrap calls do not re-issue GetKey for each operation. A
116+
/// fresh CryptographyClient is created on UnwrapKey because callers may unwrap rows pinned to
117+
/// older key versions (rotation).
118+
/// </summary>
119+
public sealed class AzureKeyVaultCryptoFacade : IKeyVaultCryptoFacade
120+
{
121+
private readonly KeyClient _keyClient;
122+
private readonly TokenCredential _credential;
123+
private readonly string _keyName;
124+
private readonly KeyWrapAlgorithm _algorithm = KeyWrapAlgorithm.RsaOaep256;
125+
126+
private CryptographyClient? _cachedWrapClient;
127+
private string? _cachedKeyVersionUri;
128+
129+
public AzureKeyVaultCryptoFacade(Uri vaultUri, string keyName, TokenCredential credential)
130+
{
131+
if (vaultUri is null) throw new ArgumentNullException(nameof(vaultUri));
132+
ArgumentException.ThrowIfNullOrEmpty(keyName);
133+
_keyName = keyName;
134+
_credential = credential ?? throw new ArgumentNullException(nameof(credential));
135+
_keyClient = new KeyClient(vaultUri, credential);
136+
}
137+
138+
public async Task<(byte[] WrappedBytes, string KeyVersionUri)> WrapKeyAsync(byte[] dek, CancellationToken ct)
139+
{
140+
var (client, versionUri) = await EnsureCurrentVersionedClientAsync(ct).ConfigureAwait(false);
141+
var result = await client.WrapKeyAsync(_algorithm, dek, ct).ConfigureAwait(false);
142+
return (result.EncryptedKey, versionUri);
143+
}
144+
145+
public async Task<byte[]> UnwrapKeyAsync(Uri keyVersionUri, byte[] wrappedBytes, CancellationToken ct)
146+
{
147+
var client = new CryptographyClient(keyVersionUri, _credential);
148+
var result = await client.UnwrapKeyAsync(_algorithm, wrappedBytes, ct).ConfigureAwait(false);
149+
return result.Key;
150+
}
151+
152+
private async Task<(CryptographyClient Client, string VersionUri)> EnsureCurrentVersionedClientAsync(CancellationToken ct)
153+
{
154+
// Cheap fast path: cached version is still good for THIS process lifetime.
155+
// We deliberately do NOT auto-refresh on rotation — a key rotation in KV must trigger
156+
// a controlled re-wrap workflow (handled by DataKeyService rotation, Phase 2.x).
157+
if (_cachedWrapClient is not null && _cachedKeyVersionUri is not null)
158+
return (_cachedWrapClient, _cachedKeyVersionUri);
159+
160+
var keyResp = await _keyClient.GetKeyAsync(_keyName, cancellationToken: ct).ConfigureAwait(false);
161+
var versionUri = keyResp.Value.Id.ToString();
162+
var client = new CryptographyClient(new Uri(versionUri), _credential);
163+
_cachedWrapClient = client;
164+
_cachedKeyVersionUri = versionUri;
165+
return (client, versionUri);
166+
}
167+
}

0 commit comments

Comments
 (0)