Merge pull request #6672 from duplicati/feature/store-password-for-trayicon
Store password with TrayIcon
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<IServerStatus, Task>? 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<string> m_passwordUpdatedTcs = new TaskCompletionSource<string>();
|
||||
|
||||
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<BackgroundRequest>(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<string, string> options)
|
||||
Dictionary<string, string> 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<string>(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<SigninResponse>("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<T> PerformRequestInternalAsync<T>(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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <param name="hostUrl">The host URL to show in the prompt</param>
|
||||
/// <param name="password">The current password to pre-fill, if any</param
|
||||
/// <param name="isChangePassword">Whether this is a change password prompt</param>
|
||||
/// <returns>A task that completes with the password, or null if cancelled</returns>
|
||||
public static Task<string?> ShowPasswordDialogAsync(bool isChangePassword)
|
||||
public static Task<bool> ShowPasswordDialogAsync(bool isChangePassword)
|
||||
{
|
||||
if (IsShowingDialog)
|
||||
throw new InvalidOperationException("A password prompt dialog is already showing.");
|
||||
|
||||
IsShowingDialog = true;
|
||||
var tcs = new TaskCompletionSource<string?>();
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
@@ -64,27 +68,27 @@ internal static class PasswordPrompt
|
||||
});
|
||||
}
|
||||
|
||||
private static void ShowDialogInternal(TaskCompletionSource<string?> tcs, bool isChangePassword)
|
||||
private static void ShowDialogInternal(TaskCompletionSource<bool> 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<string?>? 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);
|
||||
}
|
||||
|
||||
@@ -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<PasswordStorageHelper>();
|
||||
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<bool> m_passwordUpdatedTcs = new TaskCompletionSource<bool>();
|
||||
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<PasswordStorageHelper> CreateAsync(string? hostUrl, bool customUrl, string? password, Program.PasswordSource passwordSource, Dictionary<string, string?> 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).ConfigureAwait(false);
|
||||
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).ConfigureAwait(false);
|
||||
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<bool> 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,
|
||||
true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(password) && password != m_password)
|
||||
{
|
||||
await m_secretProvider.SetSecretAsync(
|
||||
PASSWORD_SECRET_NAME,
|
||||
password ?? string.Empty,
|
||||
true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,15 @@ namespace Duplicati.GUI.TrayIcon
|
||||
SuppliedPassword
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The connection to the server
|
||||
/// </summary>
|
||||
public static HttpServerConnection Connection;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that a password is needed and should be requested via GUI after Avalonia starts
|
||||
/// The default secret provider
|
||||
/// </summary>
|
||||
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<string>());
|
||||
|
||||
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<string, string> options, HostedInstanceKeeper hosted, PasswordSource passwordSource, string password, string acceptedHostCertificate)
|
||||
private static void StartTray(string[] _args, Dictionary<string, string> 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();
|
||||
|
||||
@@ -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()
|
||||
@@ -251,8 +238,19 @@ namespace Duplicati.GUI.TrayIcon
|
||||
Program.Connection.Pause();
|
||||
}
|
||||
|
||||
protected Task OnStatusUpdated(IServerStatus status)
|
||||
=> this.UpdateUIState(() =>
|
||||
/// <summary>
|
||||
/// A delay to allow the GUI to startup properly on Linux before updating the tray icon.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
@@ -298,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default secret provider, if any
|
||||
/// </summary>
|
||||
/// <param name="options">The options passed</param>
|
||||
/// <param name="cancellationToken">The cancellation token</param>
|
||||
/// <returns></returns>
|
||||
public static async Task<ISecretProvider?> GetDefaultSecretProvider(Dictionary<string, string?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single secret from the provider
|
||||
/// </summary>
|
||||
/// <param name="provider">The secret provider</param>
|
||||
/// <param name="name">The name of the secret</param>
|
||||
/// <param name="cancelToken">The cancellation token</param>
|
||||
/// <returns>The resolved secret</returns>
|
||||
public static async Task<string> ResolveSecretAsync(this ISecretProvider provider, string name, CancellationToken cancelToken)
|
||||
=> (await provider.ResolveSecretsAsync([name], cancelToken).ConfigureAwait(false))[name];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key from a value using the pattern, and also collects partial matches
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -194,6 +194,15 @@ public class MacOSKeyChainProvider : ISecretProvider
|
||||
/// </summary>
|
||||
private const string CoreFoundationLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||
|
||||
/// <summary>
|
||||
/// Success status code
|
||||
/// </summary>
|
||||
public const int errSecSuccess = 0;
|
||||
/// <summary>
|
||||
/// Duplicate item status code
|
||||
/// </summary>
|
||||
public const int errSecDuplicateItem = -25299;
|
||||
|
||||
// Classic exact-match APIs (fast path)
|
||||
/// <summary>
|
||||
/// Finds a generic password item in the keychain.
|
||||
@@ -392,12 +401,29 @@ public class MacOSKeyChainProvider : ISecretProvider
|
||||
/// </summary>
|
||||
private const string LibSystem = "/usr/lib/libSystem.B.dylib";
|
||||
|
||||
/// <summary>
|
||||
/// Loads a dynamic library.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the library.</param>
|
||||
/// <param name="mode">The loading mode.</param>
|
||||
/// <returns>Handle to the loaded library.</returns>
|
||||
[DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr dlopen(string path, int mode);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a symbol from a dynamic library.
|
||||
/// </summary>
|
||||
/// <param name="handle">Handle to the loaded library.</param>
|
||||
/// <param name="symbol">The symbol to resolve.</param>
|
||||
/// <returns>Pointer to the resolved symbol.</returns>
|
||||
[DllImport(LibSystem, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern IntPtr dlsym(IntPtr handle, string symbol);
|
||||
|
||||
/// <summary>
|
||||
/// Closes a dynamic library.
|
||||
/// </summary>
|
||||
/// <param name="handle">Handle to the loaded library.</param>
|
||||
/// <returns>Zero on success.</returns>
|
||||
[DllImport(LibSystem, CallingConvention = CallingConvention.Cdecl)]
|
||||
private static extern int dlclose(IntPtr handle);
|
||||
|
||||
@@ -453,6 +479,9 @@ public class MacOSKeyChainProvider : ISecretProvider
|
||||
/// </summary>
|
||||
internal static readonly IntPtr CFBooleanTrue;
|
||||
|
||||
/// <summary>
|
||||
/// Static constructor to load native libraries and resolve constant symbols.
|
||||
/// </summary>
|
||||
static KeychainNative()
|
||||
{
|
||||
const int RTLD_LAZY = 0x1;
|
||||
@@ -477,6 +506,12 @@ public class MacOSKeyChainProvider : ISecretProvider
|
||||
CFBooleanTrue = GetSymbol(_coreFoundationHandle, "kCFBooleanTrue");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a symbol and reads its pointer value.
|
||||
/// </summary>
|
||||
/// <param name="handle">Handle to the loaded library.</param>
|
||||
/// <param name="name">The symbol to resolve.</param>
|
||||
/// <returns>Pointer to the resolved symbol.</returns>
|
||||
private static IntPtr GetSymbol(IntPtr handle, string name)
|
||||
{
|
||||
var symbolPtr = dlsym(handle, name);
|
||||
@@ -553,17 +588,38 @@ public class MacOSKeyChainProvider : ISecretProvider
|
||||
/// <param name="overwrite">Whether to overwrite an existing item.</param>
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
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);
|
||||
/// <summary>
|
||||
/// Returns the service name to use for the given name and settings.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the secret.</param>
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
/// <returns>The service name to use.</returns>
|
||||
private static string GetServiceName(string name, KeyChainSettings settings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.Service))
|
||||
return name;
|
||||
return $"{settings.Service}.{name}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the account name to use for the given name and settings.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the secret.</param>
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
/// <returns>The account name to use.</returns>
|
||||
private static string GetAccountName(string name, KeyChainSettings settings)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.Account))
|
||||
return name;
|
||||
return settings.Account;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -573,142 +629,84 @@ public class MacOSKeyChainProvider : ISecretProvider
|
||||
/// <param name="secret">The secret value.</param>
|
||||
/// <param name="overwrite">Whether to overwrite if exists.</param>
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores an internet password in the keychain.
|
||||
/// </summary>
|
||||
/// <param name="label">The label for the item.</param>
|
||||
/// <param name="secret">The secret value.</param>
|
||||
/// <param name="overwrite">Whether to overwrite if exists.</param>
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
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
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The retrieved secret value.</returns>
|
||||
private static async Task<string> GetStringAsync(string name, KeyChainSettings settings, CancellationToken cancellationToken)
|
||||
private static Task<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a generic password using exact service and account match.
|
||||
/// </summary>
|
||||
/// <param name="name">The name/key.</param>
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
/// <returns>The password value.</returns>
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an internet password using exact server and account match.
|
||||
/// </summary>
|
||||
/// <param name="name">The name/key.</param>
|
||||
/// <param name="settings">The keychain settings.</param>
|
||||
/// <returns>The password value.</returns>
|
||||
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<byte>(),
|
||||
(uint)aBytes.Length, aBytes,
|
||||
0, Array.Empty<byte>(),
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a generic password by label.
|
||||
/// </summary>
|
||||
/// <param name="label">The label of the item.</param>
|
||||
/// <returns>The password value.</returns>
|
||||
private static string GetGenericByLabel(string label)
|
||||
{
|
||||
return GetByLabelCore(label, isInternet: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an internet password by label.
|
||||
/// </summary>
|
||||
/// <param name="label">The label of the item.</param>
|
||||
/// <returns>The password value.</returns>
|
||||
private static string GetInternetByLabel(string label)
|
||||
{
|
||||
return GetByLabelCore(label, isInternet: true);
|
||||
return Task.FromResult(GetByLabelCore(name, null, settings.Type == PasswordType.Internet));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -846,41 +746,63 @@ public class MacOSKeyChainProvider : ISecretProvider
|
||||
/// <param name="label">The label of the item.</param>
|
||||
/// <param name="isInternet">Whether it's an internet password.</param>
|
||||
/// <returns>The password value.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes password data from native memory.
|
||||
/// </summary>
|
||||
/// <param name="name">The name/key for error messages.</param>
|
||||
/// <param name="pwLen">Length of the password data.</param>
|
||||
/// <param name="pwData">Pointer to the password data.</param>
|
||||
/// <returns>The decoded password string.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the retrieved output value.
|
||||
/// </summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Disposes a process by killing it.
|
||||
/// </summary>
|
||||
/// <param name="process">The process to wrap</param>
|
||||
public class ProcessDisposer(System.Diagnostics.Process process) : IDisposable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,10 @@ namespace Duplicati.Server
|
||||
/// <summary>The commandline argument name for help.</summary>
|
||||
private const string HELP_OPTION = "help";
|
||||
|
||||
/// <summary>
|
||||
/// The name used in the secret provider for the database encryption key
|
||||
/// </summary>
|
||||
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
|
||||
/// </summary>
|
||||
public static DuplicatiWebserver DuplicatiWebserver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The default secret provider
|
||||
/// </summary>
|
||||
public static ISecretProvider DefaultSecretProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback to shutdown the modern webserver
|
||||
/// </summary>
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user