diff --git a/Duplicati/Library/Backend/Filen/FilenBackend.cs b/Duplicati/Library/Backend/Filen/FilenBackend.cs index 7e962421f..5a64546bd 100644 --- a/Duplicati/Library/Backend/Filen/FilenBackend.cs +++ b/Duplicati/Library/Backend/Filen/FilenBackend.cs @@ -39,6 +39,10 @@ public class FilenBackend : IStreamingBackend /// private const string MoveToTrashOption = "move-to-trash"; /// + /// The API key option name + /// + private const string ApiKeyOption = "api-key"; + /// /// The Filen client instance /// private FilenClient? _client; @@ -59,6 +63,10 @@ public class FilenBackend : IStreamingBackend /// private readonly string? _twoFactorCode; /// + /// The API key, if any + /// + private readonly string? _apiKey; + /// /// Whether to move files to the trash instead of deleting them /// private readonly bool _moveToTrash; @@ -93,6 +101,7 @@ public class FilenBackend : IStreamingBackend _moveToTrash = Utility.Utility.ParseBoolOption(options, MoveToTrashOption); _twoFactorCode = options.GetValueOrDefault(TwoFactorOption); + _apiKey = options.GetValueOrDefault(ApiKeyOption); _timeout = TimeoutOptionsHelper.Parse(options); } @@ -109,7 +118,7 @@ public class FilenBackend : IStreamingBackend _client = null; var httpClient = HttpClientHelper.CreateClient(); httpClient.Timeout = Timeout.InfiniteTimeSpan; - _client = await FilenClient.CreateClientAsync(httpClient, _auth.Username!, _auth.Password!, _twoFactorCode, cancellationToken).ConfigureAwait(false); + _client = await FilenClient.CreateClientAsync(httpClient, _auth.Username!, _auth.Password!, _twoFactorCode, _apiKey, cancellationToken).ConfigureAwait(false); } return _client; @@ -129,10 +138,19 @@ public class FilenBackend : IStreamingBackend public IList SupportedCommands => [ .. AuthOptionsHelper.GetOptions(), new CommandLineArgument(TwoFactorOption, CommandLineArgument.ArgumentType.String, Strings.FilenBackend.TwoFactorShort, Strings.FilenBackend.TwoFactorLong), + new CommandLineArgument(ApiKeyOption, CommandLineArgument.ArgumentType.Password, Strings.FilenBackend.ApiKeyShort, Strings.FilenBackend.ApiKeyLong), new CommandLineArgument(MoveToTrashOption, CommandLineArgument.ArgumentType.Boolean, Strings.FilenBackend.MoveToTrashShort, Strings.FilenBackend.MoveToTrashLong), .. TimeoutOptionsHelper.GetOptions() ]; + /// + /// Gets an API key for the account + /// + /// The cancellation token to use + /// The API key + internal async Task GetApiKey(CancellationToken cancellationToken) + => (await GetClientAsync(cancellationToken))?.ApiKey; + /// /// Gets the folder uuid for the folder this backend is working in /// diff --git a/Duplicati/Library/Backend/Filen/FilenClient.cs b/Duplicati/Library/Backend/Filen/FilenClient.cs index c7c6a54cd..711ed1863 100644 --- a/Duplicati/Library/Backend/Filen/FilenClient.cs +++ b/Duplicati/Library/Backend/Filen/FilenClient.cs @@ -182,6 +182,11 @@ public class FilenClient : IDisposable /// public DateTime ValidUntil => _validUntil; + /// + /// The API key for the client + /// + internal string ApiKey => _authResult.ApiKey; + /// /// Creates a new Filen client and authenticates /// @@ -189,74 +194,136 @@ public class FilenClient : IDisposable /// The email address to use for login /// The password to use for login /// The two-factor code to use for login + /// The API key to use for login /// The cancellation token to use for the operation /// The authenticated Filen client - public static async Task CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, CancellationToken cancellationToken) + public static async Task CreateClientAsync(HttpClient httpClient, string email, string password, string? twoFactorCode, string? apiKey, CancellationToken cancellationToken) { var baseUrl = GatewayUrls[Random.Shared.Next(0, GatewayUrls.Count)]; - var authResult = await FilenLogin.AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, cancellationToken).ConfigureAwait(false); + var authResult = await AuthenticateAsync(httpClient, baseUrl, email, password, twoFactorCode, apiKey, cancellationToken).ConfigureAwait(false); return new FilenClient(httpClient, authResult, baseUrl); } /// - /// Methods used for the initial login + /// Returns the authentication information for the user /// - private static class FilenLogin + /// The HTTP client to use for requests + /// The base url for all requests + /// The email address to use for login + /// The cancellation token to use for the operation + /// The authentication information for the user + private static async Task GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken) { - /// - /// Returns the authentication information for the user - /// - /// The HTTP client to use for requests - /// The base url for all requests - /// The email address to use for login - /// The cancellation token to use for the operation - /// The authentication information for the user - private static async Task GetAuthInfoAsync(HttpClient httpClient, string baseUrl, string email, CancellationToken cancellationToken) + var loginUrl = $"{baseUrl}/v3/auth/info"; + using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl); + request.Content = new StringContent(JsonSerializer.Serialize(new { email }), Encoding.UTF8, "application/json"); + + var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); + } + + /// + /// Authenticates the user with the Filen API + /// + /// The HTTP client to use for requests + /// The base url for all requests + /// The email address to use for login + /// The password to use for login + /// The two-factor code to use for login + /// The API key to use for login + /// The cancellation token to use for the operation + /// The authentication result from the initial login + private static async Task AuthenticateAsync( + HttpClient httpClient, + string baseUrl, + string email, + string password, + string? twoFactorCode, + string? apiKey, + CancellationToken cancellationToken) + { + // Always need authInfo to derive the account master key from password + var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken) + .ConfigureAwait(false); + + var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion( + password, authInfo.AuthVersion, authInfo.Salt); + + // 1) Fast-path: if apiKey is provided, try to use it to fetch master keys first + if (!string.IsNullOrWhiteSpace(apiKey)) { - var loginUrl = $"{baseUrl}/v3/auth/info"; - using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl); - request.Content = new StringContent(JsonSerializer.Serialize(new { email }), Encoding.UTF8, "application/json"); - - var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - return await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); - } - - /// - /// Authenticates the user with the Filen API - /// - /// The HTTP client to use for requests - /// The base url for all requests - /// The email address to use for login - /// The password to use for login - /// The two-factor code to use for login - /// The cancellation token to use for the operation - /// The authentication result from the initial login - public static async Task AuthenticateAsync(HttpClient httpClient, string baseUrl, string email, string password, string? twoFactorCode, CancellationToken cancellationToken) - { - var authInfo = await GetAuthInfoAsync(httpClient, baseUrl, email, cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(twoFactorCode)) - twoFactorCode = "XXXXXX"; - - var rootKeys = FilenCrypto.GeneratePasswordAndMasterKeyBasedOnAuthVersion(password, authInfo.AuthVersion, authInfo.Salt); - var loginUrl = $"{baseUrl}/v3/login"; - using var request = new HttpRequestMessage(HttpMethod.Post, loginUrl); - request.Content = new StringContent(JsonSerializer.Serialize(new { email, password = rootKeys.Password, twoFactorCode, authVersion = authInfo.AuthVersion }), Encoding.UTF8, "application/json"); - - using var response = await httpClient.SendAsync(request, cancellationToken); - var result = await ExtractDataFromResponse(response, cancellationToken).ConfigureAwait(false); - - // var mk = await GetAllMasterKeys(masterKey1, result.ApiKey, cancellationToken).ConfigureAwait(false); - - var masterKeys = rootKeys.MasterKey.DecryptMetadata(result.MasterKeys); - - return new FilenAuthResult + try { - ApiKey = result.ApiKey, - AccountMasterKey = rootKeys.MasterKey, - MasterKeys = masterKeys.Split('|').Select(DerivedKey.Create).ToList() - }; + var mkUrl = $"{baseUrl}/v3/user/masterKeys"; + using var mkReq = new HttpRequestMessage(HttpMethod.Post, mkUrl); + mkReq.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + + // For retrieval, Filen accepts an empty/placeholder body + mkReq.Content = new StringContent( + JsonSerializer.Serialize(new { masterKeys = "" }), + Encoding.UTF8, + "application/json"); + + using var mkResp = await httpClient.SendAsync(mkReq, cancellationToken) + .ConfigureAwait(false); + + mkResp.EnsureSuccessStatusCode(); + + var mkResult = await ExtractDataFromResponse(mkResp, cancellationToken) + .ConfigureAwait(false); + + var masterKeysPlain = rootKeys.MasterKey.DecryptMetadata(mkResult.MasterKeys); + + return new FilenAuthResult + { + ApiKey = apiKey, + AccountMasterKey = rootKeys.MasterKey, + MasterKeys = masterKeysPlain.Split('|').Select(DerivedKey.Create).ToList() + }; + } + catch + { + // Any failure (invalid/expired apiKey, network, schema, decrypt) -> fall back to login + } } + + // 2) Fallback: login endpoint (requires MFA if enabled) + if (string.IsNullOrWhiteSpace(twoFactorCode)) + twoFactorCode = "XXXXXX"; + + var loginUrl = $"{baseUrl}/v3/login"; + using var loginReq = new HttpRequestMessage(HttpMethod.Post, loginUrl); + loginReq.Content = new StringContent( + JsonSerializer.Serialize(new + { + email, + password = rootKeys.Password, + twoFactorCode, + authVersion = authInfo.AuthVersion + }), + Encoding.UTF8, + "application/json"); + + using var loginResp = await httpClient.SendAsync(loginReq, cancellationToken) + .ConfigureAwait(false); + + var loginResult = await ExtractDataFromResponse(loginResp, cancellationToken) + .ConfigureAwait(false); + + var masterKeys = rootKeys.MasterKey.DecryptMetadata(loginResult.MasterKeys); + + return new FilenAuthResult + { + ApiKey = loginResult.ApiKey, + AccountMasterKey = rootKeys.MasterKey, + MasterKeys = masterKeys.Split('|').Select(DerivedKey.Create).ToList() + }; + } + + private sealed class MasterKeysResponse + { + public string MasterKeys { get; set; } = ""; } /// diff --git a/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs b/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs new file mode 100644 index 000000000..ac0cf6325 --- /dev/null +++ b/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs @@ -0,0 +1,81 @@ +// 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. +using Duplicati.Library.Interface; +using Duplicati.Library.Utility; +using Duplicati.Library.Utility.Options; + +namespace Duplicati.Library.Backend.Filen; + +/// +/// Web module to help users obtain an API key for Filen.io +/// +public class GetApiKeyModule : IWebModule +{ + /// + public string Key => "filen-get-api-key"; + + /// + public string DisplayName => "Get Filen.io API Key"; + + /// + public string Description => "Module to help users obtain an API key for Filen.io using their email password, and MFA code."; + + /// + /// Constructor for metadata loading + /// + public GetApiKeyModule() + { + } + + /// + public IList SupportedCommands => + [ + .. AuthOptionsHelper.GetOptions(), + new CommandLineArgument("two-factor", CommandLineArgument.ArgumentType.String, Strings.FilenBackend.TwoFactorShort, Strings.FilenBackend.TwoFactorLong) + ]; + + /// + public IDictionary Execute(IDictionary options) + { + options.TryGetValue("filen-operation", out var operation); + if (operation != "GetApiKey") + throw new UserInformationException("Invalid operation", "InvalidOperation"); + + options.TryGetValue("url", out var url); + if (string.IsNullOrEmpty(url)) + throw new UserInformationException("URL is required", "UrlOptionMissing"); + + var uri = new Utility.Uri(url); + + var newOpts = new Dictionary(options); + foreach (var key in uri.QueryParameters.AllKeys) + if (key != null) + newOpts[key] = uri.QueryParameters[key]; + + var backend = new FilenBackend(url, newOpts); + var apiKey = backend.GetApiKey(CancellationToken.None).Await(); + return new Dictionary { { "api-key", apiKey ?? string.Empty } }; + } + + /// + public IDictionary> GetLookups() + => new Dictionary>(); +} diff --git a/Duplicati/Library/Backend/Filen/Strings.cs b/Duplicati/Library/Backend/Filen/Strings.cs index fff18b3a3..05cf1d608 100644 --- a/Duplicati/Library/Backend/Filen/Strings.cs +++ b/Duplicati/Library/Backend/Filen/Strings.cs @@ -27,6 +27,8 @@ namespace Duplicati.Library.Backend.Strings public static string DisplayName => LC.L(@"Filen.io"); public static string TwoFactorShort => LC.L(@"Optional 2-factor code"); public static string TwoFactorLong => LC.L(@"The 2-factor code to use for authentication, leave empty if the account is not MFA protected. Note that a new code must be provided by the user for each authentication attempt."); + public static string ApiKeyShort => LC.L(@"Optional API key"); + public static string ApiKeyLong => LC.L(@"The API key to use for authentication, which will work even if the account is MFA protected. Obtain the API key via the Filen CLI tool."); public static string MoveToTrashShort => LC.L(@"Move to trash"); public static string MoveToTrashLong => LC.L(@"If set, files will be moved to the trash instead of being deleted permanently."); } diff --git a/Duplicati/Library/Backends/WebModules.cs b/Duplicati/Library/Backends/WebModules.cs index 3b1c244b4..d6cd91837 100644 --- a/Duplicati/Library/Backends/WebModules.cs +++ b/Duplicati/Library/Backends/WebModules.cs @@ -37,5 +37,6 @@ public static class WebModules new KeyGenerator(), new KeyUploader(), new Storj.StorjConfig(), + new Filen.GetApiKeyModule(), ]; } diff --git a/Duplicati/WebserverCore/Endpoints/V1/WebModules.cs b/Duplicati/WebserverCore/Endpoints/V1/WebModules.cs index 4ef47e40e..9db7329e9 100644 --- a/Duplicati/WebserverCore/Endpoints/V1/WebModules.cs +++ b/Duplicati/WebserverCore/Endpoints/V1/WebModules.cs @@ -41,6 +41,19 @@ public record WebModules : IEndpointV1 private static IEnumerable ExecuteGet() => Library.DynamicLoader.WebLoader.Modules; + private static string UnmaskUrl(Connection connection, string maskedurl, string? backupId) + { + var previousUrl = !string.IsNullOrWhiteSpace(backupId) ? connection.GetBackup(backupId)?.TargetURL : null; + var unmasked = string.IsNullOrWhiteSpace(previousUrl) + ? maskedurl + : QuerystringMasking.Unmask(maskedurl, previousUrl); + + if (Connection.UrlContainsPasswordPlaceholder(unmasked)) + throw new ArgumentException("Unmasked URL contains password placeholder"); + + return unmasked; + } + private static async Task ExecutePost(Connection connection, IApplicationSettings applicationSettings, string modulekey, Dictionary inputOptions, CancellationToken cancellationToken) { var m = Library.DynamicLoader.WebLoader.Modules.FirstOrDefault(x => x.Key.Equals(modulekey, StringComparison.OrdinalIgnoreCase)) @@ -52,6 +65,12 @@ public record WebModules : IEndpointV1 await SecretProviderHelper.ApplySecretProviderAsync([], [], options, Library.Utility.TempFolder.SystemTempPath, applicationSettings.SecretProvider, cancellationToken); + if (options.TryGetValue("url", out var maskedurl) && !string.IsNullOrEmpty(maskedurl)) + { + var unmasked = UnmaskUrl(connection, maskedurl, options.GetValueOrDefault("backup-id")); + options["url"] = unmasked; + } + return new Dto.WebModuleOutputDto( Status: "OK", Result: m.Execute(options)