2025-01-07 09:40:39 +01:00
|
|
|
// Copyright (C) 2025, 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
|
|
|
#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;
|
2024-11-04 15:57:56 +01:00
|
|
|
using System.Web;
|
2024-10-24 15:56:39 +02:00
|
|
|
using Duplicati.Library.DynamicLoader;
|
|
|
|
|
using Duplicati.Library.Interface;
|
|
|
|
|
using Duplicati.Library.Logging;
|
|
|
|
|
using Duplicati.Library.Utility;
|
2024-11-04 15:57:56 +01:00
|
|
|
using Google.Protobuf.WellKnownTypes;
|
2024-10-24 15:56:39 +02:00
|
|
|
|
|
|
|
|
namespace Duplicati.Library.Main;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Helper to apply secret provider to arguments
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static class SecretProviderHelper
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The log tag
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly string LOGTAG = Log.LogTagFromType<SecretProviderLoader>();
|
|
|
|
|
|
2024-10-26 08:37:26 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// The default pattern to use for matching
|
|
|
|
|
/// </summary>
|
|
|
|
|
public const string DEFAULT_PATTERN = "$";
|
|
|
|
|
|
2024-10-24 15:56:39 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// The different levels of caching permitted for the secrets
|
|
|
|
|
/// </summary>
|
|
|
|
|
public enum CachingLevel
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Values are always fetched from the provider
|
|
|
|
|
/// </summary>
|
|
|
|
|
None,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Values are cached in memory and used if the provider is not available
|
|
|
|
|
/// </summary>
|
|
|
|
|
InMemory,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Values are cached in memory and saved to disk with encryption.
|
|
|
|
|
/// If the provider is not available, the values are fetched from disk.
|
|
|
|
|
/// </summary>
|
|
|
|
|
Persistent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates an instance of a secret provider with caching enabled
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="config">The configuration string</param>
|
|
|
|
|
/// <param name="cachingLevel">The caching level</param>
|
|
|
|
|
/// <param name="persistedFolder">The folder to persist the cache to</param>
|
|
|
|
|
/// <param name="salt">The salt to use for hashing</param>
|
2024-10-26 08:37:26 +02:00
|
|
|
/// <param name="pattern">The pattern to use for matching</param>
|
2024-10-24 15:56:39 +02:00
|
|
|
/// <param name="cancelToken">The cancellation token</param>
|
|
|
|
|
/// <returns>The secret provider instance</returns>
|
2024-10-26 08:37:26 +02:00
|
|
|
public static async Task<ISecretProvider> CreateInstanceAsync(string config, CachingLevel cachingLevel, string persistedFolder, string salt, string pattern, CancellationToken cancelToken)
|
2024-10-24 15:56:39 +02:00
|
|
|
{
|
2024-10-24 16:52:09 +02:00
|
|
|
var provider = SecretProviderLoader.CreateInstance(config);
|
2024-10-26 08:37:26 +02:00
|
|
|
var sp = WrapWithCache(config, provider, cachingLevel, persistedFolder, salt, pattern);
|
2024-10-24 15:56:39 +02:00
|
|
|
await sp.InitializeAsync(new System.Uri(config), cancelToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
return sp;
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-25 23:13:38 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Wraps a secret provider with caching
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="config">The configuration string</param>
|
|
|
|
|
/// <param name="provider">The provider to wrap</param>
|
|
|
|
|
/// <param name="cachingLevel">The caching level</param>
|
|
|
|
|
/// <param name="persistedFolder">The folder to persist the cache to</param>
|
|
|
|
|
/// <param name="salt">The salt to use for hashing</param>
|
2024-10-26 08:37:26 +02:00
|
|
|
/// <param name="pattern">The pattern to use for matching</param>
|
2024-10-25 23:13:38 +02:00
|
|
|
/// <returns>The wrapped secret provider</returns>
|
2024-10-26 08:37:26 +02:00
|
|
|
public static ISecretProvider WrapWithCache(string config, ISecretProvider provider, CachingLevel cachingLevel, string persistedFolder, string salt, string? pattern)
|
|
|
|
|
=> new SecretProviderCached(config, provider, cachingLevel, persistedFolder, salt, pattern);
|
2024-10-25 23:13:38 +02:00
|
|
|
|
2024-10-24 15:56:39 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Applies the secret provider to the arguments.
|
|
|
|
|
/// Note that this method modifes the arguments and options in place.
|
|
|
|
|
/// </summary>
|
2024-11-04 15:57:56 +01:00
|
|
|
/// <param name="realUriArguments">The arguments to modify, of type <see cref="System.Uri"/></param>
|
|
|
|
|
/// <param name="internalUriArguments">The arguments to modify, of type <see cref="Library.Utility.Uri"/></param>
|
2024-10-24 15:56:39 +02:00
|
|
|
/// <param name="options">The options to modify</param>
|
|
|
|
|
/// <param name="persistedFolder">The persisted secret cache folder</param>
|
|
|
|
|
/// <param name="fallbackProvider">The fallback provider to use if no provider is specified</param>
|
|
|
|
|
/// <param name="cancellationToken">The cancellation token</param>
|
|
|
|
|
/// <returns>The secret provider</returns>
|
2024-11-04 15:57:56 +01:00
|
|
|
public static async Task<ISecretProvider?> ApplySecretProviderAsync(System.Uri?[] realUriArguments, Library.Utility.Uri[] internalUriArguments, Dictionary<string, string?> options, string persistedFolder, ISecretProvider? fallbackProvider, CancellationToken cancellationToken)
|
2024-10-24 15:56:39 +02:00
|
|
|
{
|
|
|
|
|
var provider = options.GetValueOrDefault("secret-provider");
|
|
|
|
|
if (string.IsNullOrWhiteSpace(provider) && fallbackProvider == null)
|
|
|
|
|
return null;
|
|
|
|
|
|
2024-10-26 08:37:26 +02:00
|
|
|
var pattern = options.GetValueOrDefault("secret-provider-pattern");
|
|
|
|
|
|
2024-10-24 15:56:39 +02:00
|
|
|
ISecretProvider secretProvider;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(provider))
|
|
|
|
|
{
|
|
|
|
|
secretProvider = fallbackProvider
|
|
|
|
|
?? throw new InvalidOperationException("No secret provider specified");
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2024-10-24 16:52:09 +02:00
|
|
|
var newProvider = SecretProviderLoader.CreateInstance(provider);
|
2024-10-24 15:56:39 +02:00
|
|
|
|
2024-10-25 23:13:38 +02:00
|
|
|
// Weak salt, but semi-static
|
2024-10-24 15:56:39 +02:00
|
|
|
string salt;
|
|
|
|
|
using (var hasher = HashFactory.CreateHasher(HashFactory.SHA256))
|
|
|
|
|
salt = Environment.MachineName.ComputeHashToHex(hasher);
|
|
|
|
|
|
2024-10-25 23:13:38 +02:00
|
|
|
var cachingLevel = Library.Utility.Utility.ParseEnumOption(options, "secret-provider-cache", CachingLevel.None);
|
|
|
|
|
|
2024-10-26 08:37:26 +02:00
|
|
|
secretProvider = WrapWithCache(provider, newProvider, cachingLevel, persistedFolder, salt, pattern);
|
2024-10-24 15:56:39 +02:00
|
|
|
await secretProvider.InitializeAsync(new System.Uri(provider), cancellationToken).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-26 08:37:26 +02:00
|
|
|
if (string.IsNullOrWhiteSpace(pattern) && secretProvider is SecretProviderCached cached)
|
|
|
|
|
pattern = cached.Pattern;
|
|
|
|
|
|
2024-10-24 15:56:39 +02:00
|
|
|
if (string.IsNullOrWhiteSpace(pattern))
|
2024-11-01 08:47:36 +01:00
|
|
|
pattern = DEFAULT_PATTERN;
|
2024-10-24 15:56:39 +02:00
|
|
|
|
2024-11-04 15:57:56 +01:00
|
|
|
await ReplaceSecretsAsync(secretProvider, realUriArguments, internalUriArguments, options, pattern, cancellationToken).ConfigureAwait(false);
|
2024-10-24 15:56:39 +02:00
|
|
|
return secretProvider;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Helper method that finds all secrets matching the prefix and replaces them with the resolved values
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="provider">The secret provider to use</param>
|
2024-11-04 15:57:56 +01:00
|
|
|
/// <param name="realUriArguments">The arguments to modify, of type <see cref="System.Uri"/></param>
|
|
|
|
|
/// <param name="internalUriArguments">The arguments to modify, of type <see cref="Library.Utility.Uri"/></param>
|
2024-10-24 15:56:39 +02:00
|
|
|
/// <param name="options">Any options to update</param>
|
|
|
|
|
/// <param name="matchpattern">The prefix to look for</param>
|
|
|
|
|
/// <param name="cancelToken">The cancellation token</param>
|
|
|
|
|
/// <returns>An awaitable task</returns>
|
2024-11-04 15:57:56 +01:00
|
|
|
public static async Task ReplaceSecretsAsync(ISecretProvider provider, System.Uri?[] realUriArguments, Library.Utility.Uri[] internalUriArguments, Dictionary<string, string?> options, string matchpattern, CancellationToken cancelToken)
|
2024-10-24 15:56:39 +02:00
|
|
|
{
|
|
|
|
|
// Unwrap ${} to support ${name is long}
|
|
|
|
|
var suffix = string.Empty;
|
2024-10-25 08:00:53 +02:00
|
|
|
var matcher = @"(\w|/)";
|
2024-10-24 15:56:39 +02:00
|
|
|
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)}(?<key>{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))
|
2024-11-04 15:57:56 +01:00
|
|
|
.Select(x => (x.Key, Secret: GetKey(x.Value, pattern)))
|
2024-10-24 15:56:39 +02:00
|
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Secret))
|
2024-11-04 15:57:56 +01:00
|
|
|
.GroupBy(x => x.Secret!)
|
2024-10-24 15:56:39 +02:00
|
|
|
.ToDictionary(x => x.Key, x => x.Select(y => y.Key).ToArray());
|
|
|
|
|
|
2024-11-04 15:57:56 +01:00
|
|
|
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)
|
2024-10-24 15:56:39 +02:00
|
|
|
.Concat(optionsMap.Keys)
|
|
|
|
|
.Distinct()
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
if (secrets.Length == 0)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
var translated = await provider.ResolveSecretsAsync(secrets, cancelToken).ConfigureAwait(false);
|
|
|
|
|
|
2024-10-25 23:13:38 +02:00
|
|
|
// 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");
|
|
|
|
|
|
2024-10-24 15:56:39 +02:00
|
|
|
// Update options by replacing values
|
|
|
|
|
foreach (var v in optionsMap)
|
|
|
|
|
foreach (var k in v.Value)
|
|
|
|
|
options[k] = translated[v.Key];
|
|
|
|
|
|
2024-11-04 15:57:56 +01:00
|
|
|
// 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;
|
2024-11-05 12:44:00 +01:00
|
|
|
kp[k] = Library.Utility.Uri.UrlEncode(translated[v.Key]);
|
2024-11-04 15:57:56 +01:00
|
|
|
uri = uri.SetQuery(Library.Utility.Uri.BuildUriQuery(kp));
|
|
|
|
|
internalUriArguments[s] = uri;
|
|
|
|
|
}
|
2024-10-24 15:56:39 +02:00
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-04 15:57:56 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the key from a value using the pattern
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="value">The value to get the key from</param>
|
|
|
|
|
/// <param name="pattern">The pattern to use</param>
|
|
|
|
|
/// <returns>The key or null if not found</returns>
|
|
|
|
|
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;
|
|
|
|
|
}
|
2024-10-24 15:56:39 +02:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A cache for secret provider values
|
|
|
|
|
/// </summary>
|
|
|
|
|
private class SecretProviderCached : ISecretProvider
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The provider being cached
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly ISecretProvider _provider;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A flag indicating if the provider has been initialized
|
|
|
|
|
/// </summary>
|
|
|
|
|
private bool _initialized;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The caching level
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly CachingLevel _cachingLevel;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The configuration string
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly string _config;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The salt used for hashing and uniqueness
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly string _salt;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The persisted file; null if not persistent
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly string? _persistedFile;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The passphrase used to encrypt the persisted file
|
|
|
|
|
/// </summary>
|
|
|
|
|
private readonly string? _passphrase;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The lock object guarding _cache
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly object _lock = new();
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The in-memory cache of secrets
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static readonly Dictionary<string, Dictionary<string, string>> _cache = new();
|
|
|
|
|
|
2024-10-26 08:37:26 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// The pattern associated with the provider
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string Pattern { get; }
|
|
|
|
|
|
2024-10-24 15:56:39 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a new instance of the secret provider cache
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="config">The configuration string</param>
|
|
|
|
|
/// <param name="provider">The provider to cache</param>
|
|
|
|
|
/// <param name="cachingLevel">The caching level</param>
|
|
|
|
|
/// <param name="persistedFolder">The folder to persist the cache to</param>
|
|
|
|
|
/// <param name="salt">The salt to use for hashing</param>
|
2024-10-26 08:37:26 +02:00
|
|
|
/// <param name="pattern">The pattern to use for matching</param>
|
|
|
|
|
public SecretProviderCached(string config, ISecretProvider provider, CachingLevel cachingLevel, string persistedFolder, string salt, string? pattern)
|
2024-10-24 15:56:39 +02:00
|
|
|
{
|
|
|
|
|
_provider = provider;
|
|
|
|
|
_cachingLevel = cachingLevel;
|
|
|
|
|
_config = config;
|
|
|
|
|
_salt = salt;
|
2024-10-26 08:37:26 +02:00
|
|
|
Pattern = pattern ?? DEFAULT_PATTERN;
|
2024-10-24 15:56:39 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc/>
|
|
|
|
|
public string Key => _provider.Key;
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc/>
|
|
|
|
|
public string DisplayName => _provider.DisplayName;
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc/>
|
|
|
|
|
public string Description => _provider.Description;
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc/>
|
|
|
|
|
public IList<ICommandLineArgument> SupportedCommands => _provider.SupportedCommands;
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc/>
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Loads the cache from disk, failing silently if the file could not be read
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="cancellationToken">The cancellation token</param>
|
|
|
|
|
/// <returns>An awaitable task</returns>
|
|
|
|
|
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<Dictionary<string, string>>(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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Saves the cache to disk
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="cancellationToken">The cancellation token</param>
|
|
|
|
|
/// <returns>An awaitable task</returns>
|
|
|
|
|
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<string, string> 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the cached values for the given keys
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="keys">The keys to get</param>
|
|
|
|
|
/// <returns>The cached values or null if not found</returns>
|
|
|
|
|
private Dictionary<string, string>? GetFromCache(IEnumerable<string> 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;
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <inheritdoc/>
|
|
|
|
|
public async Task<Dictionary<string, string>> ResolveSecretsAsync(IEnumerable<string> 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<string, string> 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|