using Azure.Security.KeyVault.Secrets; using Core.Blueprint.KeyVault.Configuration; using Microsoft.Extensions.Configuration; using System.Net.Http.Json; using VaultSharp; using VaultSharp.Core; using VaultSharp.V1.AuthMethods.Token; namespace Core.Blueprint.KeyVault; /// /// Provides operations for managing secrets in Azure Key Vault or HashiCorp Vault transparently based on the environment. /// public sealed class KeyVaultProvider : IKeyVaultProvider { private readonly string environment; private readonly SecretClient? azureClient; private readonly IVaultClient? hashiClient; private readonly VaultOptions? hashiOptions; public KeyVaultProvider(IConfiguration configuration) { environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; if (environment == "Local") { hashiOptions = configuration.GetSection("Vault").Get(); hashiClient = new VaultClient(new VaultClientSettings( hashiOptions?.Address, new TokenAuthMethodInfo(hashiOptions?.Token) )); } else { var keyVaultUri = new Uri(configuration["ConnectionStrings:KeyVaultDAL"]!); azureClient = new SecretClient(keyVaultUri, new Azure.Identity.DefaultAzureCredential()); } } /// /// Creates a new secret in Azure Key Vault or HashiCorp Vault. /// public async ValueTask CreateSecretAsync(KeyVaultRequest keyVaultRequest, CancellationToken cancellationToken) { if (environment == "Local") { await hashiClient!.V1.Secrets.KeyValue.V2.WriteSecretAsync( path: keyVaultRequest.Name, data: new Dictionary { { "value", keyVaultRequest.Value } }, mountPoint: hashiOptions!.SecretMount ); return new KeyVaultResponse { Name = keyVaultRequest.Name, Value = keyVaultRequest.Value }; } KeyVaultSecret azureResponse = await azureClient!.SetSecretAsync( new KeyVaultSecret(keyVaultRequest.Name, keyVaultRequest.Value), cancellationToken ); return new KeyVaultResponse { Name = azureResponse.Name, Value = azureResponse.Value }; } /// /// Permanently deletes a secret from Azure Key Vault or HashiCorp Vault (hard delete for Vault). /// /// The name of the secret to delete. /// The cancellation token to cancel the operation. /// /// A containing a status message and a boolean indicating whether the secret was successfully deleted. /// public async ValueTask<(string Message, bool Deleted)> DeleteSecretAsync(string secretName, CancellationToken cancellationToken) { if (environment == "Local") { await DestroyAllSecretVersionsAsync(secretName, cancellationToken); } var existingSecret = await this.GetSecretAsync(secretName, cancellationToken); if (existingSecret.Item2 == string.Empty) { await azureClient!.StartDeleteSecretAsync(secretName, cancellationToken); return new("Key Deleted", true); } return new("Key Not Found", false); } /// /// Retrieves a secret from Azure Key Vault or HashiCorp Vault. /// public async ValueTask<(KeyVaultResponse Secret, string? Message)> GetSecretAsync(string secretName, CancellationToken cancellationToken) { if (environment == "Local") { try { var secret = await hashiClient!.V1.Secrets.KeyValue.V2.ReadSecretAsync( path: secretName, mountPoint: hashiOptions!.SecretMount ); if (secret.Data.Data.TryGetValue("value", out var value)) { return new(new KeyVaultResponse { Name = secretName, Value = value?.ToString() ?? "" }, string.Empty); } return new(new KeyVaultResponse(), "Key Not Found"); } catch (VaultSharp.Core.VaultApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) { return new(new KeyVaultResponse { }, "Key Not Found"); } } try { KeyVaultSecret azureResponse = await azureClient!.GetSecretAsync(secretName, cancellationToken: cancellationToken); return new(new KeyVaultResponse { Name = secretName, Value = azureResponse.Value }, string.Empty); } catch (Azure.RequestFailedException ex) when (ex.Status == 404) { return new(new KeyVaultResponse(), "Key Not Found"); } } /// /// Updates an existing secret in Azure Key Vault or HashiCorp Vault. If the secret does not exist, an error is returned. /// public async ValueTask<(KeyVaultResponse Secret, string? Message)> UpdateSecretAsync(KeyVaultRequest newSecret, CancellationToken cancellationToken) { var existingSecret = await this.GetSecretAsync(newSecret.Name, cancellationToken); if (!string.IsNullOrEmpty(existingSecret.Item2)) { return new(new KeyVaultResponse(), "Key Not Found"); } var updated = await CreateSecretAsync(newSecret, cancellationToken); return new(updated, string.Empty); } /// /// Permanently deletes all versions of a given secret in HashiCorp Vault. /// Returns a tuple indicating the result status and a message. /// /// The secret name/path. /// A cancellation token. /// /// A tuple: /// - bool?: true if deleted, false if no versions, null if not found. /// - string: message explaining the result. /// private async Task<(bool? WasDeleted, string Message)> DestroyAllSecretVersionsAsync(string secretName, CancellationToken cancellationToken) { Dictionary versions; try { var metadata = await hashiClient!.V1.Secrets.KeyValue.V2.ReadSecretMetadataAsync( path: secretName, mountPoint: hashiOptions!.SecretMount ); versions = metadata.Data.Versions.Keys.ToDictionary(k => k, _ => (object)0); if (versions.Count == 0) return (false, "Key exists but contains no versions."); } catch (VaultApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) { return (null, "Key Not Found."); } using var httpClient = new HttpClient { BaseAddress = new Uri(hashiOptions.Address) }; var request = new HttpRequestMessage(HttpMethod.Post, $"/v1/{hashiOptions.SecretMount}/destroy/{secretName}") { Content = JsonContent.Create(new { versions = versions.Keys.ToArray() }) }; request.Headers.Add("X-Vault-Token", hashiOptions.Token); var response = await httpClient.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); await hashiClient.V1.Secrets.KeyValue.V2.DeleteMetadataAsync( path: secretName, mountPoint: hashiOptions.SecretMount ); return (true, "Key Permanently Deleted."); } }