// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Duplicati.Library.DynamicLoader;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
using Duplicati.Library.Utility;
using Google.Protobuf.WellKnownTypes;
namespace Duplicati.Library.Main;
///
/// Helper to apply secret provider to arguments
///
public static class SecretProviderHelper
{
///
/// The log tag
///
private static readonly string LOGTAG = Log.LogTagFromType();
///
/// The default pattern to use for matching
///
public const string DEFAULT_PATTERN = "$";
///
/// The different levels of caching permitted for the secrets
///
public enum CachingLevel
{
///
/// Values are always fetched from the provider
///
None,
///
/// Values are cached in memory and used if the provider is not available
///
InMemory,
///
/// Values are cached in memory and saved to disk with encryption.
/// If the provider is not available, the values are fetched from disk.
///
Persistent
}
///
/// Creates an instance of a secret provider with caching enabled
///
/// The configuration string
/// The caching level
/// The folder to persist the cache to
/// The salt to use for hashing
/// The pattern to use for matching
/// The cancellation token
/// The secret provider instance
public static async Task CreateInstanceAsync(string config, CachingLevel cachingLevel, string persistedFolder, string salt, string pattern, CancellationToken cancelToken)
{
var provider = SecretProviderLoader.CreateInstance(config);
var sp = WrapWithCache(config, provider, cachingLevel, persistedFolder, salt, pattern);
await sp.InitializeAsync(new System.Uri(config), cancelToken).ConfigureAwait(false);
return sp;
}
///
/// Wraps a secret provider with caching
///
/// The configuration string
/// The provider to wrap
/// The caching level
/// The folder to persist the cache to
/// The salt to use for hashing
/// The pattern to use for matching
/// The wrapped secret provider
public static ISecretProvider WrapWithCache(string config, ISecretProvider provider, CachingLevel cachingLevel, string persistedFolder, string salt, string? pattern)
=> new SecretProviderCached(config, provider, cachingLevel, persistedFolder, salt, pattern);
///
/// Applies the secret provider to the arguments.
/// Note that this method modifes the arguments and options in place.
///
/// The arguments to modify, of type
/// The arguments to modify, of type
/// The options to modify
/// The persisted secret cache folder
/// The fallback provider to use if no provider is specified
/// The cancellation token
/// The secret provider
public static async Task ApplySecretProviderAsync(System.Uri?[] realUriArguments, Library.Utility.Uri[] internalUriArguments, Dictionary options, string persistedFolder, ISecretProvider? fallbackProvider, CancellationToken cancellationToken)
{
var provider = options.GetValueOrDefault("secret-provider");
if (string.IsNullOrWhiteSpace(provider) && fallbackProvider == null)
return null;
var pattern = options.GetValueOrDefault("secret-provider-pattern");
ISecretProvider secretProvider;
if (string.IsNullOrWhiteSpace(provider))
{
secretProvider = fallbackProvider
?? throw new InvalidOperationException("No secret provider specified");
}
else
{
var newProvider = SecretProviderLoader.CreateInstance(provider);
// Weak salt, but semi-static
string salt;
using (var hasher = HashFactory.CreateHasher(HashFactory.SHA256))
salt = Environment.MachineName.ComputeHashToHex(hasher);
var cachingLevel = Library.Utility.Utility.ParseEnumOption(options, "secret-provider-cache", CachingLevel.None);
secretProvider = WrapWithCache(provider, newProvider, cachingLevel, persistedFolder, salt, pattern);
await secretProvider.InitializeAsync(new System.Uri(provider), cancellationToken).ConfigureAwait(false);
}
if (string.IsNullOrWhiteSpace(pattern) && secretProvider is SecretProviderCached cached)
pattern = cached.Pattern;
if (string.IsNullOrWhiteSpace(pattern))
pattern = DEFAULT_PATTERN;
await ReplaceSecretsAsync(secretProvider, realUriArguments, internalUriArguments, options, pattern, cancellationToken).ConfigureAwait(false);
return secretProvider;
}
///
/// Helper method that finds all secrets matching the prefix and replaces them with the resolved values
///
/// The secret provider to use
/// The arguments to modify, of type
/// The arguments to modify, of type
/// Any options to update
/// The prefix to look for
/// The cancellation token
/// An awaitable task
public static async Task ReplaceSecretsAsync(ISecretProvider provider, System.Uri?[] realUriArguments, Library.Utility.Uri[] internalUriArguments, Dictionary options, string matchpattern, CancellationToken cancelToken)
{
// Unwrap ${} to support ${name is long}
var suffix = string.Empty;
var matcher = @"(\w|/)";
if (matchpattern.EndsWith("{}") || matchpattern.EndsWith("()") || matchpattern.EndsWith("[]"))
{
suffix = matchpattern[^1..];
matchpattern = matchpattern[..^1];
matcher = @"[^" + Regex.Escape(suffix) + "]";
}
// For the values, they could be urls, so we need to look inside the strings
var pattern = new Regex(@$"{Regex.Escape(matchpattern)}(?{matcher}+){Regex.Escape(suffix)}", RegexOptions.ExplicitCapture);
// When we get the secrets, replace these values
var optionsMap = options
.Where(x => !x.Key.StartsWith("secret-provider", StringComparison.OrdinalIgnoreCase))
.Select(x => (x.Key, Secret: GetKey(x.Value, pattern)))
.Where(x => !string.IsNullOrWhiteSpace(x.Secret))
.GroupBy(x => x.Secret!)
.ToDictionary(x => x.Key, x => x.Select(y => y.Key).ToArray());
var realUriMap = realUriArguments
.Zip(Enumerable.Range(0, realUriArguments.Length))
.Where(x => !string.IsNullOrWhiteSpace(x.First?.Query))
.Select(x => (Source: x.Second, Params: HttpUtility.ParseQueryString(x.First!.Query)))
.SelectMany(x => x.Params.AllKeys.Select(k => (Source: x.Source, Key: k, Value: x.Params[k])))
.Select(x => (x.Source, x.Key, Secret: GetKey(x.Value, pattern)))
.Where(x => !string.IsNullOrWhiteSpace(x.Secret))
.GroupBy(x => x.Secret!)
.ToDictionary(x => x.Key, x => x.Select(y => (y.Source, y.Key)).ToArray());
var internalUriMap = internalUriArguments
.Zip(Enumerable.Range(0, internalUriArguments.Length))
.Select(x => (Source: x.Second, Value: x.First))
.Where(x => !string.IsNullOrWhiteSpace(x.Value.Query))
.Select(x => (Source: x.Source, Params: x.Value.QueryParameters))
.SelectMany(x => x.Params.AllKeys.Select(k => (Source: x.Source, Key: k, Value: x.Params[k])))
.Select(x => (x.Source, x.Key, Secret: GetKey(x.Value, pattern)))
.Where(x => !string.IsNullOrWhiteSpace(x.Secret))
.GroupBy(x => x.Secret!)
.ToDictionary(x => x.Key, x => x.Select(y => (y.Source, y.Key)).ToArray());
var secrets = realUriMap.Keys
.Concat(internalUriMap.Keys)
.Concat(optionsMap.Keys)
.Distinct()
.ToArray();
if (secrets.Length == 0)
return;
var translated = await provider.ResolveSecretsAsync(secrets, cancelToken).ConfigureAwait(false);
// Sanity check the results to guard against faulty providers
if (translated.Any(x => string.IsNullOrWhiteSpace(x.Value)))
throw new InvalidOperationException("The secret provider returned an empty key");
// Update options by replacing values
foreach (var v in optionsMap)
foreach (var k in v.Value)
options[k] = translated[v.Key];
// Update real uri arguments by replacing values
foreach (var v in realUriMap)
foreach (var (s, k) in v.Value)
{
var builder = new UriBuilder(realUriArguments[s]!);
var query = HttpUtility.ParseQueryString(builder.Query);
query[k] = translated[v.Key];
builder.Query = query.ToString();
realUriArguments[s] = builder.Uri;
}
// Update internal uri arguments by replacing values
foreach (var v in internalUriMap)
foreach (var (s, k) in v.Value)
{
var uri = internalUriArguments[s];
var kp = uri.QueryParameters;
kp[k] = Library.Utility.Uri.UrlEncode(translated[v.Key]);
uri = uri.SetQuery(Library.Utility.Uri.BuildUriQuery(kp));
internalUriArguments[s] = uri;
}
return;
}
///
/// Gets the key from a value using the pattern
///
/// The value to get the key from
/// The pattern to use
/// The key or null if not found
private static string? GetKey(string? value, Regex pattern)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var match = pattern.Match(value);
if (!match.Success || string.IsNullOrWhiteSpace(match.Groups["key"].Value) || match.Length != value.Length)
return null;
return match.Groups["key"].Value;
}
///
/// A cache for secret provider values
///
private class SecretProviderCached : ISecretProvider
{
///
/// The provider being cached
///
private readonly ISecretProvider _provider;
///
/// A flag indicating if the provider has been initialized
///
private bool _initialized;
///
/// The caching level
///
private readonly CachingLevel _cachingLevel;
///
/// The configuration string
///
private readonly string _config;
///
/// The salt used for hashing and uniqueness
///
private readonly string _salt;
///
/// The persisted file; null if not persistent
///
private readonly string? _persistedFile;
///
/// The passphrase used to encrypt the persisted file
///
private readonly string? _passphrase;
///
/// The lock object guarding _cache
///
private static readonly object _lock = new();
///
/// The in-memory cache of secrets
///
private static readonly Dictionary> _cache = new();
///
/// The pattern associated with the provider
///
public string Pattern { get; }
///
/// Creates a new instance of the secret provider cache
///
/// The configuration string
/// The provider to cache
/// The caching level
/// The folder to persist the cache to
/// The salt to use for hashing
/// The pattern to use for matching
public SecretProviderCached(string config, ISecretProvider provider, CachingLevel cachingLevel, string persistedFolder, string salt, string? pattern)
{
_provider = provider;
_cachingLevel = cachingLevel;
_config = config;
_salt = salt;
Pattern = pattern ?? DEFAULT_PATTERN;
if (cachingLevel == CachingLevel.Persistent)
{
// Create a unique file name for the cache, tied to the configuration
var name = Convert.ToBase64String(Library.Utility.Utility.RepeatedHashWithSalt(config, salt))[..12];
_persistedFile = Path.Combine(persistedFolder, $"secret-cache-{name}.json.aes");
// If either the salt of the config changes, we loose the cache, both the filename and password will fail
using (var hasher = HashFactory.CreateHasher(HashFactory.SHA256))
_passphrase = Convert.ToBase64String($"{_salt}:{_config}".ComputeHash(hasher));
}
else
{
_persistedFile = null;
_passphrase = null;
}
}
///
public string Key => _provider.Key;
///
public string DisplayName => _provider.DisplayName;
///
public string Description => _provider.Description;
///
public IList SupportedCommands => _provider.SupportedCommands;
///
public async Task InitializeAsync(System.Uri config, CancellationToken cancellationToken)
{
try
{
// Always initialize the provider, and use this if possible
await _provider.InitializeAsync(config, cancellationToken).ConfigureAwait(false);
_initialized = true;
}
catch
{
if (_cachingLevel == CachingLevel.None)
throw;
if (_cachingLevel == CachingLevel.InMemory && !_cache.ContainsKey(_config))
throw;
if (_cachingLevel == CachingLevel.Persistent)
{
await LoadCacheAsync(cancellationToken).ConfigureAwait(false);
if (!_cache.ContainsKey(_config))
throw;
}
}
}
///
/// Loads the cache from disk, failing silently if the file could not be read
///
/// The cancellation token
/// An awaitable task
private async Task LoadCacheAsync(CancellationToken cancellationToken)
{
bool tryLoad;
lock (_lock)
tryLoad = _cachingLevel == CachingLevel.Persistent && !_cache.ContainsKey(_config) && File.Exists(_persistedFile);
if (tryLoad && !string.IsNullOrEmpty(_passphrase))
{
// Load from disk
try
{
using (var fs = new FileStream(_persistedFile!, FileMode.Open, FileAccess.Read))
using (var ms = new MemoryStream())
{
var decOpts = SharpAESCrypt.DecryptionOptions.Default with { LeaveOpen = true };
await SharpAESCrypt.AESCrypt.DecryptAsync(_passphrase, fs, ms, decOpts, cancellationToken).ConfigureAwait(false);
ms.Position = 0;
var res = await System.Text.Json.JsonSerializer.DeserializeAsync>(ms, cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize the cache");
lock (_lock)
if (!_cache.ContainsKey(_config))
_cache[_config] = res;
}
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "LoadPersistedCacheError", ex, "Failed to load cache from disk: {0}", ex.Message);
}
}
}
///
/// Saves the cache to disk
///
/// The cancellation token
/// An awaitable task
private async Task SaveCacheAsync(CancellationToken cancellationToken)
{
bool trySave;
lock (_lock)
trySave = _initialized && _cachingLevel == CachingLevel.Persistent && _cache.ContainsKey(_config);
if (trySave && !string.IsNullOrEmpty(_passphrase))
{
try
{
using (var ms = new MemoryStream())
{
Dictionary data;
lock (_lock)
data = _cache[_config].ToDictionary(k => k.Key, k => k.Value);
await System.Text.Json.JsonSerializer.SerializeAsync(ms, data, cancellationToken: cancellationToken).ConfigureAwait(false);
ms.Position = 0;
var encOpts = SharpAESCrypt.EncryptionOptions.Default;
using (var fs = new FileStream(_persistedFile!, FileMode.Create, FileAccess.Write))
await SharpAESCrypt.AESCrypt.EncryptAsync(_passphrase, ms, fs, encOpts, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "SavePersistedCacheError", ex, "Failed to save cache to disk: {0}", ex.Message);
}
}
}
///
/// Gets the cached values for the given keys
///
/// The keys to get
/// The cached values or null if not found
private Dictionary? GetFromCache(IEnumerable keys)
{
if (_cachingLevel == CachingLevel.InMemory || _cachingLevel == CachingLevel.Persistent)
{
lock (_lock)
if (_cache.ContainsKey(_config) && keys.All(x => _cache[_config].ContainsKey(x)))
return keys.ToDictionary(k => k, k => _cache[_config][k]);
}
return null;
}
///
public async Task> ResolveSecretsAsync(IEnumerable keys, CancellationToken cancellationToken)
{
// Don't call the provider if it was not initialized
if (!_initialized)
{
var cached = GetFromCache(keys);
if (cached != null)
return cached;
throw new InvalidOperationException("The provider has not been initialized");
}
// Always call the provider to get fresh values, if it was initialized
Dictionary result;
try
{
result = await _provider.ResolveSecretsAsync(keys, cancellationToken).ConfigureAwait(false);
}
catch
{
if (_cachingLevel == CachingLevel.None)
throw;
if (_cachingLevel == CachingLevel.InMemory || _cachingLevel == CachingLevel.Persistent)
{
var cached = GetFromCache(keys);
if (cached != null)
return cached;
}
throw;
}
// We have a result, cache it
if (_cachingLevel == CachingLevel.InMemory || _cachingLevel == CachingLevel.Persistent)
{
lock (_lock)
{
if (!_cache.ContainsKey(_config))
{
_cache[_config] = result;
}
else
{
foreach (var k in result)
_cache[_config][k.Key] = k.Value;
}
}
if (_cachingLevel == CachingLevel.Persistent)
await SaveCacheAsync(cancellationToken).ConfigureAwait(false);
}
return result;
}
}
}