// 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));
}
}