2026-04-16 15:21:24 +02:00
// Copyright (C) 2026, The Duplicati Team
2024-10-28 08:08:29 +01:00
// 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.
2024-10-24 15:56:39 +02:00
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 ;
/// <summary>
/// A secret provider that retrieves secrets from AWS Secrets Manager
/// </summary>
public class AWSSecretProvider : ISecretProvider
{
private const string OPTION_PREFIX_EXTRA = "ext-" ;
/// <inheritdoc/>
public string Key => "awssm" ;
/// <inheritdoc/>
public string DisplayName => Strings . AWSSecretProvider . DisplayName ;
/// <inheritdoc/>
public string Description => Strings . AWSSecretProvider . Description ;
2025-11-30 23:01:57 +01:00
/// <inheritdoc />
2025-12-01 15:29:24 +01:00
public Task < bool > IsSupported ( CancellationToken cancellationToken ) => Task . FromResult ( true );
2025-11-30 23:01:57 +01:00
/// <inheritdoc />
public bool IsSetSupported => true ;
2024-10-24 15:56:39 +02:00
/// <summary>
/// Constants for environment variables
/// </summary>
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" ;
}
2024-11-08 10:05:38 +01:00
/// <summary>
/// Properties that are present in the specfic configuration and the general configuration
/// </summary>
private static readonly HashSet < string > DuplicatedProperties = new [] {
nameof ( AmazonSecretsManagerConfig . RegionEndpoint ),
nameof ( AmazonSecretsManagerConfig . ServiceURL )
}. ToHashSet ();
/// <summary>
/// List of properties that slow down the loading of the AWS Secrets Manager client
/// </summary>
2024-11-15 14:02:39 +01:00
/// <remarks>Changes in this list will likely need to be reflected in S3AwsClient.cs</remarks>
2024-11-08 10:05:38 +01:00
private static readonly HashSet < string > SlowLoadingProperties = new [] {
nameof ( AmazonSecretsManagerConfig . RegionEndpoint ),
nameof ( AmazonSecretsManagerConfig . ServiceURL ),
nameof ( AmazonSecretsManagerConfig . MaxErrorRetry ),
nameof ( AmazonSecretsManagerConfig . DefaultConfigurationMode ),
nameof ( AmazonSecretsManagerConfig . Timeout ),
nameof ( AmazonSecretsManagerConfig . RetryMode ),
}. ToHashSet ();
2024-10-24 15:56:39 +02:00
/// <inheritdoc/>
public IList < ICommandLineArgument > SupportedCommands
=> CommandLineArgumentMapper . MapArguments ( new AWSSettings ())
2024-11-08 10:05:38 +01:00
. Concat ( CommandLineArgumentMapper . MapArguments ( new AmazonSecretsManagerConfig (), OPTION_PREFIX_EXTRA , exclude : DuplicatedProperties , excludeDefaultValue : SlowLoadingProperties ))
2024-10-24 15:56:39 +02:00
. ToList ();
/// <summary>
/// The AWS Secrets Manager client; null if not initialized
/// </summary>
private AmazonSecretsManagerClient ? _client ;
/// <summary>
/// The secrets to fetch
/// </summary>
private string [] _secrets = Array . Empty < string >();
/// <summary>
/// Whether the secrets are case sensitive
/// </summary>
private bool _caseSensitive ;
/// <summary>
/// Settings for AWS
/// </summary>
private class AWSSettings : ICommandLineArgumentMapper
{
/// <summary>
/// The access key
/// </summary>
2025-12-01 22:05:35 +01:00
public string? AccessId { get ; set ; }
2024-10-24 15:56:39 +02:00
/// <summary>
/// The secret key
/// </summary>
public string? SecretKey { get ; set ; }
/// <summary>
/// The region endpoint
/// </summary>
public string? RegionEndpoint { get ; set ; }
/// <summary>
/// The service URL
/// </summary>
public string? ServiceURL { get ; set ; }
/// <summary>
/// The secrets to fetch
/// </summary>
public string? Secrets { get ; set ; }
/// <summary>
/// Whether the secrets are case sensitive
/// </summary>
public bool CaseSensitive { get ; set ; }
/// <summary>
/// Gets the description for a command line argument
/// </summary>
/// <param name="name">The name of the argument</param>
/// <returns>The description for the argument</returns>
public static CommandLineArgumentDescriptionAttribute ? GetCommandLineArgumentDescription ( string name )
=> name switch
{
2025-12-01 22:05:35 +01:00
nameof ( AccessId ) => new CommandLineArgumentDescriptionAttribute () { Name = "access-id" , Type = CommandLineArgument . ArgumentType . String , ShortDescription = Strings . AWSSecretProvider . AccessKeyDescriptionShort , LongDescription = Strings . AWSSecretProvider . AccessKeyDescriptionLong ( EnvConstants . AWS_ACCESS_KEY_ID ) },
2024-10-24 15:56:39 +02:00
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
};
/// <inheritdoc/>
CommandLineArgumentDescriptionAttribute ? ICommandLineArgumentMapper . GetCommandLineArgumentDescription ( MemberInfo mi )
=> GetCommandLineArgumentDescription ( mi . Name );
}
/// <summary>
/// Gets the name of the argument
/// </summary>
/// <param name="name">The name of the property</param>
/// <returns>The name of the argument</returns>
private string ArgName ( string name ) => AWSSettings . GetCommandLineArgumentDescription ( name )?. Name ?? name ;
/// <inheritdoc/>
public async Task InitializeAsync ( System . Uri config , CancellationToken cancellationToken )
{
var args = HttpUtility . ParseQueryString ( config . Query );
var cred = CommandLineArgumentMapper . ApplyArguments ( new AWSSettings (), args );
2025-12-01 22:05:35 +01:00
if ( string . IsNullOrWhiteSpace ( cred . AccessId ))
cred . AccessId = Environment . GetEnvironmentVariable ( EnvConstants . AWS_ACCESS_KEY_ID );
2024-10-24 15:56:39 +02:00
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 );
2025-12-01 22:05:35 +01:00
if ( string . IsNullOrWhiteSpace ( cred . AccessId ) || string . IsNullOrWhiteSpace ( cred . SecretKey ))
throw new UserInformationException ( $"{ArgName(nameof(AWSSettings.AccessId))} and {ArgName(nameof(AWSSettings.SecretKey))} are required for {DisplayName}" , "AwssmMissingCredentials" );
2024-10-24 15:56:39 +02:00
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
);
2025-12-01 22:05:35 +01:00
var credentials = new Amazon . Runtime . BasicAWSCredentials ( cred . AccessId , cred . SecretKey );
2024-10-24 15:56:39 +02:00
var client = new AmazonSecretsManagerClient ( credentials , scconfig );
// Test the connection
await client . ListSecretsAsync ( new ListSecretsRequest ()
{
MaxResults = 1
}, cancellationToken ). ConfigureAwait ( false );
_client = client ;
}
/// <inheritdoc/>
public async Task < Dictionary < string , string >> ResolveSecretsAsync ( IEnumerable < string > keys , CancellationToken cancellationToken )
{
if ( _client == null )
throw new InvalidOperationException ( "The secret provider has not been initialized" );
2025-11-30 23:01:57 +01:00
var comparer = _caseSensitive ? StringComparer . Ordinal : StringComparer . OrdinalIgnoreCase ;
var result = new Dictionary < string , string >( comparer );
var missing = new HashSet < string >( keys , comparer );
2024-10-24 15:56:39 +02:00
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 )
{
2025-11-30 23:01:57 +01:00
using var ms = response . SecretBinary ;
using var sr = new StreamReader ( ms );
secretString = await sr . ReadToEndAsync ( cancellationToken ). ConfigureAwait ( false );
2024-10-24 15:56:39 +02:00
}
2025-11-30 23:01:57 +01:00
var values = string . IsNullOrWhiteSpace ( secretString ) ? null : JsonSerializer . Deserialize < Dictionary < string , string >>( secretString );
2024-10-24 15:56:39 +02:00
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 );
2025-11-30 23:01:57 +01:00
foreach ( var key in missing . ToList ())
2024-10-24 15:56:39 +02:00
{
2025-11-30 23:01:57 +01:00
if ( values . TryGetValue ( key , out var value ))
2024-10-24 15:56:39 +02:00
{
2025-11-30 23:01:57 +01:00
result [ key ] = value ;
2024-10-24 15:56:39 +02:00
missing . Remove ( key );
}
}
if ( missing . Count == 0 )
return result ;
}
}
2025-11-30 23:01:57 +01:00
foreach ( var key in missing . ToList ())
{
try
{
var response = await _client . GetSecretValueAsync ( new GetSecretValueRequest
{
SecretId = key
}, cancellationToken ). ConfigureAwait ( false );
var secretString = response . SecretString ;
if ( string . IsNullOrEmpty ( secretString ) && response . SecretBinary != null )
{
using var ms = response . SecretBinary ;
using var sr = new StreamReader ( ms );
secretString = await sr . ReadToEndAsync ( cancellationToken ). ConfigureAwait ( false );
}
if (! string . IsNullOrEmpty ( secretString ))
{
result [ key ] = secretString ;
missing . Remove ( key );
}
}
catch ( ResourceNotFoundException )
{
// Ignore and continue looking for other keys
}
}
if ( missing . Count > 0 )
throw new KeyNotFoundException ( "The following keys were not found: " + string . Join ( ", " , missing ));
return result ;
}
2024-10-24 15:56:39 +02:00
2025-11-30 23:01:57 +01:00
/// <inheritdoc/>
public async Task SetSecretAsync ( string key , string value , bool overwrite , CancellationToken cancellationToken )
{
if ( _client is null )
throw new InvalidOperationException ( "The secret provider has not been initialized" );
2025-12-01 22:05:35 +01:00
if ( _secrets is null || _secrets . Length == 0 )
throw new InvalidOperationException ( "No base secret has been configured for the AWS secret provider" );
// Use the first configured secret as the backing store for all keys.
var containerSecretId = _secrets [ 0 ];
GetSecretValueResponse ? existingResponse = null ;
var containerExists = true ;
2025-11-30 23:01:57 +01:00
try
{
2025-12-01 22:05:35 +01:00
existingResponse = await _client . GetSecretValueAsync ( new GetSecretValueRequest
2025-11-30 23:01:57 +01:00
{
2025-12-01 22:05:35 +01:00
SecretId = containerSecretId
2025-11-30 23:01:57 +01:00
}, cancellationToken ). ConfigureAwait ( false );
}
catch ( ResourceNotFoundException )
{
2025-12-01 22:05:35 +01:00
containerExists = false ;
2025-11-30 23:01:57 +01:00
}
2025-12-01 22:05:35 +01:00
var comparer = _caseSensitive ? StringComparer . Ordinal : StringComparer . OrdinalIgnoreCase ;
var values = new Dictionary < string , string >( comparer );
if ( containerExists && existingResponse is not null )
{
var secretString = existingResponse . SecretString ;
if ( string . IsNullOrWhiteSpace ( secretString ) && existingResponse . SecretBinary is not null )
{
using var ms = existingResponse . SecretBinary ;
using var sr = new StreamReader ( ms );
secretString = await sr . ReadToEndAsync ( cancellationToken ). ConfigureAwait ( false );
}
if (! string . IsNullOrWhiteSpace ( secretString ))
{
try
{
var deserialized = JsonSerializer . Deserialize < Dictionary < string , string >>( secretString )
?? new Dictionary < string , string >( comparer );
if (! _caseSensitive )
{
deserialized = deserialized
. GroupBy ( x => x . Key , x => x . Value , StringComparer . OrdinalIgnoreCase )
. ToDictionary ( x => x . Key , x => x . First (), StringComparer . OrdinalIgnoreCase );
}
values = new Dictionary < string , string >( deserialized , comparer );
}
catch ( JsonException )
{
// If the existing payload is not JSON, start fresh with an empty dictionary.
values = new Dictionary < string , string >( comparer );
}
}
}
if (! overwrite && values . ContainsKey ( key ))
2025-12-01 15:39:21 +01:00
throw new UserInformationException ( $"The key '{key}' already exists" , "KeyAlreadyExists" );
2025-11-30 23:01:57 +01:00
2025-12-01 22:05:35 +01:00
values [ key ] = value ;
var newSecretString = JsonSerializer . Serialize ( values );
if ( containerExists )
2025-11-30 23:01:57 +01:00
{
await _client . PutSecretValueAsync ( new PutSecretValueRequest
{
2025-12-01 22:05:35 +01:00
SecretId = containerSecretId ,
SecretString = newSecretString
2025-11-30 23:01:57 +01:00
}, cancellationToken ). ConfigureAwait ( false );
}
else
{
await _client . CreateSecretAsync ( new CreateSecretRequest
{
2025-12-01 22:05:35 +01:00
Name = containerSecretId ,
SecretString = newSecretString
2025-11-30 23:01:57 +01:00
}, cancellationToken ). ConfigureAwait ( false );
}
2024-10-24 15:56:39 +02:00
}
}