diff --git a/Duplicati/Library/SecretProvider/LibSecret/SecretCollection.cs b/Duplicati/Library/SecretProvider/LibSecret/SecretCollection.cs index d81a224c7..40c255ce2 100644 --- a/Duplicati/Library/SecretProvider/LibSecret/SecretCollection.cs +++ b/Duplicati/Library/SecretProvider/LibSecret/SecretCollection.cs @@ -20,7 +20,6 @@ // 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; @@ -40,6 +39,18 @@ public class SecretCollection : IDisposable /// public const string DefaultCollectionActualName = "login"; /// + /// The attribute key used to mark items with a schema name. + /// + private const string SchemaAttribute = "xdg:schema"; + /// + /// The schema name applied to created items. + /// + private const string AppliedSchema = "com.duplicati.Secret"; + /// + /// The value used for the "display" attribute. + /// + private const string DuplicatiDisplayAttribute = "Duplicati Secrets"; + /// /// The log tag for the secret collection /// private static readonly string LogTag = Log.LogTagFromType(); @@ -80,54 +91,38 @@ public class SecretCollection : IDisposable _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 D-Bus service name implementing the freedesktop Secret Service API /// The cancellation token /// The created secret collection - public static async Task CreateAsync(string collectionName, bool autoCreateCollection, CancellationToken cancellationToken) + public static async Task CreateAsync(string collectionName, bool autoCreateCollection, string serviceName, CancellationToken cancellationToken) { var connection = DBusConnection.Session; - var secretsService = new secretsService(connection, "org.freedesktop.secrets"); + var secretsService = new secretsService(connection, serviceName); 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)); + // An empty collection name means "use the canonical default collection". + var collectionPath = await GetCollectionPath(collectionName, serviceName, cancellationToken).ConfigureAwait(false); - // If "default" collection doesn't exist, fall back to "login" collection - if (!collectionPath.ToString().EndsWith(collectionName, StringComparison.OrdinalIgnoreCase) - && string.Equals(collectionName, "default", StringComparison.OrdinalIgnoreCase)) + if (collectionPath == null) { - collectionPath = collections - .FirstOrDefault(c => c.ToString().EndsWith(DefaultCollectionActualName, StringComparison.OrdinalIgnoreCase)); + // When creating for the default case, use the "login" collection label. + var useDefault = string.IsNullOrWhiteSpace(collectionName); + var createLabel = useDefault ? DefaultCollectionActualName : collectionName; - 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"); + throw new UserInformationException($"Collection {createLabel} not found", "CollectionNotFound"); // Auto-create the collection var properties = new Dictionary { - ["org.freedesktop.Secret.Collection.Label"] = VariantValue.String(collectionName) + ["org.freedesktop.Secret.Collection.Label"] = VariantValue.String(createLabel) }; var (createdCollectionPath, promptPath) = await service.CreateCollectionAsync(properties, string.Empty).ConfigureAwait(false); @@ -177,25 +172,30 @@ public class SecretCollection : IDisposable } } + var collectionPathString = collectionPath?.ToString(); + if (string.IsNullOrEmpty(collectionPathString) || collectionPathString == "/") + throw new UserInformationException("The secret service returned an invalid collection path", "InvalidCollectionPath"); + var session = secretsService.CreateSession(sessionPath); - var collection = secretsService.CreateCollection(collectionPath.ToString()); + var collection = secretsService.CreateCollection(collectionPathString); 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. + /// Checks whether the secret service DBus service is available on the current platform. /// + /// The D-Bus service name implementing the freedesktop Secret Service API /// The cancellation token. /// true when the provider can be used; otherwise false. - public static async Task IsSupported(CancellationToken cancellationToken) + public static async Task IsSupported(string serviceName, 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. + // Heuristic: secret service prompts require a graphical session to be useful. + // If there is no X11/Wayland display, we treat the provider as unsupported. var hasDisplay = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")); @@ -206,7 +206,7 @@ public class SecretCollection : IDisposable try { var connection = DBusConnection.Session; - var secretsService = new secretsService(connection, "org.freedesktop.secrets"); + var secretsService = new secretsService(connection, serviceName); var service = secretsService.CreateService("/org/freedesktop/secrets"); var task = service.GetCollectionsAsync(); @@ -225,39 +225,81 @@ public class SecretCollection : IDisposable } /// - /// 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. + /// Checks whether a secret service collection exists. + /// This is a non-throwing, best-effort check used 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) + /// The collection name to check. When null or empty, the canonical default collection (resolved via the "default" alias, falling back to "login") is checked. + /// The D-Bus service name implementing the freedesktop Secret Service API + /// The cancellation token. + /// true if the collection exists and the secret service is available; otherwise false. + public static async Task CollectionExists(string collectionName, string serviceName, CancellationToken cancellationToken) + => (await GetCollectionPath(collectionName, serviceName, cancellationToken)) is not null; + + /// + /// Returns the collection path for a given collection name, or null if the collection does not exist. + /// + /// The collection name to check. When null or empty, the canonical default collection (resolved via the "default" alias, falling back to "login") is checked. + /// The D-Bus service name implementing the freedesktop Secret Service API + /// The cancellation token. + /// The collection path, or null if the collection does not exist. + public static async Task GetCollectionPath(string collectionName, string serviceName, CancellationToken cancellationToken) { try { var connection = DBusConnection.Session; - var secretsService = new secretsService(connection, "org.freedesktop.secrets"); + var secretsService = new secretsService(connection, serviceName); var service = secretsService.CreateService("/org/freedesktop/secrets"); collectionName ??= string.Empty; + // When no specific collection was requested, the canonical resolution is via + // the Secret Service "default" alias, which may point to a collection whose + // name is not literally "default". + var useDefault = string.IsNullOrWhiteSpace(collectionName); + if (useDefault) + { + var aliasTask = service.ReadAliasAsync("default"); + if (await Task.WhenAny(aliasTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken)).ConfigureAwait(false) == aliasTask) + { + var aliasPath = await aliasTask.ConfigureAwait(false); + if (aliasPath.ToString() is { Length: > 0 } ap && ap != "/") + return aliasPath; + } + } + var task = service.GetCollectionsAsync(); if (await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken)).ConfigureAwait(false) != task) - return false; + return null; var collections = await task.ConfigureAwait(false); - return collections.Any(c => c.ToString().EndsWith(collectionName, StringComparison.OrdinalIgnoreCase)); + + // For the default case (no alias set), fall back to checking the "login" collection. + var matchName = useDefault ? DefaultCollectionActualName : collectionName; + + // Match on the final path segment exactly (e.g. ".../collection/login"), so that + // a request for "login" does not also match a collection named "mylogin". + return collections + .FirstOrDefault(c => string.Equals(GetCollectionSegment(c), matchName, StringComparison.OrdinalIgnoreCase)); } catch { // Any failure in talking to the secrets service or enumerating collections // is treated as the collection not being available. - return false; + return null; } } + /// + /// Gets the final path segment of a collection object path (e.g. "login" from + /// "/org/freedesktop/secrets/collection/login"). + /// + /// The collection object path. + /// The final path segment. + private static string GetCollectionSegment(ObjectPath path) + => path.ToString().Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? path.ToString(); + /// /// Unlocks the collection /// @@ -304,6 +346,7 @@ public class SecretCollection : IDisposable if (unlocked.Length == 0) throw new UserInformationException("Failed to unlock collection", "UnlockFailed"); + _locked = false; } /// @@ -311,40 +354,33 @@ public class SecretCollection : IDisposable /// /// The labels to look for /// The string comparer + /// The cancellation token /// The dictionary of secrets - public async Task> GetSecretsAsync(IEnumerable labels, StringComparer comparer) + public async Task> GetSecretsAsync(IEnumerable labels, StringComparer comparer, CancellationToken cancellationToken) { - 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) + var requested = labels.ToHashSet(comparer); + if (requested.Count == 0) return result; - // Enumerate all items in the collection - foreach (var r in entries) + // Resolve all requested secrets in one batch by label. + var items = await FindItemsAsync(collection, requested, comparer, cancellationToken).ConfigureAwait(false); + + foreach (var (label, item) in items) { - 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; - } + var (_, _, secret, _) = await item.GetSecretAsync(_session.Path).ConfigureAwait(false); + result[label] = Encoding.Default.GetString(secret); } catch (Exception ex) { - Log.WriteWarningMessage(LogTag, "SecretLookupError", ex, "Failed to get returned secret"); + Log.WriteWarningMessage(LogTag, "SecretLookupError", ex, $"Failed to get the secret value for {label}"); } } + var missing = requested.Where(l => !result.ContainsKey(l)).ToList(); if (missing.Count > 0) throw new UserInformationException($"Missing secrets: {string.Join(", ", missing)}", "MissingSecrets"); @@ -363,28 +399,8 @@ public class SecretCollection : IDisposable 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"); - } - } + var found = await FindItemsAsync(collection, [label], comparer, cancellationToken).ConfigureAwait(false); + var existingItem = found.GetValueOrDefault(label); if (existingItem != null && !overwrite) throw new UserInformationException($"The key '{label}' already exists", "KeyAlreadyExists"); @@ -393,19 +409,70 @@ public class SecretCollection : IDisposable if (existingItem != null) { + // Update existing item await existingItem.SetSecretAsync(secretPayload).ConfigureAwait(false); - await existingItem.SetLabelAsync(label).ConfigureAwait(false); return; } + // KDE's KeepSecret groups by the "server" attribute + // Seahorse shows the "user" attribute underneath + var attributes = new Dictionary + { + [SchemaAttribute] = AppliedSchema, + ["server"] = DuplicatiDisplayAttribute, + ["user"] = label, + ["type"] = "plaintext" + }; + var properties = new Dictionary { - ["org.freedesktop.Secret.Item.Label"] = VariantValue.String(label) + ["org.freedesktop.Secret.Item.Label"] = VariantValue.String(label), + ["org.freedesktop.Secret.Item.Attributes"] = new Dict(attributes).AsVariantValue() }; await collection.CreateItemAsync(properties, secretPayload, false).ConfigureAwait(false); } + /// + /// Finds existing items in the collection by label. + /// + /// The collection to search. + /// The labels to match. + /// The comparer used for label comparison. + /// The cancellation token. + /// A dictionary mapping each found label to its item. + private async Task> FindItemsAsync(Collection collection, IEnumerable labels, StringComparer comparer, CancellationToken cancellationToken) + { + var found = new Dictionary(comparer); + var missing = labels.ToHashSet(comparer); + if (missing.Count == 0) + return found; + + // Search for items by label + var entries = await collection.SearchItemsAsync(new Dictionary()).ConfigureAwait(false); + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (missing.Count == 0) + break; + + var item = _secretsService.CreateItem(entry); + try + { + var existingLabel = await item.GetLabelAsync().ConfigureAwait(false); + if (missing.Remove(existingLabel)) + found[existingLabel] = item; + } + catch (Exception ex) + { + Log.WriteWarningMessage(LogTag, "SecretLookupError", ex, "Failed to inspect secret"); + } + } + + return found; + } + /// public void Dispose() { diff --git a/Duplicati/Library/SecretProvider/LibSecretLinuxProvider.cs b/Duplicati/Library/SecretProvider/LibSecretLinuxProvider.cs index 952cdb8e5..885559d1e 100644 --- a/Duplicati/Library/SecretProvider/LibSecretLinuxProvider.cs +++ b/Duplicati/Library/SecretProvider/LibSecretLinuxProvider.cs @@ -33,6 +33,11 @@ namespace Duplicati.Library.SecretProvider; [SupportedOSPlatform("linux")] public class LibSecretLinuxProvider : ISecretProvider { + /// + /// The default D-Bus service name implementing the freedesktop Secret Service API. + /// + public const string DefaultServiceName = "org.freedesktop.secrets"; + /// public string Key => "libsecret"; @@ -49,7 +54,7 @@ public class LibSecretLinuxProvider : ISecretProvider { if (!OperatingSystem.IsLinux()) return Task.FromResult(false); - return SecretCollection.IsSupported(CancellationToken.None); + return SecretCollection.IsSupported(DefaultServiceName, CancellationToken.None); }); /// @@ -64,9 +69,11 @@ public class LibSecretLinuxProvider : ISecretProvider private class LibSecretConfig : ICommandLineArgumentMapper { /// - /// The collection to use + /// The collection to use. When left empty, the canonical default collection is + /// resolved via the Secret Service "default" alias. When set to an explicit value + /// (including the literal "default"), that collection name is matched literally. /// - public string Collection { get; set; } = "default"; + public string Collection { get; set; } = string.Empty; /// /// Whether the collection name is case sensitive @@ -78,6 +85,11 @@ public class LibSecretLinuxProvider : ISecretProvider /// public bool NoAutoCreateCollection { get; set; } + /// + /// The D-Bus service name implementing the freedesktop Secret Service API. + /// + public string ServiceName { get; set; } = DefaultServiceName; + /// /// Gets the command line argument description for the given name /// @@ -89,6 +101,7 @@ public class LibSecretLinuxProvider : ISecretProvider nameof(Collection) => new CommandLineArgumentDescriptionAttribute() { Name = "collection", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.LibSecretLinuxProvider.CollectionDescriptionShort, LongDescription = Strings.LibSecretLinuxProvider.CollectionDescriptionLong }, nameof(CaseSensitive) => new CommandLineArgumentDescriptionAttribute() { Name = "case-sensitive", Type = CommandLineArgument.ArgumentType.Boolean, ShortDescription = Strings.LibSecretLinuxProvider.CaseSensitiveDescriptionShort, LongDescription = Strings.LibSecretLinuxProvider.CaseSensitiveDescriptionLong }, nameof(NoAutoCreateCollection) => new CommandLineArgumentDescriptionAttribute() { Name = "no-autocreate-collection", Type = CommandLineArgument.ArgumentType.Boolean, ShortDescription = Strings.LibSecretLinuxProvider.NoAutoCreateCollectionDescriptionShort, LongDescription = Strings.LibSecretLinuxProvider.NoAutoCreateCollectionDescriptionLong }, + nameof(ServiceName) => new CommandLineArgumentDescriptionAttribute() { Name = "service-name", Type = CommandLineArgument.ArgumentType.String, ShortDescription = Strings.LibSecretLinuxProvider.ServiceNameDescriptionShort, LongDescription = Strings.LibSecretLinuxProvider.ServiceNameDescriptionLong, DefaultValue = DefaultServiceName }, _ => null, }; @@ -120,10 +133,11 @@ public class LibSecretLinuxProvider : ISecretProvider var args = HttpUtility.ParseQueryString(config.Query); _cfg = CommandLineArgumentMapper.ApplyArguments(new LibSecretConfig(), args); - if (string.IsNullOrWhiteSpace(_cfg.Collection)) - throw new UserInformationException("The collection must be specified", "CollectionRequired"); + if (string.IsNullOrWhiteSpace(_cfg.ServiceName)) + throw new UserInformationException("The service name must be specified", "ServiceNameRequired"); - _collection = await SecretCollection.CreateAsync(_cfg.Collection, !_cfg.NoAutoCreateCollection, cancellationToken).ConfigureAwait(false); + // An empty collection means "resolve the canonical default collection via the alias". + _collection = await SecretCollection.CreateAsync(_cfg.Collection, !_cfg.NoAutoCreateCollection, _cfg.ServiceName, cancellationToken).ConfigureAwait(false); await _collection.UnlockAsync().ConfigureAwait(false); } @@ -135,7 +149,7 @@ public class LibSecretLinuxProvider : ISecretProvider if (!OperatingSystem.IsLinux()) throw new PlatformNotSupportedException("LibSecret is only supported on Linux"); - return _collection.GetSecretsAsync(keys, _cfg.CaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase); + return _collection.GetSecretsAsync(keys, _cfg.CaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase, cancellationToken); } /// @@ -162,11 +176,7 @@ public class LibSecretLinuxProvider : ISecretProvider if (!OperatingSystem.IsLinux()) throw new PlatformNotSupportedException("LibSecret is only supported on Linux"); - var actual = await SecretCollection.CollectionExists(_cfg.Collection, cancellationToken); - if (actual || !_cfg.Collection.Equals("default", StringComparison.OrdinalIgnoreCase)) - return actual; - - return await SecretCollection.CollectionExists(SecretCollection.DefaultCollectionActualName, cancellationToken); + return await SecretCollection.CollectionExists(_cfg.Collection, _cfg.ServiceName, cancellationToken); } } diff --git a/Duplicati/Library/SecretProvider/Strings.cs b/Duplicati/Library/SecretProvider/Strings.cs index b9dcab986..4e25ce1a2 100644 --- a/Duplicati/Library/SecretProvider/Strings.cs +++ b/Duplicati/Library/SecretProvider/Strings.cs @@ -201,10 +201,12 @@ internal static class LibSecretLinuxProvider public static string DisplayName => LC.L("Secrets from libsecret"); public static string Description => LC.L("Secret provider that reads and writes secrets from the libsecret password manager"); public static string CollectionDescriptionShort => LC.L("The collection name"); - public static string CollectionDescriptionLong => LC.L("The collection name to use for retrieving secrets from the libsecret password manager. The value \"default\" can be used to access the default collection, and will resolve to \"login\" if the system does not have a default collection."); + public static string CollectionDescriptionLong => LC.L("The collection (keyring/wallet) name to use for retrieving secrets from the libsecret password manager. Leave empty for the system default."); public static string CaseSensitiveDescriptionShort => LC.L("Case sensitivity"); public static string CaseSensitiveDescriptionLong => LC.L("Whether to use case-sensitive matching for secret names"); public static string NoAutoCreateCollectionDescriptionShort => LC.L("Disable auto-create collection"); public static string NoAutoCreateCollectionDescriptionLong => LC.L("Disables the automatic creation of the collection if it does not exist when accessing libsecret"); + public static string ServiceNameDescriptionShort => LC.L("The D-Bus service name"); + public static string ServiceNameDescriptionLong => LC.L("The D-Bus service name implementing the freedesktop Secret Service API."); }