// 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.Text.Json; using System.Web; using Amazon.SecretsManager; using Amazon.SecretsManager.Model; using Duplicati.Library.Interface; using Duplicati.Library.Utility; namespace Duplicati.Library.SecretProvider; /// /// A secret provider that retrieves secrets from AWS Secrets Manager /// public class AWSSecretProvider : ISecretProvider { private const string OPTION_PREFIX_EXTRA = "ext-"; /// public string Key => "awssm"; /// public string DisplayName => Strings.AWSSecretProvider.DisplayName; /// public string Description => Strings.AWSSecretProvider.Description; /// /// Constants for environment variables /// private static class EnvConstants { public const string AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID"; public const string AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY"; public const string AWS_DEFAULT_REGION = "AWS_DEFAULT_REGION"; public const string AWS_ENDPOINT_URL = "AWS_ENDPOINT_URL"; } /// /// Properties that are present in the specfic configuration and the general configuration /// private static readonly HashSet DuplicatedProperties = new[] { nameof(AmazonSecretsManagerConfig.RegionEndpoint), nameof(AmazonSecretsManagerConfig.ServiceURL) }.ToHashSet(); /// /// List of properties that slow down the loading of the AWS Secrets Manager client /// /// Changes in this list will likely need to be reflected in S3AwsClient.cs private static readonly HashSet SlowLoadingProperties = new[] { nameof(AmazonSecretsManagerConfig.RegionEndpoint), nameof(AmazonSecretsManagerConfig.ServiceURL), nameof(AmazonSecretsManagerConfig.MaxErrorRetry), nameof(AmazonSecretsManagerConfig.DefaultConfigurationMode), nameof(AmazonSecretsManagerConfig.Timeout), nameof(AmazonSecretsManagerConfig.RetryMode), }.ToHashSet(); /// public IList SupportedCommands => CommandLineArgumentMapper.MapArguments(new AWSSettings()) .Concat(CommandLineArgumentMapper.MapArguments(new AmazonSecretsManagerConfig(), OPTION_PREFIX_EXTRA, exclude: DuplicatedProperties, excludeDefaultValue: SlowLoadingProperties)) .ToList(); /// /// The AWS Secrets Manager client; null if not initialized /// private AmazonSecretsManagerClient? _client; /// /// The secrets to fetch /// private string[] _secrets = Array.Empty(); /// /// Whether the secrets are case sensitive /// private bool _caseSensitive; /// /// Settings for AWS /// private class AWSSettings : ICommandLineArgumentMapper { /// /// The access key /// public string? AccessKey { get; set; } /// /// The secret key /// public string? SecretKey { get; set; } /// /// The region endpoint /// public string? RegionEndpoint { get; set; } /// /// The service URL /// public string? ServiceURL { get; set; } /// /// The secrets to fetch /// public string? Secrets { 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(AccessKey) => new CommandLineArgumentDescriptionAttribute() { Name = "access-key", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.AWSSecretProvider.AccessKeyDescriptionShort, LongDescription = Strings.AWSSecretProvider.AccessKeyDescriptionLong(EnvConstants.AWS_ACCESS_KEY_ID) }, nameof(SecretKey) => new CommandLineArgumentDescriptionAttribute() { Name = "secret-key", Type = CommandLineArgument.ArgumentType.Password, ShortDescription = Strings.AWSSecretProvider.SecretKeyDescriptionShort, LongDescription = Strings.AWSSecretProvider.SecretKeyDescriptionLong(EnvConstants.AWS_SECRET_ACCESS_KEY) }, nameof(RegionEndpoint) => new CommandLineArgumentDescriptionAttribute() { Name = "region", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.AWSSecretProvider.RegionEndpointDescriptionShort, LongDescription = Strings.AWSSecretProvider.RegionEndpointDescriptionLong(EnvConstants.AWS_DEFAULT_REGION) }, nameof(ServiceURL) => new CommandLineArgumentDescriptionAttribute() { Name = "service-url", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.AWSSecretProvider.ServiceURLDescriptionShort, LongDescription = Strings.AWSSecretProvider.ServiceURLDescriptionLong(EnvConstants.AWS_ENDPOINT_URL) }, nameof(Secrets) => new CommandLineArgumentDescriptionAttribute() { Name = "secrets", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.AWSSecretProvider.SecretsDescriptionShort, LongDescription = Strings.AWSSecretProvider.SecretsDescriptionLong }, nameof(CaseSensitive) => new CommandLineArgumentDescriptionAttribute() { Name = "case-sensitive", Type = CommandLineArgument.ArgumentType.Boolean, ShortDescription = Strings.AWSSecretProvider.CaseSensitiveDescriptionShort, LongDescription = Strings.AWSSecretProvider.CaseSensitiveDescriptionLong }, _ => null }; /// CommandLineArgumentDescriptionAttribute? ICommandLineArgumentMapper.GetCommandLineArgumentDescription(MemberInfo mi) => GetCommandLineArgumentDescription(mi.Name); } /// /// Gets the name of the argument /// /// The name of the property /// The name of the argument private string ArgName(string name) => AWSSettings.GetCommandLineArgumentDescription(name)?.Name ?? name; /// public async Task InitializeAsync(System.Uri config, CancellationToken cancellationToken) { var args = HttpUtility.ParseQueryString(config.Query); var cred = CommandLineArgumentMapper.ApplyArguments(new AWSSettings(), args); if (string.IsNullOrWhiteSpace(cred.AccessKey)) cred.AccessKey = Environment.GetEnvironmentVariable(EnvConstants.AWS_ACCESS_KEY_ID); if (string.IsNullOrWhiteSpace(cred.SecretKey)) cred.SecretKey = Environment.GetEnvironmentVariable(EnvConstants.AWS_SECRET_ACCESS_KEY); if (string.IsNullOrWhiteSpace(cred.RegionEndpoint)) cred.RegionEndpoint = Environment.GetEnvironmentVariable(EnvConstants.AWS_DEFAULT_REGION); if (string.IsNullOrWhiteSpace(cred.ServiceURL)) cred.ServiceURL = Environment.GetEnvironmentVariable(EnvConstants.AWS_ENDPOINT_URL); if (string.IsNullOrWhiteSpace(cred.AccessKey) || string.IsNullOrWhiteSpace(cred.SecretKey)) throw new UserInformationException($"{ArgName(nameof(AWSSettings.AccessKey))} and {ArgName(nameof(AWSSettings.AccessKey))} are required for {DisplayName}", "AwssmMissingCredentials"); if (string.IsNullOrWhiteSpace(cred.RegionEndpoint) && string.IsNullOrWhiteSpace(cred.ServiceURL)) throw new UserInformationException($"Either {ArgName(nameof(AWSSettings.RegionEndpoint))} or {ArgName(nameof(AWSSettings.ServiceURL))} is required for {DisplayName}", "AwssmMissingRegionOrUrl"); if (string.IsNullOrWhiteSpace(cred.Secrets)) throw new UserInformationException($"{ArgName(nameof(AWSSettings.Secrets))} is required for {DisplayName}", "AwssmMissingSecrets"); _secrets = cred.Secrets.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); _caseSensitive = cred.CaseSensitive; var initssconf = new AmazonSecretsManagerConfig(); if (!string.IsNullOrWhiteSpace(cred.RegionEndpoint)) initssconf.RegionEndpoint = Amazon.RegionEndpoint.GetBySystemName(cred.RegionEndpoint); if (!string.IsNullOrWhiteSpace(cred.ServiceURL)) initssconf.ServiceURL = cred.ServiceURL; var scconfig = CommandLineArgumentMapper.ApplyArguments( initssconf, args, OPTION_PREFIX_EXTRA ); var credentials = new Amazon.Runtime.BasicAWSCredentials(cred.AccessKey, cred.SecretKey); var client = new AmazonSecretsManagerClient(credentials, scconfig); // Test the connection await client.ListSecretsAsync(new ListSecretsRequest() { MaxResults = 1 }, cancellationToken).ConfigureAwait(false); _client = client; } /// public async Task> ResolveSecretsAsync(IEnumerable keys, CancellationToken cancellationToken) { if (_client == null) throw new InvalidOperationException("The secret provider has not been initialized"); var result = new Dictionary(); var missing = new HashSet(keys); foreach (var secret in _secrets) { var response = await _client.GetSecretValueAsync(new GetSecretValueRequest() { SecretId = secret }, cancellationToken).ConfigureAwait(false); var secretString = response.SecretString; if (string.IsNullOrWhiteSpace(secretString) && response.SecretBinary != null) { using (var ms = response.SecretBinary) using (var sr = new StreamReader(ms)) secretString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); } var values = string.IsNullOrWhiteSpace(secretString) ? null : JsonSerializer.Deserialize>(response.SecretString); if (values != null) { if (!_caseSensitive) values = values .GroupBy(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase); foreach (var key in missing) { if (values.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)); } }