// 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 System.Runtime.Versioning;
using System.Text;
using System.Threading.Tasks;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
using secrets.DBus;
using Tmds.DBus.Protocol;
namespace Duplicati.Library.SecretProvider.LibSecret;
///
/// Implementation of the secret collection for libsecret
///
[SupportedOSPlatform("Linux")]
public class SecretCollection : IDisposable
{
///
/// The fallback collection name to use when "default" is requested but doesn't exist.
/// The "login" collection is automatically unlocked when the user logs in.
///
public const string DefaultCollectionActualName = "login";
///
/// The log tag for the secret collection
///
private static readonly string LogTag = Log.LogTagFromType();
///
/// The secrets service
///
private readonly secretsService _secretsService;
///
/// The service instance
///
private readonly Service _service;
///
/// The session instance
///
private readonly Session _session;
///
/// The collection instance
///
private readonly Collection _collection;
///
/// Whether the collection is locked
///
private bool _locked;
///
/// Creates a new secret collection
///
/// The secrets service
/// The service instance
/// The session instance
/// The collection instance
/// Whether the collection is locked
private SecretCollection(secretsService secretsService, Service service, Session session, Collection collection, bool locked)
{
_secretsService = secretsService;
_service = service;
_session = session;
_collection = collection;
_locked = locked;
}
///
/// Creates a new secret collection
///
/// The collection name
/// The cancellation token
/// The created secret collection
public static Task CreateAsync(string collectionName, CancellationToken cancellationToken)
=> CreateAsync(collectionName, autoCreateCollection: false, cancellationToken);
///
/// Creates a new secret collection and optionally auto-creates it if it does not exist.
///
/// The collection name
/// If set, the collection will be created when it does not exist
/// The cancellation token
/// The created secret collection
public static async Task CreateAsync(string collectionName, bool autoCreateCollection, CancellationToken cancellationToken)
{
var connection = Connection.Session;
var secretsService = new secretsService(connection, "org.freedesktop.secrets");
var service = secretsService.CreateService("/org/freedesktop/secrets");
var (_, sessionPath) = await service.OpenSessionAsync("plain", "").ConfigureAwait(false);
collectionName ??= string.Empty;
var collections = await service.GetCollectionsAsync().ConfigureAwait(false);
var collectionPath = collections
.FirstOrDefault(c => c.ToString().EndsWith(collectionName, StringComparison.OrdinalIgnoreCase));
// If "default" collection doesn't exist, fall back to "login" collection
if (!collectionPath.ToString().EndsWith(collectionName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(collectionName, "default", StringComparison.OrdinalIgnoreCase))
{
collectionPath = collections
.FirstOrDefault(c => c.ToString().EndsWith(DefaultCollectionActualName, StringComparison.OrdinalIgnoreCase));
if (collectionPath.ToString().EndsWith(DefaultCollectionActualName, StringComparison.OrdinalIgnoreCase))
collectionName = DefaultCollectionActualName;
}
if (!collectionPath.ToString().EndsWith(collectionName, StringComparison.OrdinalIgnoreCase))
{
if (!autoCreateCollection)
throw new UserInformationException($"Collection {collectionName} not found", "CollectionNotFound");
// Auto-create the collection
var properties = new Dictionary
{
["org.freedesktop.Secret.Collection.Label"] = VariantValue.String(collectionName)
};
var (createdCollectionPath, promptPath) = await service.CreateCollectionAsync(properties, string.Empty).ConfigureAwait(false);
if (promptPath != null && promptPath != "/")
{
var promptInstance = secretsService.CreatePrompt(promptPath);
var completedTask = new TaskCompletionSource();
// Cancel the wait if the caller cancels
using var cancellationRegistration = cancellationToken.Register(() =>
{
completedTask.TrySetCanceled(cancellationToken);
});
// Subscribe to completion signal
using var _ = await promptInstance.WatchCompletedAsync((exception, result) =>
{
if (exception != null)
completedTask.TrySetException(exception);
else if (result.Dismissed)
completedTask.TrySetException(new UserInformationException("Dismissed collection create prompt", "CreateCollectionDismissed"));
else
{
// The result contains the path to the created collection
var resultPath = result.Result.GetObjectPathAsString();
completedTask.TrySetResult(resultPath);
}
}).ConfigureAwait(false);
// Ask the secrets service to show the prompt
await promptInstance.PromptAsync(string.Empty).ConfigureAwait(false);
// Wait for either completion or a timeout
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(120), cancellationToken);
var finishedTask = await Task.WhenAny(completedTask.Task, timeoutTask).ConfigureAwait(false);
if (finishedTask != completedTask.Task)
throw new UserInformationException("Timed out waiting for libsecret collection create prompt. Ensure that a secret service/keyring is running and able to show prompts.", "CreateCollectionPromptTimeout");
var resultPathString = await completedTask.Task.ConfigureAwait(false);
collectionPath = new ObjectPath(resultPathString);
}
else
{
collectionPath = createdCollectionPath;
}
}
var session = secretsService.CreateSession(sessionPath);
var collection = secretsService.CreateCollection(collectionPath.ToString());
var locked = await collection.GetLockedAsync().ConfigureAwait(false);
return new SecretCollection(secretsService, service, session, collection, locked);
}
///
/// Checks whether the libsecret DBus service is available on the current platform.
///
/// The cancellation token.
/// true when the provider can be used; otherwise false.
public static async Task IsSupported(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsLinux())
return false;
// Heuristic: libsecret prompts require a graphical session to be useful.
// If there is no X11/Wayland display, we treat libsecret as unsupported.
var hasDisplay =
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")) ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"));
if (!hasDisplay)
return false;
try
{
var connection = Connection.Session;
var secretsService = new secretsService(connection, "org.freedesktop.secrets");
var service = secretsService.CreateService("/org/freedesktop/secrets");
var task = service.GetCollectionsAsync();
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
var finishedTask = Task.WhenAny(task, timeoutTask);
if (await finishedTask.ConfigureAwait(false) != task)
return false;
return true;
}
catch
{
return false;
}
}
///
/// Checks whether a specific libsecret collection exists.
/// This is a non-throwing, best-effort check used by
/// to determine if the provider should be considered supported for a given collection.
///
/// The collection name to check. If null or empty, the default collection name is used.
/// param name="cancellationToken">The cancellation token.
/// true if the collection exists and libsecret is available; otherwise false.
public static async Task CollectionExists(string collectionName, CancellationToken cancellationToken)
{
try
{
var connection = Connection.Session;
var secretsService = new secretsService(connection, "org.freedesktop.secrets");
var service = secretsService.CreateService("/org/freedesktop/secrets");
collectionName ??= string.Empty;
var task = service.GetCollectionsAsync();
if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken)).ConfigureAwait(false) != task)
return false;
var collections = await task.ConfigureAwait(false);
return collections.Any(c => c.ToString().EndsWith(collectionName, StringComparison.OrdinalIgnoreCase));
}
catch
{
// Any failure in talking to the secrets service or enumerating collections
// is treated as the collection not being available.
return false;
}
}
///
/// Unlocks the collection
///
/// The task to await
public async Task UnlockAsync()
{
if (!_locked)
return;
var (unlocked, prompt) = await _service.UnlockAsync([_collection.Path]);
if (prompt != null && prompt != "/")
{
var promptInstance = _secretsService.CreatePrompt(prompt);
var completedTask = new TaskCompletionSource();
// Set up callback
using var result = await promptInstance.WatchCompletedAsync((exception, result) =>
{
if (exception != null)
completedTask.TrySetException(exception);
else if (result.Dismissed)
completedTask.TrySetResult(false);
else
completedTask.TrySetResult(true);
}).ConfigureAwait(false);
// Prompt
await promptInstance.PromptAsync(string.Empty).ConfigureAwait(false);
// Wait for prompt to be dismissed or completed, with timeout safeguard
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(30));
var finishedTask = await Task.WhenAny(completedTask.Task, timeoutTask).ConfigureAwait(false);
if (finishedTask != completedTask.Task)
throw new UserInformationException("Timed out waiting for libsecret unlock prompt. Ensure that a secret service/keyring is running and able to show prompts.", "UnlockPromptTimeout");
var done = await completedTask.Task.ConfigureAwait(false);
if (!done)
throw new UserInformationException("Dimissed collection unlock prompt", "UnlockDismissed");
// Unlock again, so we have the handles
(unlocked, prompt) = await _service.UnlockAsync([_collection.Path]);
}
if (unlocked.Length == 0)
throw new UserInformationException("Failed to unlock collection", "UnlockFailed");
}
///
/// Obtains the secrets from the collection
///
/// The labels to look for
/// The string comparer
/// The dictionary of secrets
public async Task> GetSecretsAsync(IEnumerable labels, StringComparer comparer)
{
var attributes = new Dictionary();
var collection = _secretsService.CreateCollection(_collection.Path);
var entries = await collection.SearchItemsAsync(attributes).ConfigureAwait(false);
var result = new Dictionary(comparer);
var missing = labels.ToHashSet(comparer);
if (missing.Count == 0)
return result;
// Enumerate all items in the collection
foreach (var r in entries)
{
var item = _secretsService.CreateItem(r);
try
{
var label = await item.GetLabelAsync().ConfigureAwait(false);
if (missing.Contains(label))
{
var (sessionPath, _, secret, contentType) = await item.GetSecretAsync(_session.Path).ConfigureAwait(false);
result[label] = Encoding.Default.GetString(secret);
missing.Remove(label);
if (missing.Count == 0)
break;
}
}
catch (Exception ex)
{
Log.WriteWarningMessage(LogTag, "SecretLookupError", ex, "Failed to get returned secret");
}
}
if (missing.Count > 0)
throw new UserInformationException($"Missing secrets: {string.Join(", ", missing)}", "MissingSecrets");
return result;
}
///
/// Stores or updates a secret in the collection.
///
/// The label of the secret.
/// The secret value.
/// Indicates whether existing secrets should be overwritten.
/// The comparer used for label comparison.
/// The cancellation token.
/// An awaitable task.
public async Task StoreSecretAsync(string label, string value, bool overwrite, StringComparer comparer, CancellationToken cancellationToken)
{
var collection = _secretsService.CreateCollection(_collection.Path);
var entries = await collection.SearchItemsAsync(new Dictionary()).ConfigureAwait(false);
Item? existingItem = null;
foreach (var entry in entries)
{
cancellationToken.ThrowIfCancellationRequested();
var item = _secretsService.CreateItem(entry);
try
{
var existingLabel = await item.GetLabelAsync().ConfigureAwait(false);
if (comparer.Equals(existingLabel, label))
{
existingItem = item;
break;
}
}
catch (Exception ex)
{
Log.WriteWarningMessage(LogTag, "SecretLookupError", ex, "Failed to inspect secret");
}
}
if (existingItem != null && !overwrite)
throw new UserInformationException($"The key '{label}' already exists", "KeyAlreadyExists");
var secretPayload = (_session.Path, Array.Empty(), Encoding.UTF8.GetBytes(value), "text/plain");
if (existingItem != null)
{
await existingItem.SetSecretAsync(secretPayload).ConfigureAwait(false);
await existingItem.SetLabelAsync(label).ConfigureAwait(false);
return;
}
var properties = new Dictionary
{
["org.freedesktop.Secret.Item.Label"] = VariantValue.String(label)
};
await collection.CreateItemAsync(properties, secretPayload, false).ConfigureAwait(false);
}
///
public void Dispose()
{
try { _session.CloseAsync().Wait(); } catch { }
}
}