// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Reflection; using System.Web; using Duplicati.Library.Interface; using Duplicati.Library.Utility; using VaultSharp; using VaultSharp.V1.AuthMethods; using VaultSharp.V1.AuthMethods.AppRole; using VaultSharp.V1.AuthMethods.Token; namespace Duplicati.Library.SecretProvider; /// /// A secret provider that retrieves secrets from HashiCorp Vault /// public class HCVaultSecretProvider : ISecretProvider { /// public string Key => "hcv"; /// public string DisplayName => Strings.HCVaultSecretProvider.DisplayName; /// public string Description => Strings.HCVaultSecretProvider.Description; /// /// The configuration for the secret provider; null if not initialized /// private IVaultClient? _client; /// /// The list of secrets to fetch /// private IReadOnlyList? _secrets; /// /// Whether the secrets are case sensitive /// private bool _caseSensitive; /// /// The mount point to use /// private string? _mountPoint; /// /// Constants for environment variables /// private static class EnvConstants { /// /// The client ID for the HashiCorp Vault /// public const string HCP_CLIENT_ID = "HCP_CLIENT_ID"; /// /// The client secret for the HashiCorp Vault /// public const string HCP_CLIENT_SECRET = "HCP_CLIENT_SECRET"; } /// /// The connection types /// public enum ConnectionType { /// /// Use HTTPS /// Https, /// /// Use HTTP /// Http }; /// /// Mapper for the command line arguments /// private class HCVaultSettings : ICommandLineArgumentMapper { /// /// The token to use for authentication /// public string? Token { get; set; } /// /// The connection type to use /// public ConnectionType ConnectionType { get; set; } = ConnectionType.Https; /// /// The secrets to probe for values /// public string? Secrets { get; set; } /// /// The mount point for the secrets /// public string? MountPoint { get; set; } = "secret"; /// /// The client ID to use for authentication /// public string? ClientId { get; set; } /// /// The client secret to use for authentication /// public string? ClientSecret { get; set; } /// /// Whether the secrets are case sensitive /// public bool CaseSensitive { get; set; } /// /// Gets the description for a command line argument /// /// The name of the argument /// The description for the argument public static CommandLineArgumentDescriptionAttribute? GetCommandLineArgumentDescription(string name) => name switch { nameof(Token) => new CommandLineArgumentDescriptionAttribute() { Name = "token", Type = CommandLineArgument.ArgumentType.Password, ShortDescription = Strings.HCVaultSecretProvider.TokenDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.TokenDescriptionLong }, nameof(ConnectionType) => new CommandLineArgumentDescriptionAttribute() { Name = "connection-type", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.ProtocolDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.ProtocolDescriptionLong }, nameof(Secrets) => new CommandLineArgumentDescriptionAttribute() { Name = "secrets", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.SecretsDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.SecretsDescriptionLong }, nameof(ClientId) => new CommandLineArgumentDescriptionAttribute() { Name = "client-id", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.ClientIdDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.ClientIdDescriptionLong(EnvConstants.HCP_CLIENT_ID) }, nameof(ClientSecret) => new CommandLineArgumentDescriptionAttribute() { Name = "client-secret", Type = CommandLineArgument.ArgumentType.Password, ShortDescription = Strings.HCVaultSecretProvider.ClientSecretDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.ClientSecretDescriptionLong(EnvConstants.HCP_CLIENT_SECRET) }, nameof(MountPoint) => new CommandLineArgumentDescriptionAttribute() { Name = "mount", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.HCVaultSecretProvider.MountPointDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.MountPointDescriptionLong }, nameof(CaseSensitive) => new CommandLineArgumentDescriptionAttribute() { Name = "case-sensitive", Type = CommandLineArgument.ArgumentType.Boolean, ShortDescription = Strings.HCVaultSecretProvider.CaseSensitiveDescriptionShort, LongDescription = Strings.HCVaultSecretProvider.CaseSensitiveDescriptionLong }, _ => null }; /// CommandLineArgumentDescriptionAttribute? ICommandLineArgumentMapper.GetCommandLineArgumentDescription(MemberInfo mi) => GetCommandLineArgumentDescription(mi.Name); } /// public IList SupportedCommands => CommandLineArgumentMapper.MapArguments(new HCVaultSettings()) .Concat(CommandLineArgumentMapper.MapArguments(typeof(VaultClientSettings))) .ToList(); /// /// Gets the name of the argument /// /// The name of the argument /// The name of the argument private string ArgName(string name) => HCVaultSettings.GetCommandLineArgumentDescription(name)?.Name ?? name; /// public async Task InitializeAsync(System.Uri config, CancellationToken cancellationToken) { var args = HttpUtility.ParseQueryString(config.Query); var cfg = CommandLineArgumentMapper.ApplyArguments(new HCVaultSettings(), args); if (string.IsNullOrWhiteSpace(cfg.ClientId)) cfg.ClientId = Environment.GetEnvironmentVariable(EnvConstants.HCP_CLIENT_ID); if (string.IsNullOrWhiteSpace(cfg.ClientSecret)) cfg.ClientSecret = Environment.GetEnvironmentVariable(EnvConstants.HCP_CLIENT_SECRET); if (string.IsNullOrWhiteSpace(cfg.ClientSecret) && !string.IsNullOrWhiteSpace(cfg.ClientId)) throw new UserInformationException($"{ArgName(nameof(HCVaultSettings.ClientSecret))} is required when {ArgName(nameof(HCVaultSettings.ClientId))} is specified", "MissingClientSecret"); if (string.IsNullOrWhiteSpace(cfg.Token) && string.IsNullOrWhiteSpace(cfg.ClientId)) throw new UserInformationException($"Either {ArgName(nameof(HCVaultSettings.Token))} or {ArgName(nameof(HCVaultSettings.ClientId))} is required", "MissingTokenOrClient"); if (string.IsNullOrWhiteSpace(cfg.Secrets)) throw new UserInformationException($"{ArgName(nameof(HCVaultSettings.Secrets))} is required", "MissingSecrets"); var secrets = cfg.Secrets?.Split(new char[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? Array.Empty(); var builder = new UriBuilder(config) { Scheme = cfg.ConnectionType == ConnectionType.Http ? "http" : "https", Query = null }; IAuthMethodInfo authMethod = string.IsNullOrWhiteSpace(cfg.Token) ? new AppRoleAuthMethodInfo(cfg.ClientId, cfg.ClientSecret) : new TokenAuthMethodInfo(cfg.Token); var vaultConfig = CommandLineArgumentMapper.ApplyArguments( new VaultClientSettings(builder.Uri.ToString(), authMethod), args ); var client = new VaultClient(vaultConfig); // Check if the connection works await client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path: secrets.First(), mountPoint: cfg.MountPoint).ConfigureAwait(false); //missing cancellationToken _secrets = secrets; _mountPoint = cfg.MountPoint; _caseSensitive = cfg.CaseSensitive; _client = client; } /// public async Task> ResolveSecretsAsync(IEnumerable keys, CancellationToken cancellationToken) { if (_client is null || _secrets is null) throw new InvalidOperationException("The secret provider has not been initialized"); using var client = new HttpClient(); var result = new Dictionary(); var missing = new HashSet(keys); // Keep trying to get the secrets from each URL until all keys are found foreach (var secret in _secrets) { var data = await _client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path: secret, mountPoint: _mountPoint).ConfigureAwait(false); //mssing cancellationToken if (data is null || data.Data is null) continue; var lookupDict = data.Data.Data; if (!_caseSensitive) lookupDict = lookupDict .GroupBy(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); foreach (var key in missing) { if (lookupDict.TryGetValue(key, out var value) && value is string stringValue) { result[key] = stringValue; missing.Remove(key); } } if (missing.Count == 0) return result; } throw new KeyNotFoundException("The following keys were not found: " + string.Join(", ", missing)); } }