Skip to content

Instantly share code, notes, and snippets.

@andreimerlescu
Created May 6, 2025 20:59
Show Gist options
  • Save andreimerlescu/c297b773641eb7f564b00a81fdf11c80 to your computer and use it in GitHub Desktop.
Save andreimerlescu/c297b773641eb7f564b00a81fdf11c80 to your computer and use it in GitHub Desktop.
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Amazon.Runtime;
using Amazon.Runtime.CredentialManagement;
using Amazon.IdentityManagement; // For generating the signature.
using Amazon.IdentityManagement.Model;
namespace VaultSecretsManager
{
/// <summary>
/// Represents the configuration needed to connect to HashiCorp Vault.
/// </summary>
public class VaultConfig
{
/// <summary>
/// The base URL of the Vault server. For HCP, this is provided by HashiCorp.
/// </summary>
public string Address { get; set; }
/// <summary>
/// The path to the KV secrets engine. For example, "secret".
/// </summary>
public string KvPath { get; set; } = "secret";
/// <summary>
/// The version of the KV secrets engine. 1 or 2. Defaults to 2.
/// </summary>
public int KvVersion { get; set; } = 2;
/// <summary>
/// The AWS Auth Mount Path.
/// </summary>
public string AwsAuthMountPath { get; set; } = "aws"; // Default to "aws"
/// <summary>
/// The role to authenticate against in Vault
/// </summary>
public string Role { get; set; }
}
/// <summary>
/// Represents a secret retrieved from Vault.
/// </summary>
public class VaultSecret
{
/// <summary>
/// Indicates whether the secret was retrieved successfully.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// The data of the secret, as a dictionary. Will be empty if Success is false.
/// </summary>
public Dictionary<string, string> Data { get; set; } = new Dictionary<string, string>();
/// <summary>
/// The raw JSON response from Vault. Useful for debugging.
/// </summary>
public string RawResponse { get; set; }
/// <summary>
/// An error message, if the secret was not retrieved successfully.
/// </summary>
public string ErrorMessage { get; set; }
}
/// <summary>
/// A client for interacting with HashiCorp Vault's KV secrets engine.
/// </summary>
public class VaultClient
{
private readonly VaultConfig _config;
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="VaultClient"/> class.
/// </summary>
/// <param name="config">The configuration for connecting to Vault.</param>
public VaultClient(VaultConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
if (string.IsNullOrEmpty(_config.Address))
{
throw new ArgumentException("Vault address must be specified.", nameof(config.Address));
}
if (string.IsNullOrEmpty(_config.Role))
{
throw new ArgumentException("Vault role must be specified for AWS auth.", nameof(config.Role));
}
_httpClient = new HttpClient();
}
private async Task<string> GetAWSTokenAsync()
{
AWSCredentials credentials = null;
// 1. Try to get credentials from the default chain (environment, config, instance profile)
try
{
credentials = new AWSCredentialsChain().GetAWSCredentials();
}
catch (AmazonServiceException e)
{
throw new Exception("Error getting AWS credentials from default chain: " + e.Message);
}
if (credentials == null)
{
throw new Exception("No AWS credentials found in the default chain.");
}
// 2. Get the required information for the GetCallerIdentity request
GetCallerIdentityRequest request = new GetCallerIdentityRequest();
GetCallerIdentityResponse response;
// 3. Create an IAM client
using (var client = new AmazonIdentityManagementServiceClient(credentials))
{
try
{
response = await client.GetCallerIdentityAsync(request);
}
catch (AmazonServiceException e)
{
throw new Exception("Error calling GetCallerIdentity: " + e.Message);
}
}
// 4. Construct the request payload for Vault
var requestData = new
{
role = _config.Role,
// Use the output from GetCallerIdentity
iam_http_request_method = "POST", // Vault wants the method.
iam_request_url = _config.Address + "/v1/auth/" + _config.AwsAuthMountPath + "/login", // Vault wants the full URL.
iam_request_body = "" , // Vault wants empty body,
iam_request_headers = new Dictionary<string, string>
{
{ "X-Vault-AWS-IAM-Server-ID", "vault.server" } // add the header.
},
identity = response.Arn
};
string jsonData = JsonSerializer.Serialize(requestData);
var content = new StringContent(jsonData, Encoding.UTF8, "application/json");
// 5. Send the authentication request to Vault
string authUrl = $"{_config.Address}/v1/auth/{_config.AwsAuthMountPath}/login";
HttpResponseMessage authResponse = await _httpClient.PostAsync(authUrl, content);
string authResponseContent = await authResponse.Content.ReadAsStringAsync();
if (!authResponse.IsSuccessStatusCode)
{
throw new Exception($"Error authenticating with Vault: {authResponse.StatusCode} - {authResponseContent}");
}
// 6. Parse the response and extract the client token
JsonDocument authDocument = JsonDocument.Parse(authResponseContent);
JsonElement authRoot = authDocument.RootElement;
if (authRoot.TryGetProperty("auth", out JsonElement authElement) &&
authElement.TryGetProperty("client_token", out JsonElement clientTokenElement))
{
return clientTokenElement.GetString();
}
else
{
throw new Exception("Failed to extract client token from Vault authentication response.");
}
}
private async Task EnsureVaultToken()
{
if (string.IsNullOrEmpty(_httpClient.DefaultRequestHeaders.Authorization?.Parameter))
{
string token = await GetAWSTokenAsync();
_httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
}
/// <summary>
/// Retrieves a secret from Vault.
/// </summary>
/// <param name="path">The path to the secret, including the customer namespace. For example, "my-customer/path/to/secret".</param>
/// <returns>A <see cref="VaultSecret"/> object containing the secret data or an error message.</returns>
public async Task<VaultSecret> GetSecretAsync(string path)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Path to the secret must be specified.", nameof(path));
}
await EnsureVaultToken(); // Ensure we have a valid Vault token.
string vaultPath;
if (_config.KvVersion == 2)
{
vaultPath = $"{_config.KvPath}/data/{path}";
}
else // KV version 1
{
vaultPath = $"{_config.KvPath}/{path}";
}
string url = $"{_config.Address}/v1/{vaultPath}";
try
{
HttpResponseMessage response = await _httpClient.GetAsync(url);
string rawResponse = await response.Content.ReadAsStringAsync(); // Store raw response
if (response.IsSuccessStatusCode)
{
JsonDocument document = JsonDocument.Parse(rawResponse);
JsonElement root = document.RootElement;
if (_config.KvVersion == 2)
{
if (root.TryGetProperty("data", out JsonElement dataElement) && dataElement.TryGetProperty("data", out JsonElement actualDataElement))
{
Dictionary<string, string> secretData = new Dictionary<string, string>();
foreach (JsonProperty property in actualDataElement.EnumerateObject())
{
secretData.Add(property.Name, property.Value.GetString());
}
return new VaultSecret
{
Success = true,
Data = secretData,
RawResponse = rawResponse
};
}
else
{
return new VaultSecret
{
Success = false,
ErrorMessage = "Failed to extract data from Vault response (KV v2).",
RawResponse = rawResponse
};
}
}
else // KV version 1
{
Dictionary<string, string> secretData = new Dictionary<string, string>();
foreach (JsonProperty property in root.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.Null)
continue;
secretData.Add(property.Name, property.Value.GetString());
}
return new VaultSecret
{
Success = true,
Data = secretData,
RawResponse = rawResponse
};
}
}
else
{
// Handle specific error cases based on status code (optional)
string errorMessage = $"Error retrieving secret from Vault: {response.StatusCode} - {rawResponse}";
return new VaultSecret
{
Success = false,
ErrorMessage = errorMessage,
RawResponse = rawResponse
};
}
}
catch (Exception ex)
{
return new VaultSecret
{
Success = false,
ErrorMessage = $"Exception: {ex.Message}",
RawResponse = null // No response to include, the call failed.
};
}
}
/// <summary>
/// Sets (creates or updates) a secret in Vault.
/// </summary>
/// <param name="path">The path to the secret, including the customer namespace. For example, "my-customer/path/to/secret".</param>
/// <param name="data">A dictionary containing the key-value pairs of the secret.</param>
/// <returns>A <see cref="VaultSecret"/> object indicating the result of the operation.</returns>
public async Task<VaultSecret> SetSecretAsync(string path, Dictionary<string, string> data)
{
if (string.IsNullOrEmpty(path))
{
throw new ArgumentException("Path to the secret must be specified.", nameof(path));
}
if (data == null || data.Count == 0)
{
throw new ArgumentException("Data must contain at least one key-value pair.", nameof(data));
}
await EnsureVaultToken(); // Ensure we have a valid Vault token.
string vaultPath;
string url;
StringContent content;
if (_config.KvVersion == 2)
{
vaultPath = $"{_config.KvPath}/data/{path}";
url = $"{_config.Address}/v1/{vaultPath}";
// Wrap the data in a "data" object as required by KV version 2
var requestData = new { data = data };
string jsonData = JsonSerializer.Serialize(requestData);
content = new StringContent(jsonData, Encoding.UTF8, "application/json");
}
else // KV version 1
{
vaultPath = $"{_config.KvPath}/{path}";
url = $"{_config.Address}/v1/{vaultPath}";
string jsonData = JsonSerializer.Serialize(data);
content = new StringContent(jsonData, Encoding.UTF8, "application/json");
}
try
{
HttpResponseMessage response = await _httpClient.PostAsync(url, content);
string rawResponse = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
return new VaultSecret
{
Success = true,
RawResponse = rawResponse
};
}
else
{
string errorMessage = $"Error setting secret in Vault: {response.StatusCode} - {rawResponse}";
return new VaultSecret
{
Success = false,
ErrorMessage = errorMessage,
RawResponse = rawResponse
};
}
}
catch (Exception ex)
{
return new VaultSecret
{
Success = false,
ErrorMessage = $"Exception: {ex.Message}",
RawResponse = null
};
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment