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