|
| 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