From 3fb601d14354c867e1ead94b027c99c4a2fc15b5 Mon Sep 17 00:00:00 2001 From: vman Date: Wed, 16 Nov 2022 14:07:28 -0500 Subject: Add project files. --- VNLib.Plugins.Extensions.Loading/VaultSecrets.cs | 302 +++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 VNLib.Plugins.Extensions.Loading/VaultSecrets.cs (limited to 'VNLib.Plugins.Extensions.Loading/VaultSecrets.cs') diff --git a/VNLib.Plugins.Extensions.Loading/VaultSecrets.cs b/VNLib.Plugins.Extensions.Loading/VaultSecrets.cs new file mode 100644 index 0000000..3a35a8e --- /dev/null +++ b/VNLib.Plugins.Extensions.Loading/VaultSecrets.cs @@ -0,0 +1,302 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +using VaultSharp; +using VaultSharp.V1.Commons; +using VaultSharp.V1.AuthMethods; +using VaultSharp.V1.AuthMethods.Token; +using VaultSharp.V1.AuthMethods.AppRole; + +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Extensions.Loading.Configuration; +using System.Security.Cryptography.X509Certificates; +using VaultSharp.V1.SecretsEngines.PKI; +using System.Text; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// + /// Adds loading extensions for secure/centralized configuration secrets + /// + public static class PluginSecretLoading + { + public const string VAULT_OBJECT_NAME = "hashicorp_vault"; + public const string SECRETS_CONFIG_KEY = "secrets"; + public const string VAULT_TOKEN_KEY = "token"; + public const string VAULT_ROLE_KEY = "role"; + public const string VAULT_SECRET_KEY = "secret"; + + public const string VAULT_URL_KEY = "url"; + + public const string VAULT_URL_SCHEME = "vault://"; + + + private static readonly ConditionalWeakTable> _vaults = new(); + + /// + /// + /// Gets a secret from the "secrets" element. + /// + /// + /// Secrets elements are merged from the host config and plugin local config 'secrets' element. + /// before searching. The plugin config takes precedence over the host config. + /// + /// + /// + /// The name of the secret propery to get + /// The element from the configuration file with the given name, or null if the configuration or property does not exist + /// + /// + public static Task TryGetSecretAsync(this PluginBase plugin, string secretName) + { + //Get the secret from the config file raw + string? rawSecret = TryGetSecretInternal(plugin, secretName); + if (rawSecret == null) + { + return Task.FromResult(null); + } + + //Secret is a vault path, or return the raw value + if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(rawSecret); + } + return GetSecretFromVaultAsync(plugin, rawSecret); + } + + /// + /// Gets a secret at the given vault url (in the form of "vault://[mount-name]/[secret-path]?secret=[secret_name]") + /// + /// + /// The raw vault url to lookup + /// The string of the object at the specified vault path + /// + /// + /// + public static Task GetSecretFromVaultAsync(this PluginBase plugin, ReadOnlySpan vaultPath) + { + //print the path for debug + if (plugin.IsDebug()) + { + plugin.Log.Debug("Retrieving secret {s} from vault", vaultPath.ToString()); + } + + //Slice off path + ReadOnlySpan paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); + ReadOnlySpan path = paq.SliceBeforeParam('?'); + ReadOnlySpan query = paq.SliceAfterParam('?'); + + if (paq.IsEmpty) + { + throw new UriFormatException("Vault secret location not valid/empty "); + } + //Get the secret + string secretTableKey = query.SliceAfterParam("secret=").SliceBeforeParam('&').ToString(); + string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); + + //get mount and path + int lastSep = path.IndexOf('/'); + string mount = path[..lastSep].ToString(); + string secret = path[(lastSep + 1)..].ToString(); + + async Task execute() + { + //Try load client + IVaultClient? client = _vaults.GetValue(plugin, TryGetVaultLoader).Value; + + _ = client ?? throw new KeyNotFoundException("Vault client not found"); + //run read async + Secret result = await client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path:secret, mountPoint:mount); + //Read the secret + return result.Data.Data[secretTableKey].ToString(); + } + + return Task.Run(execute); + } + + /// + /// + /// Gets a Certicate from the "secrets" element. + /// + /// + /// Secrets elements are merged from the host config and plugin local config 'secrets' element. + /// before searching. The plugin config takes precedence over the host config. + /// + /// + /// + /// The name of the secret propery to get + /// The element from the configuration file with the given name, or null if the configuration or property does not exist + /// + /// + public static Task TryGetCertificateAsync(this PluginBase plugin, string secretName) + { + //Get the secret from the config file raw + string? rawSecret = TryGetSecretInternal(plugin, secretName); + if (rawSecret == null) + { + return Task.FromResult(null); + } + + //Secret is a vault path, or return the raw value + if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new (rawSecret)); + } + return GetCertFromVaultAsync(plugin, rawSecret); + } + + public static Task GetCertFromVaultAsync(this PluginBase plugin, ReadOnlySpan vaultPath, CertificateCredentialsRequestOptions? options = null) + { + //print the path for debug + if (plugin.IsDebug()) + { + plugin.Log.Debug("Retrieving certificate {s} from vault", vaultPath.ToString()); + } + + //Slice off path + ReadOnlySpan paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); + ReadOnlySpan path = paq.SliceBeforeParam('?'); + ReadOnlySpan query = paq.SliceAfterParam('?'); + + if (paq.IsEmpty) + { + throw new UriFormatException("Vault secret location not valid/empty "); + } + + //Get the secret + string role = query.SliceAfterParam("role=").SliceBeforeParam('&').ToString(); + string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); + string commonName = query.SliceBeforeParam("cn=").SliceBeforeParam('&').ToString(); + + //get mount and path + int lastSep = path.IndexOf('/'); + string mount = path[..lastSep].ToString(); + string secret = path[(lastSep + 1)..].ToString(); + + async Task execute() + { + //Try load client + IVaultClient? client = _vaults.GetValue(plugin, TryGetVaultLoader).Value; + + _ = client ?? throw new KeyNotFoundException("Vault client not found"); + + options ??= new() + { + CertificateFormat = CertificateFormat.pem, + PrivateKeyFormat = PrivateKeyFormat.pkcs8, + CommonName = commonName, + }; + + //run read async + Secret result = await client.V1.Secrets.PKI.GetCredentialsAsync(pkiRoleName:secret, certificateCredentialRequestOptions:options, pkiBackendMountPoint:mount); + //Read the secret + byte[] pemCertData = Encoding.UTF8.GetBytes(result.Data.CertificateContent); + + return new (pemCertData); + } + + return Task.Run(execute); + } + + /// + /// Gets the ambient vault client for the current plugin + /// if the configuration is loaded, null otherwise + /// + /// + /// The ambient if loaded, null otherwise + /// + /// + public static IVaultClient? GetVault(this PluginBase plugin) => _vaults.GetValue(plugin, TryGetVaultLoader).Value; + + private static string? TryGetSecretInternal(PluginBase plugin, string secretName) + { + bool local = plugin.PluginConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement localEl); + bool host = plugin.HostConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement hostEl); + + //total config + IReadOnlyDictionary? conf; + + if (local && host) + { + //Load both config objects to dict + Dictionary localConf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + Dictionary hostConf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + + //merge the two configs + foreach(KeyValuePair lc in localConf) + { + //Overwrite any host config keys, plugin conf takes priority + hostConf[lc.Key] = lc.Value; + } + //set the merged config + conf = hostConf; + } + else if(local) + { + //Store only local config + conf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + } + else if(host) + { + //store only host config + conf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + } + else + { + conf = null; + } + + //Get the value or default json element + return conf != null && conf.TryGetValue(secretName, out JsonElement el) ? el.GetString() : null; + } + + private static Lazy TryGetVaultLoader(PluginBase pbase) + { + //Local func to load the vault client + IVaultClient? LoadVault() + { + //Get vault config + IReadOnlyDictionary? conf = pbase.TryGetConfig(VAULT_OBJECT_NAME); + + if(conf == null) + { + return null; + } + + //try get servre address creds from config + string? serverAddress = conf[VAULT_URL_KEY].GetString() ?? throw new KeyNotFoundException($"Failed to load the key {VAULT_URL_KEY} from object {VAULT_OBJECT_NAME}"); + + IAuthMethodInfo authMethod; + + //Get authentication method from config + if (conf.TryGetValue(VAULT_TOKEN_KEY, out JsonElement tokenEl)) + { + //Init token + authMethod = new TokenAuthMethodInfo(tokenEl.GetString()); + } + else if(conf.TryGetValue(VAULT_ROLE_KEY, out JsonElement roleEl) && conf.TryGetValue(VAULT_SECRET_KEY, out JsonElement secretEl)) + { + authMethod = new AppRoleAuthMethodInfo(roleEl.GetString(), secretEl.GetString()); + } + else + { + throw new KeyNotFoundException($"Failed to load the vault authentication method from {VAULT_OBJECT_NAME}"); + } + + //Settings + VaultClientSettings settings = new(serverAddress, authMethod); + + //create vault client + return new VaultClient(settings); + } + //init lazy + return new (LoadVault, LazyThreadSafetyMode.PublicationOnly); + } + } +} -- cgit