// 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 { } } }