From a9d389ffab063767d555a3dbbbf4e43867db3753 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Thu, 25 Jun 2026 14:40:58 +0200 Subject: [PATCH] Improve secret provider and license checker This improves the secret provider by allowing a value of `default://` to get the system default provider (if one exists). The license checker now supports multiple formats for the on-disk file. --- Duplicati/CommandLine/SecretTool/Program.cs | 12 +++--- Duplicati/CommandLine/ServerUtil/Settings.cs | 2 - .../DynamicLoader/SecretProviderLoader.cs | 41 ++++++++++++------- .../Library/Main/SecretProviderHelper.cs | 25 ++--------- proprietary/LicenseChecker/LicenseChecker.cs | 7 +++- proprietary/LicenseChecker/LicenseHelper.cs | 16 ++++++++ 6 files changed, 55 insertions(+), 48 deletions(-) diff --git a/Duplicati/CommandLine/SecretTool/Program.cs b/Duplicati/CommandLine/SecretTool/Program.cs index 482080b1b..9974aa84b 100644 --- a/Duplicati/CommandLine/SecretTool/Program.cs +++ b/Duplicati/CommandLine/SecretTool/Program.cs @@ -102,9 +102,8 @@ public static class Program /// The exit code. private static async Task RunTest(string secretUrl, string[] secrets) { - var secretProvider = SecretProviderLoader.CreateInstance(secretUrl); - await secretProvider.InitializeAsync(new Uri(secretUrl), CancellationToken.None); - var result = await secretProvider.ResolveSecretsAsync(secrets, CancellationToken.None); + var secretProvider = await SecretProviderLoader.CreateInstanceAsync(secretUrl, true, CancellationToken.None).ConfigureAwait(false); + var result = await secretProvider.ResolveSecretsAsync(secrets, CancellationToken.None).ConfigureAwait(false); Console.WriteLine("NOTE: Secret values are not displayed for security reasons"); Console.WriteLine("Secrets:"); @@ -140,7 +139,7 @@ public static class Program { if (string.IsNullOrWhiteSpace(secretUrl)) { - var defaultProvider = await SecretProviderLoader.GetDefaultSecretProviderForOperatingSystem(CancellationToken.None); + var defaultProvider = await SecretProviderLoader.GetDefaultSecretProviderForOperatingSystem(true, CancellationToken.None); if (defaultProvider == null) throw new UserInformationException("No working default secret provider found", "NoDefaultSecretProvider"); @@ -204,12 +203,11 @@ public static class Program throw new UserInformationException("Secret values do not match", "SecretMismatch"); } - var secretProvider = SecretProviderLoader.CreateInstance(secretUrl); - await secretProvider.InitializeAsync(new Uri(secretUrl), CancellationToken.None); + var secretProvider = await SecretProviderLoader.CreateInstanceAsync(secretUrl, true, CancellationToken.None).ConfigureAwait(false); await secretProvider.SetSecretAsync(key, value, overwrite, CancellationToken.None); // Verify that the secret was stored correctly - var result = await secretProvider.ResolveSecretsAsync([key], CancellationToken.None); + var result = await secretProvider.ResolveSecretsAsync([key], CancellationToken.None).ConfigureAwait(false); if (!result.ContainsKey(key) || result[key] != value) throw new UserInformationException("Failed to verify that the secret was stored correctly", "SecretVerificationFailed"); Console.WriteLine($"Secret '{key}' stored."); diff --git a/Duplicati/CommandLine/ServerUtil/Settings.cs b/Duplicati/CommandLine/ServerUtil/Settings.cs index a09e85ad4..4726cb0a4 100644 --- a/Duplicati/CommandLine/ServerUtil/Settings.cs +++ b/Duplicati/CommandLine/ServerUtil/Settings.cs @@ -99,8 +99,6 @@ public sealed record Settings( ISecretProvider? secretInstance = null; if (!string.IsNullOrWhiteSpace(secretProvider)) { - var secretProviderInstance = SecretProviderLoader.CreateInstance(secretProvider); - // Map into expected structure var opts = new Dictionary { diff --git a/Duplicati/Library/DynamicLoader/SecretProviderLoader.cs b/Duplicati/Library/DynamicLoader/SecretProviderLoader.cs index 703d1ea91..37fdee6c7 100644 --- a/Duplicati/Library/DynamicLoader/SecretProviderLoader.cs +++ b/Duplicati/Library/DynamicLoader/SecretProviderLoader.cs @@ -117,8 +117,10 @@ public class SecretProviderLoader /// Creates an instance of a secret provider /// /// The configuration string + /// Whether to initialize the provider + /// The cancellation token /// The secret provider instance - public static ISecretProvider CreateInstance(string config) + public static async Task CreateInstanceAsync(string config, bool initialize, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(config)) throw new ArgumentNullException(nameof(config)); @@ -145,6 +147,15 @@ public class SecretProviderLoader config = result; } + if (string.Equals(config, "default://", StringComparison.OrdinalIgnoreCase)) + { + var defaultProvider = await GetDefaultSecretProviderForOperatingSystem(initialize, cancellationToken).ConfigureAwait(false); + if (defaultProvider == null) + throw new InvalidOperationException("No default secret provider is available for this system"); + + return defaultProvider; + } + var uri = new Uri(config); var key = uri.Scheme; @@ -154,6 +165,9 @@ public class SecretProviderLoader if (Activator.CreateInstance(providerType.GetType()) is not ISecretProvider provider) throw new InvalidOperationException($"Failed to create an instance of {providerType}"); + if (initialize) + await provider.InitializeAsync(uri, cancellationToken).ConfigureAwait(false); + return provider; } @@ -161,37 +175,34 @@ public class SecretProviderLoader /// Gets the default secret provider for the current operating system /// /// The secret provider or null if none is available - public static async Task GetDefaultSecretProviderForOperatingSystem(CancellationToken cancellationToken) + public static async Task GetDefaultSecretProviderForOperatingSystem(bool initialize, CancellationToken cancellationToken) { if (OperatingSystem.IsWindows()) { var res = new WindowsCredentialManagerProvider(); - await res.InitializeAsync(new Uri("wincred://"), cancellationToken); + if (initialize) + await res.InitializeAsync(new Uri("wincred://"), cancellationToken).ConfigureAwait(false); return res; } if (OperatingSystem.IsMacOS()) { var res = new MacOSKeyChainProvider(); - await res.InitializeAsync(new Uri("keychain://"), cancellationToken); + if (initialize) + await res.InitializeAsync(new Uri("keychain://"), cancellationToken).ConfigureAwait(false); return res; } if (OperatingSystem.IsLinux()) { - ISecretProvider tmp = new LibSecretLinuxProvider(); - if (await tmp.IsSupported(cancellationToken)) + var res = new LibSecretLinuxProvider(); + if (await res.IsSupported(cancellationToken).ConfigureAwait(false)) { - var res = new LibSecretLinuxProvider(); - await res.InitializeAsync(new Uri("libsecret://"), cancellationToken); - if (await res.DoesCollectionExist(cancellationToken)) + if (!initialize) return res; - } - tmp = new UnixPassProvider(); - if (await tmp.IsSupported(cancellationToken)) - { - await tmp.InitializeAsync(new Uri("pass://"), cancellationToken); - return tmp; + await res.InitializeAsync(new Uri("libsecret://"), cancellationToken).ConfigureAwait(false); + if (await res.DoesCollectionExist(cancellationToken).ConfigureAwait(false)) + return res; } } diff --git a/Duplicati/Library/Main/SecretProviderHelper.cs b/Duplicati/Library/Main/SecretProviderHelper.cs index ca6eb7ead..417812aba 100644 --- a/Duplicati/Library/Main/SecretProviderHelper.cs +++ b/Duplicati/Library/Main/SecretProviderHelper.cs @@ -71,25 +71,6 @@ public static class SecretProviderHelper 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 /// @@ -118,14 +99,14 @@ public static class SecretProviderHelper var providerConfig = options.GetValueOrDefault("secret-provider"); if (!string.IsNullOrWhiteSpace(providerConfig)) { - var provider = SecretProviderLoader.CreateInstance(providerConfig); + var provider = await SecretProviderLoader.CreateInstanceAsync(providerConfig, true, cancellationToken).ConfigureAwait(false); if (provider?.IsSetSupported == true) return provider; } try { - return await SecretProviderLoader.GetDefaultSecretProviderForOperatingSystem(cancellationToken).ConfigureAwait(false); + return await SecretProviderLoader.GetDefaultSecretProviderForOperatingSystem(true, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -161,7 +142,7 @@ public static class SecretProviderHelper } else { - var newProvider = SecretProviderLoader.CreateInstance(provider); + var newProvider = await SecretProviderLoader.CreateInstanceAsync(provider, false, cancellationToken); // Weak salt, but semi-static string salt; diff --git a/proprietary/LicenseChecker/LicenseChecker.cs b/proprietary/LicenseChecker/LicenseChecker.cs index f20f8bb42..2d05d89cc 100644 --- a/proprietary/LicenseChecker/LicenseChecker.cs +++ b/proprietary/LicenseChecker/LicenseChecker.cs @@ -72,9 +72,9 @@ public static class LicenseChecker if (string.IsNullOrWhiteSpace(licenseKey)) throw new ArgumentException("License key cannot be null or empty", nameof(licenseKey)); - if (licenseKey.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + if (licenseKey.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) { - var filePath = licenseKey[5..]; + var filePath = licenseKey[7..]; if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("License file path cannot be null or empty", nameof(licenseKey)); if (!File.Exists(filePath)) @@ -104,6 +104,9 @@ public static class LicenseChecker } else { + if (licenseKey.StartsWith("jwt:", StringComparison.OrdinalIgnoreCase)) + licenseKey = licenseKey[4..]; + using var httpClient = new HttpClient(); using var content = JsonContent.Create(new LicenseRequestData(licenseKey)); using var response = await httpClient.PostAsync(ServerUrl, content, cancellationToken); diff --git a/proprietary/LicenseChecker/LicenseHelper.cs b/proprietary/LicenseChecker/LicenseHelper.cs index 2244654a9..3128e27d1 100644 --- a/proprietary/LicenseChecker/LicenseHelper.cs +++ b/proprietary/LicenseChecker/LicenseHelper.cs @@ -7,6 +7,7 @@ namespace Duplicati.Proprietary.LicenseChecker; public static class LicenseHelper { + private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(typeof(LicenseHelper)); private static readonly object _licenseLock = new(); private static LicenseData? _cachedLicenseData; private static string? _remoteClientLicenseKey; @@ -59,8 +60,23 @@ public static class LicenseHelper // Check for a license file in the installation directory (highest priority) var keyfilepath = Path.Combine(UpdaterManager.INSTALLATIONDIR, "license.key"); if (File.Exists(keyfilepath)) + { + // Fallback to assume we have a direct license key key = $"file://{keyfilepath}"; + try + { + // Probe if the file has base64 encoded data + var content = File.ReadAllText(keyfilepath); + if (content.TrimStart().StartsWith("base64:", StringComparison.OrdinalIgnoreCase) || content.TrimStart().StartsWith("jwt:", StringComparison.OrdinalIgnoreCase)) + key = content; + } + catch (Exception ex) + { + Library.Logging.Log.WriteVerboseMessage(LOGTAG, "FailedToLoadLicenseFile", ex, "Failed to open the existing license file, check permissions"); + } + } + // Check for a license key in the environment variables if (string.IsNullOrWhiteSpace(key)) key = Environment.GetEnvironmentVariable("DUPLICATI_LICENSE_KEY");