From c5ab7c778b60519b1e2e9b4f707353d7ad6d04a2 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Dec 2025 16:28:27 +0100 Subject: [PATCH 1/2] Add support for Filen.io API key This adds support for using an API key with Filen to work with an MFA protected account The webmodule needs to be integrated into ngclient, so the user can simply click the button and get the api-key assigned. --- .../Library/Backend/Filen/FilenBackend.cs | 20 +- .../Library/Backend/Filen/FilenClient.cs | 177 ++++++++++++------ .../Library/Backend/Filen/GetApiKeyModule.cs | 66 +++++++ Duplicati/Library/Backend/Filen/Strings.cs | 2 + Duplicati/Library/Backends/WebModules.cs | 1 + 5 files changed, 210 insertions(+), 56 deletions(-) create mode 100644 Duplicati/Library/Backend/Filen/GetApiKeyModule.cs diff --git a/Duplicati/Library/Backend/Filen/FilenBackend.cs b/Duplicati/Library/Backend/Filen/FilenBackend.cs index 7e962421f..a7f0f521e 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.String, 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..ffadbdec1 --- /dev/null +++ b/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs @@ -0,0 +1,66 @@ +// 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) + { + var backend = new FilenBackend(options["url"], new Dictionary()); + 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(), ]; } From e968f81f26b0cf2b11e37a577276b430f483e16b Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Wed, 10 Dec 2025 11:54:23 +0100 Subject: [PATCH 2/2] Fixed issues with webmodule --- .../Library/Backend/Filen/FilenBackend.cs | 2 +- .../Library/Backend/Filen/GetApiKeyModule.cs | 19 +++++++++++++++++-- .../WebserverCore/Endpoints/V1/WebModules.cs | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Duplicati/Library/Backend/Filen/FilenBackend.cs b/Duplicati/Library/Backend/Filen/FilenBackend.cs index a7f0f521e..5a64546bd 100644 --- a/Duplicati/Library/Backend/Filen/FilenBackend.cs +++ b/Duplicati/Library/Backend/Filen/FilenBackend.cs @@ -138,7 +138,7 @@ 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.String, Strings.FilenBackend.ApiKeyShort, Strings.FilenBackend.ApiKeyLong), + 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() ]; diff --git a/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs b/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs index ffadbdec1..ac0cf6325 100644 --- a/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs +++ b/Duplicati/Library/Backend/Filen/GetApiKeyModule.cs @@ -53,9 +53,24 @@ public class GetApiKeyModule : IWebModule ]; /// - public IDictionary Execute(IDictionary options) + public IDictionary Execute(IDictionary options) { - var backend = new FilenBackend(options["url"], new Dictionary()); + 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 } }; } 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)