diff --git a/Nickvision.Desktop/FreeDesktop/SecretServiceProxy.cs b/Nickvision.Desktop/FreeDesktop/SecretServiceProxy.cs index 4e0ec61..31ac4c7 100644 --- a/Nickvision.Desktop/FreeDesktop/SecretServiceProxy.cs +++ b/Nickvision.Desktop/FreeDesktop/SecretServiceProxy.cs @@ -101,10 +101,11 @@ private SecretServiceProxy(DBusConnection connection, string sessionPath) } /// - /// Unlocks the given object (collection or item path). + /// Unlocks the given object (collection or item path), prompting the user if required. /// /// The D-Bus object path to unlock - internal async Task UnlockAsync(string objectPath) + /// True if unlocked successfully, false if the user dismissed the prompt + internal async Task UnlockAsync(string objectPath) { MessageBuffer buffer; { @@ -113,14 +114,57 @@ internal async Task UnlockAsync(string objectPath) writer.WriteArray(new ObjectPath[] { objectPath }); buffer = writer.CreateMessage(); } - await _connection.CallMethodAsync(buffer, static (Message m, object? _) => + var promptPath = await _connection.CallMethodAsync(buffer, static (Message m, object? _) => { var reader = m.GetBodyReader(); reader.AlignStruct(); - reader.ReadArrayOfObjectPath(); // unlocked (ignored) - reader.ReadObjectPath(); // prompt (ignored) - return true; + reader.ReadArrayOfObjectPath(); // already-unlocked list (not reliable for items that need prompting) + return reader.ReadObjectPathAsString(); // prompt path, or "/" if no prompt is needed }, null); + if (string.IsNullOrEmpty(promptPath) || promptPath == "/") + { + // Object was already unlocked, no user prompt required + return true; + } + return await PromptAsync(promptPath); + } + + /// + /// Invokes a Secret Service prompt and waits for the user to complete or dismiss it. + /// + /// The D-Bus object path of the prompt + /// True if the user completed the prompt, false if dismissed + private async Task PromptAsync(string promptPath) + { + var tcs = new TaskCompletionSource(); + using var subscription = await _connection.WatchSignalAsync( + SecretsBus, promptPath, "org.freedesktop.Secret.Prompt", "Completed", + static (Message m, object? _) => + { + var reader = m.GetBodyReader(); + return reader.ReadBool(); // dismissed + }, + (Exception? ex, bool dismissed) => + { + if (ex is not null) + { + tcs.TrySetException(ex); + } + else + { + tcs.TrySetResult(!dismissed); + } + }, + null, /* emitOnCapturedContext */ false, ObserverFlags.None); + MessageBuffer buffer; + { + using var writer = _connection.GetMessageWriter(); + writer.WriteMethodCallHeader(SecretsBus, promptPath, "org.freedesktop.Secret.Prompt", "Prompt", "s", MessageFlags.None); + writer.WriteString(""); // no parent window-id + buffer = writer.CreateMessage(); + } + await _connection.CallMethodAsync(buffer); + return await tcs.Task; } /// @@ -167,17 +211,16 @@ await _connection.CallMethodAsync(buffer, static (Message m, object? _) => } /// - /// Searches for items in the specified collection that match the given attributes. + /// Searches for items across all collections that match the given attributes. /// - /// The collection object path /// Attributes to match - /// An array of matching item object paths - internal async Task SearchItemsAsync(string collectionPath, Dictionary attributes) + /// A tuple of unlocked and locked item object paths + internal async Task<(string[] Unlocked, string[] Locked)> SearchItemsAsync(Dictionary attributes) { MessageBuffer buffer; { using var writer = _connection.GetMessageWriter(); - writer.WriteMethodCallHeader(SecretsBus, collectionPath, CollectionInterface, "SearchItems", "a{ss}", MessageFlags.None); + writer.WriteMethodCallHeader(SecretsBus, SecretsPath, ServiceInterface, "SearchItems", "a{ss}", MessageFlags.None); var dictStart = writer.WriteDictionaryStart(); foreach (var kv in attributes) { @@ -191,13 +234,20 @@ internal async Task SearchItemsAsync(string collectionPath, Dictionary return await _connection.CallMethodAsync(buffer, static (Message m, object? _) => { var reader = m.GetBodyReader(); - var paths = reader.ReadArrayOfObjectPath(); - var result = new string[paths.Length]; - for (var i = 0; i < paths.Length; i++) + reader.AlignStruct(); + var unlockedPaths = reader.ReadArrayOfObjectPath(); + var lockedPaths = reader.ReadArrayOfObjectPath(); + var unlocked = new string[unlockedPaths.Length]; + var locked = new string[lockedPaths.Length]; + for (var i = 0; i < unlockedPaths.Length; i++) { - result[i] = paths[i].ToString(); + unlocked[i] = unlockedPaths[i].ToString(); } - return result; + for (var i = 0; i < lockedPaths.Length; i++) + { + locked[i] = lockedPaths[i].ToString(); + } + return (unlocked, locked); }, null); } @@ -215,16 +265,27 @@ internal async Task SearchItemsAsync(string collectionPath, Dictionary writer.WriteObjectPath(_sessionPath); buffer = writer.CreateMessage(); } - return await _connection.CallMethodAsync(buffer, static (Message m, object? _) => + try { - var reader = m.GetBodyReader(); - reader.AlignStruct(); - reader.ReadObjectPath(); // session (ignored) - reader.ReadArrayOfByte(); // parameters (ignored for plain) - var valueBytes = reader.ReadArrayOfByte(); - reader.ReadString(); // content type (ignored) - return Encoding.UTF8.GetString(valueBytes); - }, null); + return await _connection.CallMethodAsync(buffer, static (Message m, object? _) => + { + var reader = m.GetBodyReader(); + reader.AlignStruct(); + reader.ReadObjectPath(); // session (ignored) + reader.ReadArrayOfByte(); // parameters (ignored for plain) + var valueBytes = reader.ReadArrayOfByte(); + reader.ReadString(); // content type (ignored) + return Encoding.UTF8.GetString(valueBytes); + }, null); + } + catch (DBusErrorReplyException e) + { + if(e.ErrorName == "org.freedesktop.Secret.Error.IsLocked") + { + return null; + } + } + return null; } /// diff --git a/Nickvision.Desktop/Nickvision.Desktop.csproj b/Nickvision.Desktop/Nickvision.Desktop.csproj index a6e1359..f96f0ba 100644 --- a/Nickvision.Desktop/Nickvision.Desktop.csproj +++ b/Nickvision.Desktop/Nickvision.Desktop.csproj @@ -8,7 +8,7 @@ true true Nickvision.Desktop - 2026.3.9 + 2026.3.10 Nickvision Nickvision A cross-platform base for Nickvision desktop applications. diff --git a/Nickvision.Desktop/System/SecretService.cs b/Nickvision.Desktop/System/SecretService.cs index 8bc9317..3c15b05 100644 --- a/Nickvision.Desktop/System/SecretService.cs +++ b/Nickvision.Desktop/System/SecretService.cs @@ -132,9 +132,7 @@ public async Task AddAsync(Secret secret) return false; } await svc.UnlockAsync(collPath); - var itemPath = await svc.CreateItemAsync(collPath, secret.Name, - new Dictionary { { "application", secret.Name } }, - secret.Value); + var itemPath = await svc.CreateItemAsync(collPath, secret.Name, new Dictionary { { "application", secret.Name } }, secret.Value); var res = !string.IsNullOrEmpty(itemPath); if (res) { @@ -245,29 +243,21 @@ public async Task DeleteAsync(string name) _logger.LogError($"Failed to delete system secret ({name}): unable to connect to secrets service."); return false; } - var collPath = await svc.GetDefaultCollectionPathAsync(); - if (string.IsNullOrEmpty(collPath) || collPath == "/") - { - collPath = await svc.CreateCollectionAsync("Default keyring", "default"); - } - if (string.IsNullOrEmpty(collPath)) - { - _logger.LogError($"Failed to delete system secret ({name}) as the keyring collection could not be accessed."); - return false; - } - await svc.UnlockAsync(collPath); - var items = await svc.SearchItemsAsync(collPath, - new Dictionary { { "application", name } }); - if (items.Length == 0) + var (unlocked, locked) = await svc.SearchItemsAsync(new Dictionary { { "application", name } }); + var itemPath = unlocked.Length > 0 ? unlocked[0] : locked.Length > 0 ? locked[0] : null; + if (itemPath is null) { _logger.LogWarning($"System secret ({name}) not found."); + return false; } - else + if (locked.Length > 0 && !await svc.UnlockAsync(itemPath)) { - await svc.DeleteItemAsync(items[0]); - _logger.LogInformation($"Deleted system secret ({name}) successfully."); + _logger.LogError($"Failed to delete system secret ({name}): the user dismissed the unlock prompt."); + return false; } - return items.Length > 0; + await svc.DeleteItemAsync(itemPath); + _logger.LogInformation($"Deleted system secret ({name}) successfully."); + return true; } else { @@ -356,25 +346,27 @@ public async Task DeleteAsync(string name) _logger.LogError($"Failed to get system secret ({name}): unable to connect to secrets service."); return null; } - var collPath = await svc.GetDefaultCollectionPathAsync(); - if (string.IsNullOrEmpty(collPath) || collPath == "/") + var (unlocked, locked) = await svc.SearchItemsAsync(new Dictionary { { "application", name } }); + string? itemPath = null; + if (unlocked.Length > 0) { - collPath = await svc.CreateCollectionAsync("Default keyring", "default"); + itemPath = unlocked[0]; } - if (string.IsNullOrEmpty(collPath)) + else if (locked.Length > 0) { - _logger.LogError($"Failed to get system secret ({name}) as the keyring collection could not be accessed."); - return null; + if (!await svc.UnlockAsync(locked[0])) + { + _logger.LogError($"Failed to get system secret ({name}): the user dismissed the unlock prompt."); + return null; + } + itemPath = locked[0]; } - await svc.UnlockAsync(collPath); - var items = await svc.SearchItemsAsync(collPath, - new Dictionary { { "application", name } }); - if (items.Length == 0) + if (itemPath is null) { _logger.LogInformation($"System secret ({name}) not found."); return null; } - var value = await svc.GetSecretAsync(items[0]); + var value = await svc.GetSecretAsync(itemPath); return value is null ? null : new Secret(name, value); } else @@ -476,29 +468,29 @@ public async Task UpdateAsync(Secret secret) _logger.LogError($"Failed to update system secret ({secret.Name}): unable to connect to secrets service."); return false; } - var collPath = await svc.GetDefaultCollectionPathAsync(); - if (string.IsNullOrEmpty(collPath) || collPath == "/") + var (unlocked, locked) = await svc.SearchItemsAsync(new Dictionary { { "application", secret.Name } }); + string? itemPath = null; + if (unlocked.Length > 0) { - collPath = await svc.CreateCollectionAsync("Default keyring", "default"); + itemPath = unlocked[0]; } - if (string.IsNullOrEmpty(collPath)) + else if (locked.Length > 0) { - _logger.LogError($"Failed to update system secret ({secret.Name}) as the keyring collection could not be accessed."); - return false; + if (!await svc.UnlockAsync(locked[0])) + { + _logger.LogError($"Failed to update system secret ({secret.Name}): the user dismissed the unlock prompt."); + return false; + } + itemPath = locked[0]; } - await svc.UnlockAsync(collPath); - var items = await svc.SearchItemsAsync(collPath, - new Dictionary { { "application", secret.Name } }); - if (items.Length == 0) + if (itemPath is null) { _logger.LogError($"Failed to update system secret ({secret.Name})."); + return false; } - else - { - await svc.SetSecretAsync(items[0], secret.Value); - _logger.LogInformation($"Updated system secret ({secret.Name}) successfully."); - } - return items.Length > 0; + await svc.SetSecretAsync(itemPath, secret.Value); + _logger.LogInformation($"Updated system secret ({secret.Name}) successfully."); + return true; } else {