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)