Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 86 additions & 25 deletions Nickvision.Desktop/FreeDesktop/SecretServiceProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,11 @@ private SecretServiceProxy(DBusConnection connection, string sessionPath)
}

/// <summary>
/// Unlocks the given object (collection or item path).
/// Unlocks the given object (collection or item path), prompting the user if required.
/// </summary>
/// <param name="objectPath">The D-Bus object path to unlock</param>
internal async Task UnlockAsync(string objectPath)
/// <returns>True if unlocked successfully, false if the user dismissed the prompt</returns>
internal async Task<bool> UnlockAsync(string objectPath)
{
MessageBuffer buffer;
{
Expand All @@ -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);
}

/// <summary>
/// Invokes a Secret Service prompt and waits for the user to complete or dismiss it.
/// </summary>
/// <param name="promptPath">The D-Bus object path of the prompt</param>
/// <returns>True if the user completed the prompt, false if dismissed</returns>
private async Task<bool> PromptAsync(string promptPath)
{
var tcs = new TaskCompletionSource<bool>();
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;
}

/// <summary>
Expand Down Expand Up @@ -167,17 +211,16 @@ await _connection.CallMethodAsync(buffer, static (Message m, object? _) =>
}

/// <summary>
/// Searches for items in the specified collection that match the given attributes.
/// Searches for items across all collections that match the given attributes.
/// </summary>
/// <param name="collectionPath">The collection object path</param>
/// <param name="attributes">Attributes to match</param>
/// <returns>An array of matching item object paths</returns>
internal async Task<string[]> SearchItemsAsync(string collectionPath, Dictionary<string, string> attributes)
/// <returns>A tuple of unlocked and locked item object paths</returns>
internal async Task<(string[] Unlocked, string[] Locked)> SearchItemsAsync(Dictionary<string, string> 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)
{
Expand All @@ -191,13 +234,20 @@ internal async Task<string[]> 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);
}

Expand All @@ -215,16 +265,27 @@ internal async Task<string[]> 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;
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion Nickvision.Desktop/Nickvision.Desktop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<PackageId>Nickvision.Desktop</PackageId>
<Version>2026.3.9</Version>
<Version>2026.3.10</Version>
<Company>Nickvision</Company>
<Authors>Nickvision</Authors>
<Description>A cross-platform base for Nickvision desktop applications.</Description>
Expand Down
88 changes: 40 additions & 48 deletions Nickvision.Desktop/System/SecretService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ public async Task<bool> AddAsync(Secret secret)
return false;
}
await svc.UnlockAsync(collPath);
var itemPath = await svc.CreateItemAsync(collPath, secret.Name,
new Dictionary<string, string> { { "application", secret.Name } },
secret.Value);
var itemPath = await svc.CreateItemAsync(collPath, secret.Name, new Dictionary<string, string> { { "application", secret.Name } }, secret.Value);
var res = !string.IsNullOrEmpty(itemPath);
if (res)
{
Expand Down Expand Up @@ -245,29 +243,21 @@ public async Task<bool> 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<string, string> { { "application", name } });
if (items.Length == 0)
var (unlocked, locked) = await svc.SearchItemsAsync(new Dictionary<string, string> { { "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
{
Expand Down Expand Up @@ -356,25 +346,27 @@ public async Task<bool> 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<string, string> { { "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<string, string> { { "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
Expand Down Expand Up @@ -476,29 +468,29 @@ public async Task<bool> 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<string, string> { { "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<string, string> { { "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
{
Expand Down
Loading