From cf42426d1bf1636185fd4a065b79d4931b0a46eb Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sun, 7 Dec 2025 22:26:37 +0100 Subject: [PATCH 1/7] Store password with TrayIcon When using the TrayIcon with no hosted server (i.e., connecting to a running server), it is now possible to save the password and url in the secret provider. There is a checkbox that allows saving the settings, and if checked, will save the settings to the secret provider (using the OS default if none is specifically provided). This fixes #6379 --- .../Duplicati.GUI.TrayIcon/AvaloniaRunner.cs | 9 +- .../HttpServerConnection.cs | 57 +++----- .../Duplicati.GUI.TrayIcon/PasswordPrompt.cs | 52 +++---- .../PasswordStorageHelper.cs | 131 ++++++++++++++++++ .../GUI/Duplicati.GUI.TrayIcon/Program.cs | 29 ++-- .../Duplicati.GUI.TrayIcon/TrayIconBase.cs | 21 +-- .../Library/Main/SecretProviderHelper.cs | 31 ++++- Duplicati/Library/RestAPI/Strings.cs | 2 + .../UnitTest/ServerApiIntegrationTests.cs | 2 +- 9 files changed, 236 insertions(+), 98 deletions(-) create mode 100644 Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs index ba25d8c38..180834fc9 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/AvaloniaRunner.cs @@ -181,7 +181,7 @@ namespace Duplicati.GUI.TrayIcon if (tcs.Task.IsCompletedSuccessfully) { // Check if we need to show the password prompt - if (Program.NeedsPasswordPrompt) + if (Program.PasswordStorage.NeedsPasswordPrompt) ShowPasswordPromptAsync(lifetime).FireAndForget(); actionDelayer.SignalStart(); @@ -204,9 +204,9 @@ namespace Duplicati.GUI.TrayIcon { try { - var password = await PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: false).ConfigureAwait(false); + var res = await PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: false).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(password)) + if (!res) { RunOnUIThreadInternal(() => { @@ -218,9 +218,6 @@ namespace Duplicati.GUI.TrayIcon }); return; } - - // Password received, notify the callback - Program.Connection.UpdatePassword(password); } catch (Exception ex) { diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs index 7ed4076dc..6c79f9cd4 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/HttpServerConnection.cs @@ -87,12 +87,10 @@ namespace Duplicati.GUI.TrayIcon private record BackgroundRequest(string Method, string Endpoint, string? Body, TimeSpan? Timeout = null); - private Uri m_serverUri; - private string m_apiUri; - private string m_baseUri; - private string m_password; private string? m_accesstoken; private bool m_isTryingWithPassword; + private string ApiUri => BaseUri + "api/v1"; + private string BaseUri => Util.AppendDirSeparator(_passwordStorageHelper.HostUrl ?? "", "/"); public Func? OnStatusUpdated; public Action? ConnectionClosed; @@ -116,29 +114,14 @@ namespace Duplicati.GUI.TrayIcon private readonly CancellationTokenSource m_stopToken = new CancellationTokenSource(); private readonly Program.PasswordSource m_passwordSource; - private readonly TaskCompletionSource m_passwordUpdatedTcs = new TaskCompletionSource(); public IServerStatus Status { get { return m_status; } } - public void UpdatePassword(string newpassword) + private void UpdateServerUri() { - m_password = newpassword; - m_accesstoken = null; - m_passwordUpdatedTcs.TrySetResult(newpassword); - } - - public void UpdateServerUri(Uri newServer) - { - if (newServer == null) - throw new ArgumentNullException(nameof(newServer)); - - m_serverUri = newServer; - m_baseUri = Util.AppendDirSeparator(m_serverUri.ToString(), "/"); - m_apiUri = m_baseUri + "api/v1"; - // Reset state to ensure clean reconnection to the new server m_lastEventId = 0; m_lastDataUpdateId = -1; @@ -149,28 +132,24 @@ namespace Duplicati.GUI.TrayIcon Channel.Create(name: "TrayIconRequestQueue"); private readonly CancellationToken _applicationExitEvent; + private readonly PasswordStorageHelper _passwordStorageHelper; public Program.PasswordSource PasswordSource => m_passwordSource; - public Uri ServerUri => m_serverUri; - - public HttpServerConnection(IApplicationSettings? applicationSettings, System.Uri server, string password, + public HttpServerConnection(IApplicationSettings? applicationSettings, Program.PasswordSource passwordSource, bool disableTrayIconLogin, string acceptedHostCertificate, - Dictionary options) + Dictionary options, + PasswordStorageHelper passwordStorageHelper) { _applicationExitEvent = applicationSettings?.ApplicationExit ?? CancellationToken.None; - m_serverUri = server; - m_baseUri = Util.AppendDirSeparator(m_serverUri.ToString(), "/"); - - m_apiUri = m_baseUri + "api/v1"; - + _passwordStorageHelper = passwordStorageHelper; + _passwordStorageHelper.OnPasswordChanged += (sender, e) => UpdateServerUri(); m_disableTrayIconLogin = disableTrayIconLogin; m_firstNotificationTime = DateTime.Now; - m_password = password; m_passwordSource = passwordSource; var acceptedCertificates = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -290,10 +269,10 @@ namespace Duplicati.GUI.TrayIcon private async Task PasswordAvailableIfNeeded() { - if (string.IsNullOrWhiteSpace(m_password) && m_passwordSource == Program.PasswordSource.SuppliedPassword) + if (m_passwordSource == Program.PasswordSource.SuppliedPassword && string.IsNullOrWhiteSpace(_passwordStorageHelper.Password)) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(m_stopToken.Token, _applicationExitEvent); - await m_passwordUpdatedTcs.Task.WaitAsync(cts.Token).ConfigureAwait(false); + await _passwordStorageHelper.WaitForPasswordUpdateAsync(cts.Token).ConfigureAwait(false); } } @@ -441,10 +420,10 @@ namespace Duplicati.GUI.TrayIcon } // If we know the password, issue a token from the API - if (!string.IsNullOrWhiteSpace(m_password)) + if (!string.IsNullOrWhiteSpace(_passwordStorageHelper.Password)) { m_accesstoken = (await PerformRequestInternalAsync("POST", "/auth/login", - JsonSerializer.Serialize(new { Password = m_password }), null).ConfigureAwait(false)).AccessToken; + JsonSerializer.Serialize(new { Password = _passwordStorageHelper.Password }), null).ConfigureAwait(false)).AccessToken; return; } @@ -462,7 +441,7 @@ namespace Duplicati.GUI.TrayIcon private async Task PerformRequestInternalAsync(string method, string endpoint, string? body, TimeSpan? timeout) { - var request = new HttpRequestMessage(new HttpMethod(method), new Uri(m_apiUri + endpoint)); + var request = new HttpRequestMessage(new HttpMethod(method), new Uri(ApiUri + endpoint)); if (!string.IsNullOrWhiteSpace(m_accesstoken)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", m_accesstoken); @@ -542,8 +521,8 @@ namespace Duplicati.GUI.TrayIcon } // If we know the password, issue a token from the API - if (string.IsNullOrWhiteSpace(signinjwt) && !string.IsNullOrWhiteSpace(m_password)) - signinjwt = await IssueSigninTokenAsync(m_password); + if (string.IsNullOrWhiteSpace(signinjwt) && !string.IsNullOrWhiteSpace(_passwordStorageHelper.Password)) + signinjwt = await IssueSigninTokenAsync(_passwordStorageHelper.Password); return signinjwt; } @@ -563,8 +542,8 @@ namespace Duplicati.GUI.TrayIcon } return string.IsNullOrWhiteSpace(signinjwt) - ? m_baseUri + STATUS_WINDOW - : m_baseUri + SIGNIN_WINDOW + $"?token={signinjwt}"; + ? BaseUri + STATUS_WINDOW + : BaseUri + SIGNIN_WINDOW + $"?token={signinjwt}"; } } } diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordPrompt.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordPrompt.cs index 15f0c3ce5..2af5f5d53 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordPrompt.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordPrompt.cs @@ -22,12 +22,14 @@ #nullable enable using System; +using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Threading; +using Duplicati.Library.Utility; namespace Duplicati.GUI.TrayIcon; @@ -38,15 +40,17 @@ internal static class PasswordPrompt /// Shows a password prompt dialog within an already-running Avalonia application. /// Must be called from the UI thread or will dispatch to it. /// + /// The host URL to show in the prompt + /// The current password to pre-fill, if anyWhether this is a change password prompt /// A task that completes with the password, or null if cancelled - public static Task ShowPasswordDialogAsync(bool isChangePassword) + public static Task ShowPasswordDialogAsync(bool isChangePassword) { if (IsShowingDialog) throw new InvalidOperationException("A password prompt dialog is already showing."); IsShowingDialog = true; - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(); if (Dispatcher.UIThread.CheckAccess()) { @@ -64,27 +68,27 @@ internal static class PasswordPrompt }); } - private static void ShowDialogInternal(TaskCompletionSource tcs, bool isChangePassword) + private static void ShowDialogInternal(TaskCompletionSource tcs, bool isChangePassword) { try { var window = new PasswordPromptWindow(isChangePassword); window.PasswordSubmitted += (_, pwd) => { - tcs.TrySetResult(pwd); + tcs.TrySetResult(true); window.Close(); }; window.Cancelled += (_, _) => { - tcs.TrySetResult(null); + tcs.TrySetResult(false); window.Close(); }; window.Closed += (_, _) => { // Ensure the task completes even if window is closed another way - tcs.TrySetResult(null); + tcs.TrySetResult(false); }; window.Show(); @@ -92,7 +96,7 @@ internal static class PasswordPrompt catch (Exception ex) { Console.Error.WriteLine($"Failed to show password prompt window: {ex.Message}"); - tcs.TrySetResult(null); + tcs.TrySetResult(false); } } } @@ -101,6 +105,7 @@ internal class PasswordPromptWindow : Window { private readonly TextBox passwordBox; private readonly TextBox hostUrlBox; + private readonly CheckBox saveConfigurationCheckBox; public event EventHandler? PasswordSubmitted; @@ -127,6 +132,7 @@ internal class PasswordPromptWindow : Window { Watermark = "Server password", PasswordChar = '•', + Text = Program.PasswordStorage.Password ?? string.Empty, HorizontalAlignment = HorizontalAlignment.Stretch, Margin = new Thickness(0, 0, 0, 10) }; @@ -140,12 +146,21 @@ internal class PasswordPromptWindow : Window hostUrlBox = new TextBox { - Text = Program.Connection?.ServerUri?.ToString() ?? string.Empty, + Text = Program.PasswordStorage.HostUrl ?? string.Empty, Watermark = "http://localhost:8200", HorizontalAlignment = HorizontalAlignment.Stretch, Margin = new Thickness(0, 0, 0, 10) }; + saveConfigurationCheckBox = new CheckBox + { + Content = "Save configuration", + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 0, 0, 10), + IsChecked = Program.PasswordStorage.ShouldSavePassword, + IsVisible = Program.PasswordStorage.IsSetSupported == true, + }; + var okButton = new Button { Content = "Connect", @@ -183,7 +198,8 @@ internal class PasswordPromptWindow : Window description, passwordBox, hostUrlLabel, - hostUrlBox + hostUrlBox, + saveConfigurationCheckBox } }; @@ -207,22 +223,8 @@ internal class PasswordPromptWindow : Window var password = passwordBox.Text; var hostUrlText = hostUrlBox.Text?.Trim(); - if (!string.IsNullOrWhiteSpace(hostUrlText) && Program.Connection != null) - { - try - { - var uri = new Uri(hostUrlText, UriKind.Absolute); - - if (!uri.Equals(Program.Connection.ServerUri)) - { - Program.Connection.UpdateServerUri(uri); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Invalid host URL '{hostUrlText}': {ex.Message}"); - } - } + Program.PasswordStorage.UpdatePasswordAsync(hostUrlText, password, saveConfigurationCheckBox.IsChecked ?? false, CancellationToken.None) + .Await(); PasswordSubmitted?.Invoke(this, password); } diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs new file mode 100644 index 000000000..ff1328804 --- /dev/null +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Duplicati.Library.Interface; +using Duplicati.Library.Main; + +#nullable enable + +namespace Duplicati.GUI.TrayIcon; + +public class PasswordStorageHelper +{ + private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType(); + private const string HOSTURL_SECRET_NAME = "duplicati-trayicon-hosturl"; + private const string PASSWORD_SECRET_NAME = "duplicati-trayicon-password"; + + private readonly ISecretProvider? m_secretProvider; + private string m_hostUrl; + private string m_password; + private readonly TaskCompletionSource m_passwordUpdatedTcs = new TaskCompletionSource(); + private readonly Program.PasswordSource m_passwordSource; + + public event EventHandler? OnPasswordChanged; + + public bool IsSetSupported => m_secretProvider?.IsSetSupported ?? false; + + public string? HostUrl => m_hostUrl; + public string? Password => m_password; + + public bool ShouldSavePassword { get; private set; } + + public bool NeedsPasswordPrompt => m_passwordSource == Program.PasswordSource.SuppliedPassword && string.IsNullOrWhiteSpace(m_password); + + public static async Task CreateAsync(string? hostUrl, bool customUrl, string? password, Program.PasswordSource passwordSource, Dictionary options) + { + var secretProvider = await SecretProviderHelper.GetDefaultSecretProvider(options, CancellationToken.None); + + // If we get the password from the secret provider, we should save it back to the secret provider + var shouldSavePassword = false; + + if (secretProvider != null && passwordSource == Program.PasswordSource.SuppliedPassword && (string.IsNullOrWhiteSpace(hostUrl) || !customUrl || string.IsNullOrWhiteSpace(password))) + { + if (string.IsNullOrWhiteSpace(hostUrl) || !customUrl) + { + try + { + hostUrl = await secretProvider.ResolveSecretAsync(HOSTURL_SECRET_NAME, CancellationToken.None); + shouldSavePassword = true; + } + catch (Exception ex) + { + Library.Logging.Log.WriteInformationMessage(LOGTAG, "SecretProviderFailedToGetEncryptionKey", $"Failed to get stored configuration \"{HOSTURL_SECRET_NAME}\": {ex.Message}"); + + } + } + + if (string.IsNullOrWhiteSpace(password)) + { + try + { + password = await secretProvider.ResolveSecretAsync(PASSWORD_SECRET_NAME, CancellationToken.None); + shouldSavePassword = true; + } + catch (Exception ex) + { + Library.Logging.Log.WriteInformationMessage(LOGTAG, "SecretProviderFailedToGetEncryptionKey", $"Failed to get stored configuration \"{PASSWORD_SECRET_NAME}\": {ex.Message}"); + } + } + } + + return new PasswordStorageHelper(secretProvider, hostUrl, password, passwordSource, shouldSavePassword); + } + + private PasswordStorageHelper(ISecretProvider? secretProvider, string? hostUrl, string? password, Program.PasswordSource passwordSource, bool shouldSavePassword) + { + m_secretProvider = secretProvider; + m_hostUrl = hostUrl ?? string.Empty; + m_password = password ?? string.Empty; + m_passwordSource = passwordSource; + ShouldSavePassword = shouldSavePassword; + if (!string.IsNullOrWhiteSpace(m_password)) + m_passwordUpdatedTcs.TrySetResult(true); + } + + public Task WaitForPasswordUpdateAsync(CancellationToken cancellationToken) + => m_passwordUpdatedTcs.Task.WaitAsync(cancellationToken); + + public async Task UpdatePasswordAsync(string? hostUrl, string? password, bool save, CancellationToken cancellationToken) + { + var res = false; + if (m_secretProvider != null && IsSetSupported && save) + { + try + { + if (!string.IsNullOrWhiteSpace(hostUrl) && hostUrl != m_hostUrl) + { + await m_secretProvider.SetSecretAsync( + HOSTURL_SECRET_NAME, + hostUrl ?? string.Empty, + false, + cancellationToken); + } + + if (!string.IsNullOrWhiteSpace(password) && password != m_password) + { + await m_secretProvider.SetSecretAsync( + PASSWORD_SECRET_NAME, + password ?? string.Empty, + false, + cancellationToken); + } + + res = true; + ShouldSavePassword = true; + } + catch (Exception ex) + { + Library.Logging.Log.WriteWarningMessage(LOGTAG, "PasswordSaveFailed", ex, "Failed to save configuration"); + } + } + + m_hostUrl = hostUrl ?? string.Empty; + m_password = password ?? string.Empty; + m_passwordUpdatedTcs.TrySetResult(true); + OnPasswordChanged?.Invoke(this, EventArgs.Empty); + + return res; + } +} diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs index 399efa94f..07003294d 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/Program.cs @@ -59,12 +59,15 @@ namespace Duplicati.GUI.TrayIcon SuppliedPassword } + /// + /// The connection to the server + /// public static HttpServerConnection Connection; /// - /// Indicates that a password is needed and should be requested via GUI after Avalonia starts + /// The default secret provider /// - public static bool NeedsPasswordPrompt { get; private set; } + public static PasswordStorageHelper PasswordStorage; private const string HOSTURL_OPTION = "hosturl"; private const string NOHOSTEDSERVER_OPTION = "no-hosted-server"; @@ -87,7 +90,6 @@ namespace Duplicati.GUI.TrayIcon private static string _browser_command = null; private static bool disableTrayIconLogin = false; private static bool openui = false; - private static Uri serverURL = new(DEFAULT_HOSTURL); public static string BrowserCommand { get { return _browser_command; } } public static Server.Database.Connection databaseConnection = null; @@ -149,6 +151,8 @@ namespace Duplicati.GUI.TrayIcon using (var logger = new ConsoleOutput(Console.Out, options)) CommandLineArgumentValidator.ValidateArguments(supportedCommands, options, Server.Program.KnownDuplicateOptions, new HashSet()); + Uri serverURL = new Uri(DEFAULT_HOSTURL); + if (!detached) { try @@ -221,22 +225,29 @@ namespace Duplicati.GUI.TrayIcon password = pwd; // Let the user specify the port, if they are not providing a hosturl + var customUrl = false; if (!options.ContainsKey(HOSTURL_OPTION) && options.TryGetValue(WebServerLoader.OPTION_PORT, out var portString) && int.TryParse(portString, out var port)) + { serverURL = new UriBuilder(serverURL) { Port = port }.Uri; + customUrl = true; + } if (options.TryGetValue(HOSTURL_OPTION, out var url)) + { serverURL = new Uri(url); + customUrl = true; + } - if (string.IsNullOrWhiteSpace(password) && passwordSource == PasswordSource.SuppliedPassword) - NeedsPasswordPrompt = true; - - StartTray(_args, options, hosted, passwordSource, password, acceptedHostCertificate); + StartTray(_args, options, hosted, passwordSource, password, serverURL, customUrl, acceptedHostCertificate); return 0; } - private static void StartTray(string[] _args, Dictionary options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, string acceptedHostCertificate) + private static void StartTray(string[] _args, Dictionary options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, Uri serverURL, bool customUrl, string acceptedHostCertificate) { + PasswordStorage = PasswordStorageHelper.CreateAsync(serverURL.ToString(), customUrl, password, passwordSource, options) + .Await(); + using (hosted) { var reSpawn = 0; @@ -248,7 +259,7 @@ namespace Duplicati.GUI.TrayIcon try { - using (Connection = new HttpServerConnection(hosted?.applicationSettings, serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options)) + using (Connection = new HttpServerConnection(hosted?.applicationSettings, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options, PasswordStorage)) { // Make sure we have the latest status, but don't care if it fails Connection.UpdateStatus().FireAndForget(); diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs index 5110f2a63..c0f3f6e57 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs @@ -180,24 +180,11 @@ namespace Duplicati.GUI.TrayIcon ]; } - private void OnChangePasswordClicked() + private async void OnChangePasswordClicked() { - PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: true) - .ContinueWith(t => - { - OnStatusUpdated(Program.Connection.Status); - if (t.IsFaulted) - return; - - if (string.IsNullOrWhiteSpace(t.Result)) - return; - - Program.Connection.UpdatePassword(t.Result); - }) - .FireAndForget(); - - OnStatusUpdated(Program.Connection.Status); - + var res = await PasswordPrompt.ShowPasswordDialogAsync(isChangePassword: true); + if (res) + await OnStatusUpdated(Program.Connection.Status); } private void Reconnect() diff --git a/Duplicati/Library/Main/SecretProviderHelper.cs b/Duplicati/Library/Main/SecretProviderHelper.cs index 60c9c5ba4..2c0d01661 100644 --- a/Duplicati/Library/Main/SecretProviderHelper.cs +++ b/Duplicati/Library/Main/SecretProviderHelper.cs @@ -33,7 +33,6 @@ using Duplicati.Library.DynamicLoader; using Duplicati.Library.Interface; using Duplicati.Library.Logging; using Duplicati.Library.Utility; -using Google.Protobuf.WellKnownTypes; namespace Duplicati.Library.Main; @@ -104,6 +103,26 @@ public static class SecretProviderHelper public static ISecretProvider WrapWithCache(string config, ISecretProvider provider, CachingLevel cachingLevel, string persistedFolder, string salt, string? pattern) => new SecretProviderCached(config, provider, cachingLevel, persistedFolder, salt, pattern); + + /// + /// Gets the default secret provider, if any + /// + /// The options passed + /// The cancellation token + /// + public static async Task GetDefaultSecretProvider(Dictionary options, CancellationToken cancellationToken) + { + var providerConfig = options.GetValueOrDefault("secret-provider"); + if (!string.IsNullOrWhiteSpace(providerConfig)) + { + var provider = SecretProviderLoader.CreateInstance(providerConfig); + if (provider?.IsSetSupported == true) + return provider; + } + + return await SecretProviderLoader.GetDefaultSecretProviderForOperatingSystem(cancellationToken).ConfigureAwait(false); + } + /// /// Applies the secret provider to the arguments. /// Note that this method modifes the arguments and options in place. @@ -264,6 +283,16 @@ public static class SecretProviderHelper return; } + /// + /// Resolves a single secret from the provider + /// + /// The secret provider + /// The name of the secret + /// The cancellation token + /// The resolved secret + public static async Task ResolveSecretAsync(this ISecretProvider provider, string name, CancellationToken cancelToken) + => (await provider.ResolveSecretsAsync([name], cancelToken).ConfigureAwait(false))[name]; + /// /// Gets the key from a value using the pattern, and also collects partial matches /// diff --git a/Duplicati/Library/RestAPI/Strings.cs b/Duplicati/Library/RestAPI/Strings.cs index 7a72fd53c..5c5b3ac62 100644 --- a/Duplicati/Library/RestAPI/Strings.cs +++ b/Duplicati/Library/RestAPI/Strings.cs @@ -117,6 +117,8 @@ Error message: {0}", error); } public static string AllowedEncryptionModulesLong { get { return LC.L(@"Set the allowed encryption modules for remote control. The value is a comma-separated list of encryption module names. If this option is not set, all encryption modules are allowed. Use this option to restrict the encryption modules that can be used to encrypt data."); } } public static string AllowedCompressionModulesShort { get { return LC.L(@"Set the allowed compression modules for remote control"); } } public static string AllowedCompressionModulesLong { get { return LC.L(@"Set the allowed compression modules for remote control. The value is a comma-separated list of compression module names. If this option is not set, all compression modules are allowed. Use this option to restrict the compression modules that can be used to compress data."); } } + public static string SecretProviderFailedToGetEncryptionKey { get { return LC.L(@"Failed to get encryption key from secret provider"); } } + public static string SecretProviderFailedToSetEncryptionKey { get { return LC.L(@"Failed to set encryption key in secret provider"); } } } internal static class Scheduler { diff --git a/Duplicati/UnitTest/ServerApiIntegrationTests.cs b/Duplicati/UnitTest/ServerApiIntegrationTests.cs index 9359622e7..08bb4d032 100644 --- a/Duplicati/UnitTest/ServerApiIntegrationTests.cs +++ b/Duplicati/UnitTest/ServerApiIntegrationTests.cs @@ -133,7 +133,7 @@ public class ServerApiIntegrationTests : BasicSetupHelper Assert.That(result.Id, Is.Not.Null.And.Not.Empty, "Import should return a backup ID"); - var importedBackup = await AssertBackupListedAsync(httpClient, result.Id).ConfigureAwait(false); + var importedBackup = await AssertBackupListedAsync(httpClient, result.Id!).ConfigureAwait(false); Assert.That(importedBackup.IsUnencryptedOrPassphraseStored, Is.True, "Imported backup should be ready to run without prompting for a passphrase"); }).ConfigureAwait(false); } From 925c87a3d6cea72373fa717014fdc50d64811831 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sun, 7 Dec 2025 22:31:44 +0100 Subject: [PATCH 2/7] Fix macOS keychain This fixes the write support for storing secrets in the macOS KeyChain, such that each entry has a unique service name. --- .../SecretProvider/MacOSKeyChainProvider.cs | 408 +++++++----------- 1 file changed, 156 insertions(+), 252 deletions(-) diff --git a/Duplicati/Library/SecretProvider/MacOSKeyChainProvider.cs b/Duplicati/Library/SecretProvider/MacOSKeyChainProvider.cs index c6419d0bc..deb5f8798 100644 --- a/Duplicati/Library/SecretProvider/MacOSKeyChainProvider.cs +++ b/Duplicati/Library/SecretProvider/MacOSKeyChainProvider.cs @@ -194,6 +194,15 @@ public class MacOSKeyChainProvider : ISecretProvider /// private const string CoreFoundationLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + /// + /// Success status code + /// + public const int errSecSuccess = 0; + /// + /// Duplicate item status code + /// + public const int errSecDuplicateItem = -25299; + // Classic exact-match APIs (fast path) /// /// Finds a generic password item in the keychain. @@ -392,12 +401,29 @@ public class MacOSKeyChainProvider : ISecretProvider /// private const string LibSystem = "/usr/lib/libSystem.B.dylib"; + /// + /// Loads a dynamic library. + /// + /// The path to the library. + /// The loading mode. + /// Handle to the loaded library. [DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr dlopen(string path, int mode); + /// + /// Resolves a symbol from a dynamic library. + /// + /// Handle to the loaded library. + /// The symbol to resolve. + /// Pointer to the resolved symbol. [DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] private static extern IntPtr dlsym(IntPtr handle, string symbol); + /// + /// Closes a dynamic library. + /// + /// Handle to the loaded library. + /// Zero on success. [DllImport(LibSystem, CallingConvention = CallingConvention.Cdecl)] private static extern int dlclose(IntPtr handle); @@ -453,6 +479,9 @@ public class MacOSKeyChainProvider : ISecretProvider /// internal static readonly IntPtr CFBooleanTrue; + /// + /// Static constructor to load native libraries and resolve constant symbols. + /// static KeychainNative() { const int RTLD_LAZY = 0x1; @@ -477,6 +506,12 @@ public class MacOSKeyChainProvider : ISecretProvider CFBooleanTrue = GetSymbol(_coreFoundationHandle, "kCFBooleanTrue"); } + /// + /// Resolves a symbol and reads its pointer value. + /// + /// Handle to the loaded library. + /// The symbol to resolve. + /// Pointer to the resolved symbol. private static IntPtr GetSymbol(IntPtr handle, string name) { var symbolPtr = dlsym(handle, name); @@ -553,17 +588,38 @@ public class MacOSKeyChainProvider : ISecretProvider /// Whether to overwrite an existing item. /// The keychain settings. /// Cancellation token. - private static async Task SetStringAsync(string name, string secret, bool overwrite, KeyChainSettings settings, CancellationToken cancellationToken) + private static Task SetStringAsync(string name, string secret, bool overwrite, KeyChainSettings settings, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - // Use SecItem* for both classes so label is always set and updateable. - if (settings.Type == PasswordType.Generic) - SetGeneric(name, secret, overwrite, settings); - else - SetInternet(name, secret, overwrite, settings); + SetItem(name, secret, overwrite, settings, isInternet: settings.Type == PasswordType.Internet); + return Task.CompletedTask; + } - await Task.CompletedTask.ConfigureAwait(false); + /// + /// Returns the service name to use for the given name and settings. + /// + /// The name of the secret. + /// The keychain settings. + /// The service name to use. + private static string GetServiceName(string name, KeyChainSettings settings) + { + if (string.IsNullOrWhiteSpace(settings.Service)) + return name; + return $"{settings.Service}.{name}"; + } + + /// + /// Returns the account name to use for the given name and settings. + /// + /// The name of the secret. + /// The keychain settings. + /// The account name to use. + private static string GetAccountName(string name, KeyChainSettings settings) + { + if (string.IsNullOrWhiteSpace(settings.Account)) + return name; + return settings.Account; } /// @@ -573,142 +629,84 @@ public class MacOSKeyChainProvider : ISecretProvider /// The secret value. /// Whether to overwrite if exists. /// The keychain settings. - private static void SetGeneric(string label, string secret, bool overwrite, KeyChainSettings settings) + private static void SetItem(string label, string secret, bool overwrite, KeyChainSettings settings, bool isInternet) { - var service = settings.Service ?? label; - var account = settings.Account ?? label; + var serviceOrServer = GetServiceName(label, settings); + var account = GetAccountName(label, settings); - IntPtr q = IntPtr.Zero, attrs = IntPtr.Zero, upd = IntPtr.Zero; - IntPtr cfService = IntPtr.Zero, cfAccount = IntPtr.Zero, cfLabel = IntPtr.Zero, cfSecret = IntPtr.Zero; + IntPtr attrs = IntPtr.Zero, q = IntPtr.Zero, upd = IntPtr.Zero; + IntPtr cfServiceOrServer = IntPtr.Zero, cfAccount = IntPtr.Zero, cfLabel = IntPtr.Zero, cfSecret = IntPtr.Zero; try { - cfService = KeychainNative.CFString(service); + cfServiceOrServer = KeychainNative.CFString(serviceOrServer); cfAccount = KeychainNative.CFString(account); cfLabel = KeychainNative.CFString(label); cfSecret = KeychainNative.CFData(Encoding.UTF8.GetBytes(secret)); - // Query for exact (service,account) + // Build attributes for add + attrs = KeychainNative.NewMutableDict(); + KeychainNative.DictSet(attrs, KeychainNative.SecClass, + isInternet ? KeychainNative.SecClassInternetPassword + : KeychainNative.SecClassGenericPassword); + + if (isInternet) + KeychainNative.DictSet(attrs, KeychainNative.SecAttrServer, cfServiceOrServer); + else + KeychainNative.DictSet(attrs, KeychainNative.SecAttrService, cfServiceOrServer); + + KeychainNative.DictSet(attrs, KeychainNative.SecAttrAccount, cfAccount); + KeychainNative.DictSet(attrs, KeychainNative.SecAttrLabel, cfLabel); + KeychainNative.DictSet(attrs, KeychainNative.SecValueData, cfSecret); + + var status = KeychainNative.SecItemAdd(attrs, out var added); + if (added != IntPtr.Zero) KeychainNative.CFRelease(added); + + if (status == KeychainNative.errSecSuccess) + return; + + if (status != KeychainNative.errSecDuplicateItem) + throw new UserInformationException( + $"Failed to store secret in keychain (status {status})", + "KeyChainInsertFailed"); + + // Duplicate item + if (!overwrite) + throw new UserInformationException( + $"Item already exists in keychain: {label}", + "KeyChainInsertFailed"); + + // Query for the existing item to update (exact key) q = KeychainNative.NewMutableDict(); - KeychainNative.DictSet(q, KeychainNative.SecClass, KeychainNative.SecClassGenericPassword); - KeychainNative.DictSet(q, KeychainNative.SecAttrService, cfService); + KeychainNative.DictSet(q, KeychainNative.SecClass, + isInternet ? KeychainNative.SecClassInternetPassword + : KeychainNative.SecClassGenericPassword); + + if (isInternet) + KeychainNative.DictSet(q, KeychainNative.SecAttrServer, cfServiceOrServer); + else + KeychainNative.DictSet(q, KeychainNative.SecAttrService, cfServiceOrServer); + KeychainNative.DictSet(q, KeychainNative.SecAttrAccount, cfAccount); - var status = KeychainNative.SecItemCopyMatching(q, out var existing); + // Update dictionary + upd = KeychainNative.NewMutableDict(); + KeychainNative.DictSet(upd, KeychainNative.SecValueData, cfSecret); + KeychainNative.DictSet(upd, KeychainNative.SecAttrLabel, cfLabel); - if (status == 0) - { - if (existing != IntPtr.Zero) KeychainNative.CFRelease(existing); - - if (!overwrite) - throw new UserInformationException($"Item already exists in keychain: {label}", "KeyChainInsertFailed"); - - upd = KeychainNative.NewMutableDict(); - KeychainNative.DictSet(upd, KeychainNative.SecValueData, cfSecret); - KeychainNative.DictSet(upd, KeychainNative.SecAttrLabel, cfLabel); - - status = KeychainNative.SecItemUpdate(q, upd); - if (status != 0) - throw new UserInformationException($"Failed to update secret in keychain (status {status})", "KeyChainInsertFailed"); - } - else - { - attrs = KeychainNative.NewMutableDict(); - KeychainNative.DictSet(attrs, KeychainNative.SecClass, KeychainNative.SecClassGenericPassword); - KeychainNative.DictSet(attrs, KeychainNative.SecAttrService, cfService); - KeychainNative.DictSet(attrs, KeychainNative.SecAttrAccount, cfAccount); - KeychainNative.DictSet(attrs, KeychainNative.SecAttrLabel, cfLabel); - KeychainNative.DictSet(attrs, KeychainNative.SecValueData, cfSecret); - - status = KeychainNative.SecItemAdd(attrs, out var added); - if (added != IntPtr.Zero) KeychainNative.CFRelease(added); - - if (status != 0) - throw new UserInformationException($"Failed to store secret in keychain (status {status})", "KeyChainInsertFailed"); - } + status = KeychainNative.SecItemUpdate(q, upd); + if (status != KeychainNative.errSecSuccess) + throw new UserInformationException( + $"Failed to update secret in keychain (status {status})", + "KeyChainInsertFailed"); } finally { - if (q != IntPtr.Zero) KeychainNative.CFRelease(q); if (attrs != IntPtr.Zero) KeychainNative.CFRelease(attrs); + if (q != IntPtr.Zero) KeychainNative.CFRelease(q); if (upd != IntPtr.Zero) KeychainNative.CFRelease(upd); - if (cfService != IntPtr.Zero) KeychainNative.CFRelease(cfService); - if (cfAccount != IntPtr.Zero) KeychainNative.CFRelease(cfAccount); - if (cfLabel != IntPtr.Zero) KeychainNative.CFRelease(cfLabel); - if (cfSecret != IntPtr.Zero) KeychainNative.CFRelease(cfSecret); - } - } - - /// - /// Stores an internet password in the keychain. - /// - /// The label for the item. - /// The secret value. - /// Whether to overwrite if exists. - /// The keychain settings. - private static void SetInternet(string label, string secret, bool overwrite, KeyChainSettings settings) - { - // Preserve earlier semantics: "service" maps to serverName. - var server = settings.Service ?? label; - var account = settings.Account ?? label; - - IntPtr q = IntPtr.Zero, attrs = IntPtr.Zero, upd = IntPtr.Zero; - IntPtr cfServer = IntPtr.Zero, cfAccount = IntPtr.Zero, cfLabel = IntPtr.Zero, cfSecret = IntPtr.Zero; - - try - { - cfServer = KeychainNative.CFString(server); - cfAccount = KeychainNative.CFString(account); - cfLabel = KeychainNative.CFString(label); - cfSecret = KeychainNative.CFData(Encoding.UTF8.GetBytes(secret)); - - // Query for exact (server,account) - q = KeychainNative.NewMutableDict(); - KeychainNative.DictSet(q, KeychainNative.SecClass, KeychainNative.SecClassInternetPassword); - KeychainNative.DictSet(q, KeychainNative.SecAttrServer, cfServer); - KeychainNative.DictSet(q, KeychainNative.SecAttrAccount, cfAccount); - - var status = KeychainNative.SecItemCopyMatching(q, out var existing); - - if (status == 0) - { - if (existing != IntPtr.Zero) KeychainNative.CFRelease(existing); - - if (!overwrite) - throw new UserInformationException($"Item already exists in keychain: {label}", "KeyChainInsertFailed"); - - upd = KeychainNative.NewMutableDict(); - KeychainNative.DictSet(upd, KeychainNative.SecValueData, cfSecret); - KeychainNative.DictSet(upd, KeychainNative.SecAttrLabel, cfLabel); - - status = KeychainNative.SecItemUpdate(q, upd); - if (status != 0) - throw new UserInformationException($"Failed to update secret in keychain (status {status})", "KeyChainInsertFailed"); - } - else - { - attrs = KeychainNative.NewMutableDict(); - KeychainNative.DictSet(attrs, KeychainNative.SecClass, KeychainNative.SecClassInternetPassword); - KeychainNative.DictSet(attrs, KeychainNative.SecAttrServer, cfServer); - KeychainNative.DictSet(attrs, KeychainNative.SecAttrAccount, cfAccount); - KeychainNative.DictSet(attrs, KeychainNative.SecAttrLabel, cfLabel); - KeychainNative.DictSet(attrs, KeychainNative.SecValueData, cfSecret); - - status = KeychainNative.SecItemAdd(attrs, out var added); - if (added != IntPtr.Zero) KeychainNative.CFRelease(added); - - if (status != 0) - throw new UserInformationException($"Failed to store secret in keychain (status {status})", "KeyChainInsertFailed"); - } - } - finally - { - if (q != IntPtr.Zero) KeychainNative.CFRelease(q); - if (attrs != IntPtr.Zero) KeychainNative.CFRelease(attrs); - if (upd != IntPtr.Zero) KeychainNative.CFRelease(upd); - - if (cfServer != IntPtr.Zero) KeychainNative.CFRelease(cfServer); + if (cfServiceOrServer != IntPtr.Zero) KeychainNative.CFRelease(cfServiceOrServer); if (cfAccount != IntPtr.Zero) KeychainNative.CFRelease(cfAccount); if (cfLabel != IntPtr.Zero) KeychainNative.CFRelease(cfLabel); if (cfSecret != IntPtr.Zero) KeychainNative.CFRelease(cfSecret); @@ -722,7 +720,7 @@ public class MacOSKeyChainProvider : ISecretProvider /// The keychain settings. /// Cancellation token. /// The retrieved secret value. - private static async Task GetStringAsync(string name, KeyChainSettings settings, CancellationToken cancellationToken) + private static Task GetStringAsync(string name, KeyChainSettings settings, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -731,11 +729,7 @@ public class MacOSKeyChainProvider : ISecretProvider { try { - var exact = settings.Type == PasswordType.Internet - ? GetInternetExact(name, settings) - : GetGenericExact(name, settings); - - return await Task.FromResult(exact).ConfigureAwait(false); + return Task.FromResult(GetByLabelCore(name, settings, settings.Type == PasswordType.Internet)); } catch (UserInformationException ex) when (ex.HelpID == "KeyChainItemMissing") { @@ -743,101 +737,7 @@ public class MacOSKeyChainProvider : ISecretProvider } } - // If no service/account set, or exact failed: label-only. - var byLabel = settings.Type == PasswordType.Internet - ? GetInternetByLabel(name) - : GetGenericByLabel(name); - - return await Task.FromResult(byLabel).ConfigureAwait(false); - } - - /// - /// Retrieves a generic password using exact service and account match. - /// - /// The name/key. - /// The keychain settings. - /// The password value. - private static string GetGenericExact(string name, KeyChainSettings settings) - { - var service = settings.Service ?? name; - var account = settings.Account ?? name; - - var sBytes = Encoding.UTF8.GetBytes(service); - var aBytes = Encoding.UTF8.GetBytes(account); - - int status = KeychainNative.SecKeychainFindGenericPassword( - IntPtr.Zero, - (uint)sBytes.Length, sBytes, - (uint)aBytes.Length, aBytes, - out var pwLen, out var pwData, out var _); - - if (status != 0) - throw new UserInformationException($"Item not found in keychain: {name}", "KeyChainItemMissing"); - - try - { - return DecodePassword(name, pwLen, pwData); - } - finally - { - try { KeychainNative.SecKeychainItemFreeContent(IntPtr.Zero, pwData); } catch { } - } - } - - /// - /// Retrieves an internet password using exact server and account match. - /// - /// The name/key. - /// The keychain settings. - /// The password value. - private static string GetInternetExact(string name, KeyChainSettings settings) - { - var server = settings.Service ?? name; - var account = settings.Account ?? name; - - var sBytes = Encoding.UTF8.GetBytes(server); - var aBytes = Encoding.UTF8.GetBytes(account); - - int status = KeychainNative.SecKeychainFindInternetPassword( - IntPtr.Zero, - (uint)sBytes.Length, sBytes, - 0, Array.Empty(), - (uint)aBytes.Length, aBytes, - 0, Array.Empty(), - 0, 0, 0, - out var pwLen, out var pwData, out var _); - - if (status != 0) - throw new UserInformationException($"Item not found in keychain: {name}", "KeyChainItemMissing"); - - try - { - return DecodePassword(name, pwLen, pwData); - } - finally - { - try { KeychainNative.SecKeychainItemFreeContent(IntPtr.Zero, pwData); } catch { } - } - } - - /// - /// Retrieves a generic password by label. - /// - /// The label of the item. - /// The password value. - private static string GetGenericByLabel(string label) - { - return GetByLabelCore(label, isInternet: false); - } - - /// - /// Retrieves an internet password by label. - /// - /// The label of the item. - /// The password value. - private static string GetInternetByLabel(string label) - { - return GetByLabelCore(label, isInternet: true); + return Task.FromResult(GetByLabelCore(name, null, settings.Type == PasswordType.Internet)); } /// @@ -846,41 +746,63 @@ public class MacOSKeyChainProvider : ISecretProvider /// The label of the item. /// Whether it's an internet password. /// The password value. - private static string GetByLabelCore(string label, bool isInternet) + private static string GetByLabelCore(string name, KeyChainSettings? settings, bool isInternet) { IntPtr q = IntPtr.Zero; IntPtr cfLabel = IntPtr.Zero; + IntPtr cfService = IntPtr.Zero; + IntPtr cfAccount = IntPtr.Zero; IntPtr result = IntPtr.Zero; try { - cfLabel = KeychainNative.CFString(label); + cfLabel = KeychainNative.CFString(name); q = KeychainNative.NewMutableDict(); KeychainNative.DictSet(q, KeychainNative.SecClass, isInternet ? KeychainNative.SecClassInternetPassword : KeychainNative.SecClassGenericPassword); + + // Always constrain by label/name KeychainNative.DictSet(q, KeychainNative.SecAttrLabel, cfLabel); + + // Optionally constrain by service + account as well + if (settings != null && (!string.IsNullOrWhiteSpace(settings.Service) || !string.IsNullOrWhiteSpace(settings.Account))) + { + var service = GetServiceName(name, settings); + var account = GetAccountName(name, settings); + + cfService = KeychainNative.CFString(service); + cfAccount = KeychainNative.CFString(account); + + KeychainNative.DictSet(q, KeychainNative.SecAttrService, cfService); + KeychainNative.DictSet(q, KeychainNative.SecAttrAccount, cfAccount); + } + KeychainNative.DictSet(q, KeychainNative.SecReturnData, KeychainNative.CFBooleanTrue); KeychainNative.DictSet(q, KeychainNative.SecMatchLimit, KeychainNative.SecMatchLimitOne); var status = KeychainNative.SecItemCopyMatching(q, out result); if (status != 0 || result == IntPtr.Zero) - throw new UserInformationException( - $"Item not found in keychain by label: {label}", - "KeyChainItemMissing"); + { + var msg = settings != null + ? $"Item not found in keychain: label={name}, service={GetServiceName(name, settings)}, account={GetAccountName(name, settings)}" + : $"Item not found in keychain by label: {name}"; + + throw new UserInformationException(msg, "KeyChainItemMissing"); + } var len = (int)KeychainNative.CFDataGetLength(result); if (len <= 0) - throw new UserInformationException($"The key '{label}' returned an empty value", "KeyChainItemEmpty"); + throw new UserInformationException($"The key '{name}' returned an empty value", "KeyChainItemEmpty"); var ptr = KeychainNative.CFDataGetBytePtr(result); var managed = new byte[len]; Marshal.Copy(ptr, managed, 0, len); var output = Encoding.UTF8.GetString(managed).Trim(); - ValidateOutput(label, output); + ValidateOutput(name, output); return output; } finally @@ -888,29 +810,11 @@ public class MacOSKeyChainProvider : ISecretProvider if (result != IntPtr.Zero) KeychainNative.CFRelease(result); if (q != IntPtr.Zero) KeychainNative.CFRelease(q); if (cfLabel != IntPtr.Zero) KeychainNative.CFRelease(cfLabel); + if (cfService != IntPtr.Zero) KeychainNative.CFRelease(cfService); + if (cfAccount != IntPtr.Zero) KeychainNative.CFRelease(cfAccount); } } - /// - /// Decodes password data from native memory. - /// - /// The name/key for error messages. - /// Length of the password data. - /// Pointer to the password data. - /// The decoded password string. - private static string DecodePassword(string name, uint pwLen, IntPtr pwData) - { - if (pwLen == 0 || pwData == IntPtr.Zero) - throw new UserInformationException($"The key '{name}' returned an empty value", "KeyChainItemEmpty"); - - var managed = new byte[pwLen]; - Marshal.Copy(pwData, managed, 0, (int)pwLen); - var output = Encoding.UTF8.GetString(managed).Trim(); - - ValidateOutput(name, output); - return output; - } - /// /// Validates the retrieved output value. /// From 08a93c27404b84bf9b5afccf4e94703b43e7246e Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sun, 7 Dec 2025 22:55:31 +0100 Subject: [PATCH 3/7] Automatically encrypt settings database if secret provider supports setting the password. --- Duplicati/Server/Program.cs | 67 ++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/Duplicati/Server/Program.cs b/Duplicati/Server/Program.cs index 40b622ca1..7b520f1c2 100644 --- a/Duplicati/Server/Program.cs +++ b/Duplicati/Server/Program.cs @@ -84,6 +84,10 @@ namespace Duplicati.Server /// The commandline argument name for help. private const string HELP_OPTION = "help"; + /// + /// The name used in the secret provider for the database encryption key + /// + private const string DATABASE_SECRET_VALUE_NAME = "duplicati-server-encryption-key"; #if DEBUG private const bool DEBUG_MODE = true; @@ -129,6 +133,11 @@ namespace Duplicati.Server /// public static DuplicatiWebserver DuplicatiWebserver { get; set; } + /// + /// The default secret provider + /// + public static ISecretProvider DefaultSecretProvider { get; set; } + /// /// Callback to shutdown the modern webserver /// @@ -194,6 +203,8 @@ namespace Duplicati.Server ApplyEnvironmentVariables(commandlineOptions); ApplySecretProvider(applicationSettings, commandlineOptions, CancellationToken.None).Await(); + DefaultSecretProvider = SecretProviderHelper.GetDefaultSecretProvider(commandlineOptions, CancellationToken.None) + .Await(); var parameterFileOption = PARAMETERS_FILE_OPTION_EXTRAS.Prepend(PARAMETERS_FILE_OPTION) .FirstOrDefault(x => commandlineOptions.ContainsKey(x)); @@ -907,11 +918,65 @@ namespace Duplicati.Server var usingBlacklistedKey = encKey?.IsBlacklisted ?? false; var hasValidEncryptionKey = encKey != null; - applicationSettings.SettingsEncryptionKeyProvidedExternally = hasValidEncryptionKey; + // Don't encrypt the database in debug mode, unless explicitly requested + if (DEBUG_MODE) + { + if (string.IsNullOrWhiteSpace(commandlineOptions.GetValueOrDefault(DISABLE_DB_ENCRYPTION_OPTION))) + disableDbEncryption = true; + } + + // If we are supposed to have an encryption key, but do not, try to get it from the (default) secret provider + if (!hasValidEncryptionKey && DefaultSecretProvider != null) + { + string encryptionKey = null; + try + { + encryptionKey = DefaultSecretProvider.ResolveSecretAsync(DATABASE_SECRET_VALUE_NAME, CancellationToken.None).Await(); + } + catch + { + Log.WriteInformationMessage(LOGTAG, "SecretProviderFailedToGetEncryptionKey", null, Strings.Program.SecretProviderFailedToGetEncryptionKey); + } + + // If there is no encryption key, and the secret provider supports setting secrets, try to set the encryption key + if (!disableDbEncryption && string.IsNullOrWhiteSpace(encryptionKey) && DefaultSecretProvider.IsSetSupported) + { + var tmpkey = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + try + { + DefaultSecretProvider.SetSecretAsync( + DATABASE_SECRET_VALUE_NAME, tmpkey, false, + CancellationToken.None).Await(); + + // For some cloud providers, this can sometimes fail due to eventual consistency + // For now, the solution is that settings will be encrypted in the next run + var tmp = DefaultSecretProvider.ResolveSecretAsync(DATABASE_SECRET_VALUE_NAME, CancellationToken.None).Await(); + + if (tmp != tmpkey) + throw new Exception("Secret provider did not return the expected value"); + + encryptionKey = tmpkey; + } + catch + { + Log.WriteVerboseMessage(LOGTAG, "SecretProviderFailedToSetEncryptionKey", null, Strings.Program.SecretProviderFailedToSetEncryptionKey); + } + } + + // If we got an encryption key (or created one), apply it + if (!string.IsNullOrWhiteSpace(encryptionKey)) + { + encKey = EncryptedFieldHelper.KeyInstance.CreateKeyIfValid(encryptionKey); + hasValidEncryptionKey = encKey != null; + } + } + if (requireDbEncryptionKey && !(hasValidEncryptionKey || disableDbEncryption)) throw new UserInformationException(Strings.Program.DatabaseEncryptionKeyRequired(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME, DISABLE_DB_ENCRYPTION_OPTION), "RequireDbEncryptionKey"); + applicationSettings.SettingsEncryptionKeyProvidedExternally = hasValidEncryptionKey; + var hasEncryptedFields = false; try { From 76dcf1c9ff39a5a044c1d3b6d079481b9b35d6a0 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Sun, 7 Dec 2025 22:57:25 +0100 Subject: [PATCH 4/7] Overwrite existing settings when saving --- Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs index ff1328804..dcf64b2ee 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs @@ -99,7 +99,7 @@ public class PasswordStorageHelper await m_secretProvider.SetSecretAsync( HOSTURL_SECRET_NAME, hostUrl ?? string.Empty, - false, + true, cancellationToken); } @@ -108,7 +108,7 @@ public class PasswordStorageHelper await m_secretProvider.SetSecretAsync( PASSWORD_SECRET_NAME, password ?? string.Empty, - false, + true, cancellationToken); } From 8d7256f434d138bd8c6d117dcfee42b301ee8823 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Mon, 8 Dec 2025 11:33:28 +0100 Subject: [PATCH 5/7] Added configureawait --- .../GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs index dcf64b2ee..c2667694d 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/PasswordStorageHelper.cs @@ -46,7 +46,7 @@ public class PasswordStorageHelper { try { - hostUrl = await secretProvider.ResolveSecretAsync(HOSTURL_SECRET_NAME, CancellationToken.None); + hostUrl = await secretProvider.ResolveSecretAsync(HOSTURL_SECRET_NAME, CancellationToken.None).ConfigureAwait(false); shouldSavePassword = true; } catch (Exception ex) @@ -60,7 +60,7 @@ public class PasswordStorageHelper { try { - password = await secretProvider.ResolveSecretAsync(PASSWORD_SECRET_NAME, CancellationToken.None); + password = await secretProvider.ResolveSecretAsync(PASSWORD_SECRET_NAME, CancellationToken.None).ConfigureAwait(false); shouldSavePassword = true; } catch (Exception ex) @@ -100,7 +100,7 @@ public class PasswordStorageHelper HOSTURL_SECRET_NAME, hostUrl ?? string.Empty, true, - cancellationToken); + cancellationToken).ConfigureAwait(false); } if (!string.IsNullOrWhiteSpace(password) && password != m_password) @@ -109,7 +109,7 @@ public class PasswordStorageHelper PASSWORD_SECRET_NAME, password ?? string.Empty, true, - cancellationToken); + cancellationToken).ConfigureAwait(false); } res = true; From 588cec8aa6282657f9d25677f455612035b63cc0 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Mon, 8 Dec 2025 12:05:24 +0100 Subject: [PATCH 6/7] More robustness for Pass --- .../SecretProvider/UnixPassProvider.cs | 40 +++++++++++----- Duplicati/Library/Utility/ProcessDisposer.cs | 46 +++++++++++++++++++ 2 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 Duplicati/Library/Utility/ProcessDisposer.cs diff --git a/Duplicati/Library/SecretProvider/UnixPassProvider.cs b/Duplicati/Library/SecretProvider/UnixPassProvider.cs index 451eccda7..01c082dc6 100644 --- a/Duplicati/Library/SecretProvider/UnixPassProvider.cs +++ b/Duplicati/Library/SecretProvider/UnixPassProvider.cs @@ -203,9 +203,18 @@ public class UnixPassProvider : ISecretProvider using var process = Process.Start(psi); if (process is null) throw new InvalidOperationException("Failed to start pass"); + using var _ = new ProcessDisposer(process); + + using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + ct.CancelAfter(TimeSpan.FromSeconds(10)); + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct.Token); + var stderrTask = process.StandardError.ReadToEndAsync(ct.Token); + + await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(ct.Token)).ConfigureAwait(false); + + var output = await stdoutTask.ConfigureAwait(false); + var error = await stderrTask.ConfigureAwait(false); - var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); - var error = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(error)) throw new UserInformationException($"Error running pass: {error}", "PassError"); if (process.ExitCode != 0) @@ -239,10 +248,14 @@ public class UnixPassProvider : ISecretProvider using var process = Process.Start(psi); if (process is null) throw new InvalidOperationException("Failed to start pass"); + using var _ = new ProcessDisposer(process); - var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); - await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(cancellationToken)).ConfigureAwait(false); + using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + ct.CancelAfter(TimeSpan.FromSeconds(10)); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct.Token); + var stderrTask = process.StandardError.ReadToEndAsync(ct.Token); + await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(ct.Token)).ConfigureAwait(false); return process.ExitCode == 0; } @@ -276,17 +289,20 @@ public class UnixPassProvider : ISecretProvider using var process = Process.Start(psi); if (process is null) throw new InvalidOperationException("Failed to start pass"); + using var _ = new ProcessDisposer(process); - var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + ct.CancelAfter(TimeSpan.FromSeconds(10)); - await process.StandardInput.WriteAsync(value.AsMemory(), cancellationToken).ConfigureAwait(false); - await process.StandardInput.WriteAsync(Environment.NewLine.AsMemory(), cancellationToken).ConfigureAwait(false); - await process.StandardInput.FlushAsync().ConfigureAwait(false); + var stdoutTask = process.StandardOutput.ReadToEndAsync(ct.Token); + var stderrTask = process.StandardError.ReadToEndAsync(ct.Token); + + await process.StandardInput.WriteAsync(value.AsMemory(), ct.Token).ConfigureAwait(false); + await process.StandardInput.WriteAsync(Environment.NewLine.AsMemory(), ct.Token).ConfigureAwait(false); + await process.StandardInput.FlushAsync(ct.Token).ConfigureAwait(false); process.StandardInput.Close(); - await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(cancellationToken)).ConfigureAwait(false); - + await Task.WhenAll(stdoutTask, stderrTask, process.WaitForExitAsync(ct.Token)).ConfigureAwait(false); if (process.ExitCode != 0) { var error = await stderrTask.ConfigureAwait(false); diff --git a/Duplicati/Library/Utility/ProcessDisposer.cs b/Duplicati/Library/Utility/ProcessDisposer.cs new file mode 100644 index 000000000..07fda1404 --- /dev/null +++ b/Duplicati/Library/Utility/ProcessDisposer.cs @@ -0,0 +1,46 @@ +// 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; + +namespace Duplicati.Library.Utility; + +/// +/// Disposes a process by killing it. +/// +/// The process to wrap +public class ProcessDisposer(System.Diagnostics.Process process) : IDisposable +{ + /// + public void Dispose() + { + try + { + if (!process.HasExited) + process.Kill(true); + } + catch + { + // Ignore + } + } +} From e5610b89a3e4dd20487c3f097e185280b5321889 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Mon, 8 Dec 2025 13:37:54 +0100 Subject: [PATCH 7/7] Fixed a visual startup issue on Linux --- .../GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs index c0f3f6e57..f11f8ba30 100644 --- a/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs +++ b/Duplicati/GUI/Duplicati.GUI.TrayIcon/TrayIconBase.cs @@ -238,8 +238,19 @@ namespace Duplicati.GUI.TrayIcon Program.Connection.Pause(); } - protected Task OnStatusUpdated(IServerStatus status) - => this.UpdateUIState(() => + /// + /// A delay to allow the GUI to startup properly on Linux before updating the tray icon. + /// + private readonly Task m_startupDelay = + OperatingSystem.IsLinux() + ? Task.Delay(1000) + : Task.CompletedTask; + + protected async Task OnStatusUpdated(IServerStatus status) + { + await m_startupDelay; + await this.UpdateUIState(() + => { switch (status.SuggestedStatusIcon) { @@ -285,7 +296,8 @@ namespace Duplicati.GUI.TrayIcon m_pauseMenu.SetHidden(status.SuggestedStatusIcon == SuggestedStatusIcon.Disconnected); m_reconnectMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected || PasswordPrompt.IsShowingDialog); m_changePasswordMenu.SetHidden(status.SuggestedStatusIcon != SuggestedStatusIcon.Disconnected || Program.Connection?.PasswordSource != Program.PasswordSource.SuppliedPassword || PasswordPrompt.IsShowingDialog); - }); + }).ConfigureAwait(false); + } #region IDisposable implementation public abstract void Dispose();