diff --git a/Desktop/Desktop.csproj b/Desktop/Desktop.csproj
index b4e4180..76e92b4 100644
--- a/Desktop/Desktop.csproj
+++ b/Desktop/Desktop.csproj
@@ -3,9 +3,9 @@
WinExe
- net9.0
+ net10.0
- $(TargetFrameworks);net9.0-windows10.0.19041.0
+ $(TargetFrameworks);net10.0-windows10.0.19041.0
10.0.19041.41
enable
@@ -44,8 +44,8 @@
-
-
+
+
diff --git a/KeyVaultExplorer/App.axaml.cs b/KeyVaultExplorer/App.axaml.cs
index f7908dd..5a6b57e 100644
--- a/KeyVaultExplorer/App.axaml.cs
+++ b/KeyVaultExplorer/App.axaml.cs
@@ -32,8 +32,6 @@ public static void CreateDesktopResources()
Dispatcher.UIThread.Post(async () =>
{
- await KvExplorerDb.OpenSqlConnection();
-
if (!dbExists)
KvExplorerDb.InitializeDatabase();
}, DispatcherPriority.Loaded);
@@ -52,13 +50,6 @@ public static void CreateDesktopResources()
}
}
- private void MainWindowOnClosing(object? sender, WindowClosingEventArgs e)
- {
- if (sender is Window window)
- {
- KvExplorerDb.CloseSqlConnection();
- }
- }
public override void Initialize()
{
diff --git a/KeyVaultExplorer/Assets/Info.plist b/KeyVaultExplorer/Assets/Info.plist
index 4ba49fe..e678c94 100644
--- a/KeyVaultExplorer/Assets/Info.plist
+++ b/KeyVaultExplorer/Assets/Info.plist
@@ -5,7 +5,7 @@
CFBundleShortVersionString
0.0.0.2
CFBundleIdentifier
- us.sidesteplabs.KeyVaultExplorer
+ us.cricketthomas.KeyVaultExplorer
CFBundleInfoDictionaryVersion
6.0
NSAppTransportSecurity
diff --git a/KeyVaultExplorer/Database/KvExplorerDb.cs b/KeyVaultExplorer/Database/KvExplorerDb.cs
index c25d9cd..443f254 100644
--- a/KeyVaultExplorer/Database/KvExplorerDb.cs
+++ b/KeyVaultExplorer/Database/KvExplorerDb.cs
@@ -1,9 +1,9 @@
-using KeyVaultExplorer.Models;
+using KeyVaultExplorer.Database;
+using KeyVaultExplorer.Models;
using KeyVaultExplorer.Services;
using Microsoft.Data.Sqlite;
using System;
using System.Collections.Generic;
-using System.Data.Common;
using System.IO;
using System.Linq;
using System.Text;
@@ -13,72 +13,37 @@ namespace KeyVaultExplorer.Database;
public partial class KvExplorerDb : IDisposable
{
- private static DbConnection _connection;
+ private static string _password = null;
public KvExplorerDb()
{
}
- public static async Task OpenSqlConnection()
+ public async void Dispose()
{
- string DataSource = Path.Combine(Constants.DatabaseFilePath);
- var pass = await DatabaseEncryptedPasswordManager.GetSecret();
- var connection = new SqliteConnection($"Filename={DataSource}; Password={pass}");
- connection.Open();
- _connection = connection;
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.CloseAsync();
}
- public static void CloseSqlConnection()
+ public async Task DeleteQuickAccessItemByKeyVaultId(string keyVaultId)
{
- _connection.Close();
- }
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ var command = connection.CreateCommand();
+ command.CommandText = "DELETE FROM QuickAccess WHERE KeyVaultId = @KeyVaultId;";
+ command.Parameters.Add(new SqliteParameter("@KeyVaultId", keyVaultId));
- public void Dispose()
- {
- _connection.Close();
- }
+ var rowsAffected = await command.ExecuteNonQueryAsync();
- public static async void InitializeDatabase()
- {
- string tableCommand = """
- PRAGMA foreign_keys = off;
- BEGIN TRANSACTION;
- -- Table: Subscriptions
- CREATE TABLE IF NOT EXISTS Subscriptions (
- DisplayName TEXT NOT NULL,
- SubscriptionId TEXT (200) PRIMARY KEY UNIQUE ON CONFLICT IGNORE,
- TenantId TEXT (200)
- );
- CREATE UNIQUE INDEX IF NOT EXISTS IX_Subscriptions_DisplayName_SubscriptionsId ON Subscriptions (
- SubscriptionId ASC,
- DisplayName ASC
- );
- -- Table: QuickAccess
- CREATE TABLE IF NOT EXISTS QuickAccess (
- Id INTEGER NOT NULL CONSTRAINT PK_QuickAccess PRIMARY KEY AUTOINCREMENT,
- Name TEXT NOT NULL,
- VaultUri TEXT NOT NULL,
- KeyVaultId TEXT NOT NULL CONSTRAINT UQ_KeyVaultId UNIQUE ON CONFLICT IGNORE,
- SubscriptionDisplayName TEXT,
- SubscriptionId TEXT,
- TenantId TEXT NOT NULL,
- Location TEXT NOT NULL
- );
- -- Index: IX_QuickAccess_KeyVaultId
- CREATE INDEX IF NOT EXISTS IX_QuickAccess_KeyVaultId ON QuickAccess (
- KeyVaultId
- );
- COMMIT TRANSACTION;
- PRAGMA foreign_keys = on;
- """;
- var createTableCommand = _connection.CreateCommand();
- createTableCommand.CommandText = tableCommand;
- await createTableCommand.ExecuteNonQueryAsync();
+ // Check if any rows were deleted (1 or more indicates success)
+ return rowsAffected > 0;
}
public async IAsyncEnumerable GetQuickAccessItemsAsyncEnumerable(string tenantId = null)
{
- var command = _connection.CreateCommand();
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ var command = connection.CreateCommand();
var query = new StringBuilder("SELECT Id, Name, VaultUri, KeyVaultId, SubscriptionDisplayName, SubscriptionId, TenantId, Location FROM QuickAccess");
if (!string.IsNullOrWhiteSpace(tenantId))
@@ -88,7 +53,6 @@ public async IAsyncEnumerable GetQuickAccessItemsAsyncEnumerable(st
query.Append(";");
command.CommandText = query.ToString();
-
var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
@@ -108,76 +72,13 @@ public async IAsyncEnumerable GetQuickAccessItemsAsyncEnumerable(st
}
}
- public async Task QuickAccessItemByKeyVaultIdExists(string? keyVaultId)
- {
- var command = _connection.CreateCommand();
- command.CommandText = "SELECT 1 FROM QuickAccess WHERE KeyVaultId = @KeyVaultId LIMIT 1;";
- command.Parameters.Add(new SqliteParameter("@KeyVaultId", keyVaultId));
-
- var result = await command.ExecuteScalarAsync();
- return result is not null;
- }
-
- public async Task DeleteQuickAccessItemByKeyVaultId(string keyVaultId)
- {
- var command = _connection.CreateCommand();
- command.CommandText = "DELETE FROM QuickAccess WHERE KeyVaultId = @KeyVaultId;";
- command.Parameters.Add(new SqliteParameter("@KeyVaultId", keyVaultId));
-
- var rowsAffected = await command.ExecuteNonQueryAsync();
-
- // Check if any rows were deleted (1 or more indicates success)
- return rowsAffected > 0;
- }
-
- public async Task InsertQuickAccessItemAsync(QuickAccess item)
- {
- var command = _connection.CreateCommand();
- command.CommandText = """
- INSERT INTO QuickAccess (Name, VaultUri, KeyVaultId, SubscriptionDisplayName, SubscriptionId, TenantId, Location)
- VALUES (@Name, @VaultUri, @KeyVaultId, @SubscriptionDisplayName, @SubscriptionId, @TenantId, @Location);
- """;
- command.Parameters.Add(new SqliteParameter("@Name", item.Name));
- command.Parameters.Add(new SqliteParameter("@VaultUri", item.VaultUri));
- command.Parameters.Add(new SqliteParameter("@KeyVaultId", item.KeyVaultId));
- command.Parameters.Add(new SqliteParameter("@SubscriptionDisplayName", item.SubscriptionDisplayName ?? (object)DBNull.Value));
- command.Parameters.Add(new SqliteParameter("@SubscriptionId", item.SubscriptionId ?? (object)DBNull.Value));
- command.Parameters.Add(new SqliteParameter("@TenantId", item.TenantId));
- command.Parameters.Add(new SqliteParameter("@Location", item.Location));
- await command.ExecuteNonQueryAsync();
- }
-
- public async Task GetToggleSettings()
- {
- var command = _connection.CreateCommand();
- command.CommandText = "SELECT Name, Value FROM SETTINGS";
- var settings = new AppSettings();
- var reader = command.ExecuteReader();
- while (reader.Read())
- {
- Enum.TryParse(reader.GetString(0), true, out SettingType parsedEnumValue);
- switch (parsedEnumValue)
- {
- case SettingType.BackgroundTransparency:
- settings.BackgroundTransparency = reader.GetBoolean(1);
- break;
-
- case SettingType.ClipboardTimeout:
- settings.ClipboardTimeout = reader.GetInt32(1);
- break;
-
- default:
- break;
- }
- }
- return settings;
- }
-
public async Task> GetStoredSubscriptions(string tenantId = null)
{
- var command = _connection.CreateCommand();
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ var command = connection.CreateCommand();
var query = new StringBuilder("SELECT DisplayName, SubscriptionId, TenantId FROM Subscriptions");
-
+
if (!string.IsNullOrWhiteSpace(tenantId))
{
query.Append($" WHERE TenantId = '{tenantId.ToUpperInvariant()}'");
@@ -200,22 +101,60 @@ public async Task> GetStoredSubscriptions(string tenantId =
return subscriptions;
}
- public async Task InsertSubscriptions(IList subscriptions)
+ public async Task InsertQuickAccessItemAsync(QuickAccess item)
+ {
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ var command = connection.CreateCommand();
+ command.CommandText = """
+ INSERT INTO QuickAccess (Name, VaultUri, KeyVaultId, SubscriptionDisplayName, SubscriptionId, TenantId, Location)
+ VALUES (@Name, @VaultUri, @KeyVaultId, @SubscriptionDisplayName, @SubscriptionId, @TenantId, @Location);
+ """;
+ command.Parameters.Add(new SqliteParameter("@Name", item.Name));
+ command.Parameters.Add(new SqliteParameter("@VaultUri", item.VaultUri));
+ command.Parameters.Add(new SqliteParameter("@KeyVaultId", item.KeyVaultId));
+ command.Parameters.Add(new SqliteParameter("@SubscriptionDisplayName", item.SubscriptionDisplayName ?? (object)DBNull.Value));
+ command.Parameters.Add(new SqliteParameter("@SubscriptionId", item.SubscriptionId ?? (object)DBNull.Value));
+ command.Parameters.Add(new SqliteParameter("@TenantId", item.TenantId));
+ command.Parameters.Add(new SqliteParameter("@Location", item.Location));
+ await command.ExecuteNonQueryAsync();
+ }
+
+ public async Task InsertSubscriptions(IEnumerable subscriptions)
{
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ using var tx = connection.BeginTransaction();
+
foreach (var subscription in subscriptions)
{
- var command = _connection.CreateCommand();
+ var command = connection.CreateCommand();
command.CommandText = "INSERT OR IGNORE INTO Subscriptions (DisplayName, SubscriptionId, TenantId) VALUES (@DisplayName, @SubscriptionId, @TenantId);";
command.Parameters.Add(new SqliteParameter("@DisplayName", subscription.DisplayName));
command.Parameters.Add(new SqliteParameter("@SubscriptionId", subscription.SubscriptionId));
command.Parameters.Add(new SqliteParameter("@TenantId", subscription.TenantId));
await command.ExecuteNonQueryAsync();
}
+ tx.Commit();
+ }
+
+ public async Task QuickAccessItemByKeyVaultIdExists(string? keyVaultId)
+ {
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ var command = connection.CreateCommand();
+ command.CommandText = "SELECT 1 FROM QuickAccess WHERE KeyVaultId = @KeyVaultId LIMIT 1;";
+ command.Parameters.Add(new SqliteParameter("@KeyVaultId", keyVaultId));
+
+ var result = await command.ExecuteScalarAsync();
+ return result is not null;
}
public async Task RemoveSubscriptionsBySubscriptionIDs(IEnumerable subscriptionIds)
{
- var command = _connection.CreateCommand();
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ var command = connection.CreateCommand();
var paramString = new StringBuilder("DELETE FROM Subscriptions WHERE SubscriptionId IN (");
subscriptionIds.TryGetNonEnumeratedCount(out int count);
@@ -231,4 +170,89 @@ public async Task RemoveSubscriptionsBySubscriptionIDs(IEnumerable subsc
await command.ExecuteNonQueryAsync();
}
+
+ private static async Task TryCreateDatabaseAndOpenConnection()
+ {
+ var dbPassExists = File.Exists(Constants.DatabasePasswordFilePath);
+ if (!dbPassExists)
+ {
+ DatabaseEncryptedPasswordManager.SetSecret($"keyvaultexplorer_{System.Guid.NewGuid().ToString()[..6]}");
+ }
+
+ _password = await DatabaseEncryptedPasswordManager.GetSecret();
+
+ var db = new SqliteConnection($"Filename={Constants.DatabaseFilePath}; Password={_password}");
+
+ return db;
+ }
+
+ public static async void InitializeDatabase()
+ {
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+
+ string tableCommand = """
+ PRAGMA foreign_keys = off;
+ BEGIN TRANSACTION;
+ -- Table: Subscriptions
+ CREATE TABLE IF NOT EXISTS Subscriptions (
+ DisplayName TEXT NOT NULL,
+ SubscriptionId TEXT (200) PRIMARY KEY UNIQUE ON CONFLICT IGNORE,
+ TenantId TEXT (200)
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS IX_Subscriptions_DisplayName_SubscriptionsId ON Subscriptions (
+ SubscriptionId ASC,
+ DisplayName ASC
+ );
+ -- Table: QuickAccess
+ CREATE TABLE IF NOT EXISTS QuickAccess (
+ Id INTEGER NOT NULL CONSTRAINT PK_QuickAccess PRIMARY KEY AUTOINCREMENT,
+ Name TEXT NOT NULL,
+ VaultUri TEXT NOT NULL,
+ KeyVaultId TEXT NOT NULL CONSTRAINT UQ_KeyVaultId UNIQUE ON CONFLICT IGNORE,
+ SubscriptionDisplayName TEXT,
+ SubscriptionId TEXT,
+ TenantId TEXT NOT NULL,
+ Location TEXT NOT NULL
+ );
+ -- Index: IX_QuickAccess_KeyVaultId
+ CREATE INDEX IF NOT EXISTS IX_QuickAccess_KeyVaultId ON QuickAccess (
+ KeyVaultId
+ );
+ COMMIT TRANSACTION;
+ PRAGMA foreign_keys = on;
+ """;
+ var createTableCommand = connection.CreateCommand();
+ createTableCommand.CommandText = tableCommand;
+ await createTableCommand.ExecuteNonQueryAsync();
+ }
+
+ public async Task DropTablesAndRecreate()
+ {
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.OpenAsync();
+ string deleteCommand = """
+ PRAGMA foreign_keys = off;
+ BEGIN TRANSACTION;
+ DROP TABLE IF EXISTS Subscriptions;
+ DROP TABLE IF EXISTS QuickAccess;
+ COMMIT TRANSACTION;
+ PRAGMA foreign_keys = on;
+ """;
+ var deleteTableCommand = connection.CreateCommand();
+ deleteTableCommand.CommandText = deleteCommand;
+ await deleteTableCommand.ExecuteNonQueryAsync();
+ InitializeDatabase();
+ }
+
+ public async Task DeleteDatabaseFile()
+ {
+ if (File.Exists(Constants.DatabaseFilePath))
+ {
+ using var connection = await TryCreateDatabaseAndOpenConnection();
+ await connection.CloseAsync();
+ File.Delete(Constants.DatabaseFilePath);
+ DatabaseEncryptedPasswordManager.PurgePasswords();
+ }
+ }
}
\ No newline at end of file
diff --git a/KeyVaultExplorer/KeyVaultExplorer.csproj b/KeyVaultExplorer/KeyVaultExplorer.csproj
index 263decf..fc7a133 100644
--- a/KeyVaultExplorer/KeyVaultExplorer.csproj
+++ b/KeyVaultExplorer/KeyVaultExplorer.csproj
@@ -1,6 +1,6 @@
- net9.0
+ net10.0
$(TargetFrameworks);net9.0-windows10.0.19041.0
10.0.19041.41
@@ -54,21 +54,21 @@
-
+
-
+
-
-
+
+
-
-
-
+
+
+
-
+
-
+
diff --git a/KeyVaultExplorer/Models/Constants.cs b/KeyVaultExplorer/Models/Constants.cs
index 7486c5e..d196c98 100644
--- a/KeyVaultExplorer/Models/Constants.cs
+++ b/KeyVaultExplorer/Models/Constants.cs
@@ -1,6 +1,7 @@
using Microsoft.Identity.Client.Extensions.Msal;
using System;
using System.Collections.Generic;
+using System.IO;
namespace KeyVaultExplorer.Models;
@@ -14,7 +15,7 @@ public static class Constants
public const string ProtectedKeyFileName = "keyvaultexplorerforazure_database_key.bin";
public const string DeviceFileTokenName = "keyvaultexplorerforazure_database_device-token.txt";
- //The Application or Client ID will be generated while registering the app in the Azure portal. Copy and paste the GUID.
+ //The Application or Client ID will be generated while registering the app in the Azure portal. This can also be set in settings of the app rather than recompiling.
public static readonly string ClientId = "fdc1e6da-d735-4627-af3e-d40377f55713";
//Leaving the scope to its default values.
@@ -35,8 +36,9 @@ public static class Constants
public const string KeyChainServiceName = "keyvaultexplorerforazure_msal_service";
public const string KeyChainAccountName = "keyvaultexplorerforazure_msal_account";
+ public static readonly string DatabasePasswordFilePath = Path.Combine(LocalAppDataFolder, EncryptedSecretFileName);
- public const string LinuxKeyRingSchema = "us.sidesteplabs.keyvaultexplorer.tokencache";
+ public const string LinuxKeyRingSchema = "us.cricketthomas.keyvaultexplorer.tokencache";
public const string LinuxKeyRingCollection = MsalCacheHelper.LinuxKeyRingDefaultCollection;
public const string LinuxKeyRingLabel = "MSAL token cache for Key Vault Explorer for Azure.";
public static readonly KeyValuePair LinuxKeyRingAttr1 = new KeyValuePair("Version", "1");
diff --git a/KeyVaultExplorer/Services/AuthService.cs b/KeyVaultExplorer/Services/AuthService.cs
index de6d643..6fd6c35 100644
--- a/KeyVaultExplorer/Services/AuthService.cs
+++ b/KeyVaultExplorer/Services/AuthService.cs
@@ -37,7 +37,7 @@ public AuthService()
authenticationClient = PublicClientApplicationBuilder.Create(clientId)
.WithRedirectUri($"msal{clientId}://auth")
.WithRedirectUri("http://localhost")
- .WithIosKeychainSecurityGroup("us.sidesteplabs.keyvaultexplorer")
+ .WithIosKeychainSecurityGroup("us.cricketthomas.keyvaultexplorer")
.Build();
}
diff --git a/KeyVaultExplorer/Services/DatabaseEncryptedPasswordManager.cs b/KeyVaultExplorer/Services/DatabaseEncryptedPasswordManager.cs
index 63c581f..a19207e 100644
--- a/KeyVaultExplorer/Services/DatabaseEncryptedPasswordManager.cs
+++ b/KeyVaultExplorer/Services/DatabaseEncryptedPasswordManager.cs
@@ -130,4 +130,32 @@ private static byte[] GetProtectedKey()
}
return new byte[32];
}
+ public static void PurgePasswords()
+ {
+ string dbPath = Path.Combine(Constants.LocalAppDataFolder, Constants.ProtectedKeyFileName);
+ if (OperatingSystem.IsMacOS())
+ {
+ string macosPath = dbPath.Replace("bin", "txt");
+ if (File.Exists(macosPath))
+ {
+ File.Delete(macosPath);
+ }
+ MacOSKeyChainService.DeleteFromKeychain(Constants.KeychainSecretName, Constants.KeychainServiceName);
+ }
+ if (OperatingSystem.IsWindows())
+ {
+ if (File.Exists(dbPath))
+ File.Delete(dbPath);
+
+ string encryptedSecretPath = Path.Combine(Constants.LocalAppDataFolder, Constants.EncryptedSecretFileName);
+ if (File.Exists(encryptedSecretPath))
+ File.Delete(encryptedSecretPath);
+
+ if (File.Exists(Constants.DatabasePasswordFilePath))
+ File.Delete(Constants.DatabasePasswordFilePath);
+
+ if (File.Exists(Constants.DeviceFileTokenName))
+ File.Delete(Constants.DeviceFileTokenName);
+ }
+ }
}
\ No newline at end of file
diff --git a/KeyVaultExplorer/Services/MacOSKeyChainService.cs b/KeyVaultExplorer/Services/MacOSKeyChainService.cs
index dffcff4..d81c79b 100644
--- a/KeyVaultExplorer/Services/MacOSKeyChainService.cs
+++ b/KeyVaultExplorer/Services/MacOSKeyChainService.cs
@@ -1,120 +1,227 @@
using System;
+using System.Diagnostics;
using System.Runtime.InteropServices;
+using System.Text;
using System.Threading.Tasks;
-namespace KeyVaultExplorer;
+namespace KeyVaultExplorer.Services;
public static class MacOSKeyChainService
{
- public const string SecurityLibrary = "/System/Library/Frameworks/Security.framework/Security";
+ private const string SecurityLibrary = "/System/Library/Frameworks/Security.framework/Security";
- [DllImport(SecurityLibrary)]
- public static extern int SecKeychainAddGenericPassword(
- IntPtr keychain,
- uint serviceNameLength,
- string serviceName,
- uint accountNameLength,
- string accountName,
- uint passwordLength,
- IntPtr passwordData,
- IntPtr itemRef);
-
- [DllImport(SecurityLibrary)]
- private static extern int SecKeychainItemFreeContent(
- IntPtr attrList,
- IntPtr data);
-
- [DllImport(SecurityLibrary)]
- private static extern int SecKeychainFindGenericPassword(
- IntPtr keychainOrArray,
- uint serviceNameLength,
- string serviceName,
- uint accountNameLength,
- string accountName,
- out uint passwordLength,
- out IntPtr passwordData,
- IntPtr itemRef);
-
- public static void SaveToKeychain(string serviceName, string accountName, string password)
+ ///
+ /// Delete a generic password from the login keychain.
+ /// Falls back to the `security` CLI if the native API fails.
+ ///
+ public static void DeleteFromKeychain(string serviceName, string accountName)
{
- IntPtr itemRef = IntPtr.Zero;
try
{
- var result = SecKeychainAddGenericPassword(
- IntPtr.Zero,
+ var kc = GetDefaultKeychain();
+ var rc = SecKeychainFindGenericPassword(
+ kc,
(uint)serviceName.Length, serviceName,
(uint)accountName.Length, accountName,
- (uint)password.Length, Marshal.StringToCoTaskMemAnsi(password),
- itemRef);
+ out _,
+ out _,
+ out var itemRef);
- if (result != 0)
- {
- throw new Exception($"Failed to save to Keychain. Error: {result}");
- }
+ if (rc != 0)
+ throw new Exception($"SecKeychainFindGenericPassword returned {rc}");
+
+ if (itemRef == IntPtr.Zero)
+ throw new Exception("No matching item found in the keychain.");
+
+ rc = SecKeychainItemDelete(itemRef);
+ if (rc != 0)
+ throw new Exception($"SecKeychainItemDelete returned {rc}");
}
- finally
+ catch
{
- if (itemRef != IntPtr.Zero)
- {
- Marshal.FreeCoTaskMem(itemRef);
- }
+ // Native failed — fall back to security CLI
+ DeleteFromKeychainCli(serviceName, accountName);
}
}
+ ///
+ /// Retrieve a generic password from the login keychain.
+ /// Falls back to the `security` CLI if native API fails.
+ ///
public static string GetPassword(string serviceName, string accountName)
{
- IntPtr itemRef = IntPtr.Zero;
- IntPtr passwordData = IntPtr.Zero;
-
try
{
- uint passwordLength;
- int result = SecKeychainFindGenericPassword(
- IntPtr.Zero,
+ var kc = GetDefaultKeychain();
+ var rc = SecKeychainFindGenericPassword(
+ kc,
(uint)serviceName.Length, serviceName,
(uint)accountName.Length, accountName,
- out passwordLength,
- out passwordData,
- itemRef);
+ out var pwdLen,
+ out var pwdData,
+ out _);
- if (result != 0)
- {
- throw new Exception($"Failed to retrieve password from Keychain. Error: {result}");
- }
+ if (rc != 0)
+ throw new Exception($"SecKeychainFindGenericPassword returned {rc}");
- if (passwordData != IntPtr.Zero)
- {
- byte[] passwordBytes = new byte[passwordLength];
- Marshal.Copy(passwordData, passwordBytes, 0, (int)passwordLength);
+ if (pwdData == IntPtr.Zero || pwdLen == 0)
+ throw new Exception("No password data returned.");
- return System.Text.Encoding.UTF8.GetString(passwordBytes);
+ try
+ {
+ var buf = new byte[pwdLen];
+ Marshal.Copy(pwdData, buf, 0, (int)pwdLen);
+ return Encoding.UTF8.GetString(buf);
}
- else
+ finally
{
- throw new Exception("No password data found in Keychain.");
+ SecKeychainItemFreeContent(IntPtr.Zero, pwdData);
}
}
- finally
+ catch
+ {
+ // Native failed — fall back to security CLI
+ return GetPasswordCli(serviceName, accountName);
+ }
+ }
+
+ public static Task GetPasswordAsync(string serviceName, string accountName) =>
+ Task.Run(() => GetPassword(serviceName, accountName));
+
+ ///
+ /// Save (or update) a generic password into the login keychain.
+ /// Falls back to the `security` CLI if the native API returns an error.
+ ///
+ public static void SaveToKeychain(string serviceName, string accountName, string password)
+ {
+ try
{
- if (itemRef != IntPtr.Zero)
+ var kc = GetDefaultKeychain();
+ var pwdPtr = Marshal.StringToCoTaskMemAnsi(password);
+ try
{
- Marshal.FreeCoTaskMem(itemRef);
- }
+ var rc = SecKeychainAddGenericPassword(
+ kc,
+ (uint)serviceName.Length, serviceName,
+ (uint)accountName.Length, accountName,
+ (uint)password.Length, pwdPtr,
+ out _);
- if (passwordData != IntPtr.Zero)
+ if (rc != 0)
+ throw new Exception($"SecKeychainAddGenericPassword returned {rc}");
+ }
+ finally
{
- SecKeychainItemFreeContent(IntPtr.Zero, passwordData);
+ Marshal.FreeCoTaskMem(pwdPtr);
}
}
+ catch
+ {
+ // Native failed — fall back to security CLI
+ SaveToKeychainCli(serviceName, accountName, password);
+ }
}
- public static async Task GetPasswordAsync(string serviceName, string accountName)
+ ///
+ /// Async wrappers
+ ///
+ public static Task SetPasswordAsync(string serviceName, string accountName, string password) =>
+ Task.Run(() => SaveToKeychain(serviceName, accountName, password));
+
+ private static void DeleteFromKeychainCli(string service, string account)
{
- return await Task.Run(() => GetPassword(serviceName, accountName));
+ var psi = new ProcessStartInfo("security",
+ $"delete-generic-password -a {EscapeArg(account)} -s {EscapeArg(service)}")
+ {
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+ using var proc = Process.Start(psi);
+ proc.WaitForExit();
+ if (proc.ExitCode != 0)
+ throw new Exception($"security CLI delete-generic-password failed: {proc.StandardError.ReadToEnd()}");
}
- public static async void SetPasswordAsync(string serviceName, string accountName, string password)
+ private static string EscapeArg(string s) => "\"" + s.Replace("\"", "\\\"") + "\"";
+
+ private static IntPtr GetDefaultKeychain()
{
- await Task.Run(() => SaveToKeychain(serviceName, accountName, password));
+ var rc = SecKeychainCopyDefault(out var kc);
+ if (rc != 0)
+ throw new Exception($"SecKeychainCopyDefault failed: {rc}");
+ // If the keychain is locked, this will trigger the system prompt
+ SecKeychainUnlock(kc, 0, IntPtr.Zero, true);
+ return kc;
}
+
+ private static string GetPasswordCli(string service, string account)
+ {
+ var psi = new ProcessStartInfo("security",
+ $"find-generic-password -a {EscapeArg(account)} -s {EscapeArg(service)} -w")
+ {
+ RedirectStandardOutput = true,
+ UseShellExecute = false
+ };
+ using var proc = Process.Start(psi);
+ var output = proc.StandardOutput.ReadToEnd().TrimEnd('\n');
+ proc.WaitForExit();
+ if (proc.ExitCode != 0)
+ throw new Exception("security CLI find-generic-password failed to locate the secret.");
+ return output;
+ }
+
+ private static void SaveToKeychainCli(string service, string account, string password)
+ {
+ var psi = new ProcessStartInfo("security",
+ $"add-generic-password -a {EscapeArg(account)} -s {EscapeArg(service)} -w {EscapeArg(password)} -U")
+ {
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+ using var proc = Process.Start(psi);
+ proc.WaitForExit();
+ if (proc.ExitCode != 0)
+ throw new Exception($"security CLI add-generic-password failed: {proc.StandardError.ReadToEnd()}");
+ }
+
+ [DllImport(SecurityLibrary)]
+ private static extern int SecKeychainAddGenericPassword(
+ IntPtr keychain,
+ uint serviceNameLength,
+ string serviceName,
+ uint accountNameLength,
+ string accountName,
+ uint passwordLength,
+ IntPtr passwordData,
+ out IntPtr itemRef);
+
+ // P/Invokes into Security.framework
+ [DllImport(SecurityLibrary)]
+ private static extern int SecKeychainCopyDefault(out IntPtr defaultKeychain);
+
+ [DllImport(SecurityLibrary)]
+ private static extern int SecKeychainFindGenericPassword(
+ IntPtr keychain,
+ uint serviceNameLength,
+ string serviceName,
+ uint accountNameLength,
+ string accountName,
+ out uint passwordLength,
+ out IntPtr passwordData,
+ out IntPtr itemRef);
+
+ [DllImport(SecurityLibrary)]
+ private static extern int SecKeychainItemDelete(IntPtr itemRef);
+
+ [DllImport(SecurityLibrary)]
+ private static extern int SecKeychainItemFreeContent(
+ IntPtr attrList,
+ IntPtr data);
+
+ [DllImport(SecurityLibrary)]
+ private static extern int SecKeychainUnlock(
+ IntPtr keychain,
+ uint seconds,
+ IntPtr initialData,
+ bool useUI);
}
\ No newline at end of file
diff --git a/KeyVaultExplorer/ViewModels/FilterService.cs b/KeyVaultExplorer/ViewModels/FilterService.cs
index fdc1f28..5720940 100644
--- a/KeyVaultExplorer/ViewModels/FilterService.cs
+++ b/KeyVaultExplorer/ViewModels/FilterService.cs
@@ -15,8 +15,6 @@ public static IList Filter(IList allSu
return allSubscriptions;
}
- var filteredSubscriptions = new List();
-
foreach (var subscription in allSubscriptions)
{
var filteredResourceGroups = subscription.ResourceGroups
@@ -40,10 +38,19 @@ public static IList Filter(IList allSu
ResourceGroups = new ObservableCollection(filteredResourceGroups)
};
- filteredSubscriptions.Add(filteredSubscription);
+ new List().Add(filteredSubscription);
}
}
- return filteredSubscriptions;
+ // Sort the filtered results alphabetically
+ var sortedSubscriptions = SortService.SortSubscriptions(new List());
+
+ // Sort the nested collections within each subscription
+ foreach (var subscription in sortedSubscriptions)
+ {
+ SortService.SortSubscriptionTree(subscription);
+ }
+
+ return sortedSubscriptions;
}
}
\ No newline at end of file
diff --git a/KeyVaultExplorer/ViewModels/KeyVaultTreeListViewModel.cs b/KeyVaultExplorer/ViewModels/KeyVaultTreeListViewModel.cs
index fc2cba6..26fc21c 100644
--- a/KeyVaultExplorer/ViewModels/KeyVaultTreeListViewModel.cs
+++ b/KeyVaultExplorer/ViewModels/KeyVaultTreeListViewModel.cs
@@ -1,5 +1,4 @@
-using Avalonia.Input;
-using Avalonia.Threading;
+using Avalonia.Threading;
using Azure.Core;
using Azure.ResourceManager;
using Azure.ResourceManager.KeyVault;
@@ -9,9 +8,6 @@
using KeyVaultExplorer.Models;
using KeyVaultExplorer.Services;
using System;
-using System.Buffers;
-using System.Collections;
-using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
@@ -50,7 +46,7 @@ public KeyVaultTreeListViewModel()
{
_authService = Defaults.Locator.GetRequiredService();
_vaultService = Defaults.Locator.GetRequiredService();
- _dbContext = Defaults.Locator.GetRequiredService();
+ using var _ = _dbContext = Defaults.Locator.GetRequiredService();
_notificationViewModel = Defaults.Locator.GetRequiredService();
_clipboardService = Defaults.Locator.GetRequiredService();
// PropertyChanged += OnMyViewModelPropertyChanged;
@@ -118,6 +114,8 @@ await DelaySetIsBusy(async () =>
quickAccess.ResourceGroups[0].KeyVaultResources.Add(kvrResponse);
quickAccess.PropertyChanged += KvSubscriptionModel_PropertyChanged;
}
+ quickAccess.ResourceGroups[0].KeyVaultResources = SortService.SortKeyVaults(quickAccess.ResourceGroups[0].KeyVaultResources);
+
quickAccess.ResourceGroups[0].ResourceGroupDisplayName = "Pinned";
quickAccess.ResourceGroups[0].IsExpanded = true;
@@ -139,6 +137,7 @@ await DelaySetIsBusy(async () =>
var searched = await Task.Run(() =>
{
return new ObservableCollection(_treeViewList);
+ return SortService.SortSubscriptions(_treeViewList);
});
Dispatcher.UIThread.Post(() => {
TreeViewList = searched;
@@ -190,6 +189,10 @@ public async Task PinVaultToQuickAccess(KeyVaultResource model)
await _dbContext.InsertQuickAccessItemAsync(qa);
TreeViewList[0].ResourceGroups[0].KeyVaultResources.Add(model);
+
+ // Sort the quick access key vaults alphabetically
+ TreeViewList[0].ResourceGroups[0].KeyVaultResources = SortService.SortKeyVaults(TreeViewList[0].ResourceGroups[0].KeyVaultResources);
+
var quickAccess = new KvSubscriptionModel
{
SubscriptionDisplayName = "Quick Access",
@@ -251,6 +254,9 @@ await DelaySetIsBusy(async () =>
{
kvResourceModel.KeyVaultResources.Add(vault);
}
+
+ // Sort the key vaults alphabetically
+ kvResourceModel.KeyVaultResources = SortService.SortKeyVaults(kvResourceModel.KeyVaultResources);
});
}, DispatcherPriority.ContextIdle);
}
@@ -291,6 +297,10 @@ await DelaySetIsBusy(async () =>
KeyVaultResources = [placeholder]
});
}
+
+ // Sort the resource groups alphabetically
+ kvSubModel.ResourceGroups = SortService.SortResourceGroups(kvSubModel.ResourceGroups);
+
kvSubModel.HasSubNodeDataBeenFetched = true;
kvSubModel.ResourceGroups.CollectionChanged += TreeViewSubNode_CollectionChanged;
});
diff --git a/KeyVaultExplorer/ViewModels/SettingsPageViewModel.cs b/KeyVaultExplorer/ViewModels/SettingsPageViewModel.cs
index 4b569aa..6b988e3 100644
--- a/KeyVaultExplorer/ViewModels/SettingsPageViewModel.cs
+++ b/KeyVaultExplorer/ViewModels/SettingsPageViewModel.cs
@@ -1,7 +1,9 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Mvvm.Messaging;
using FluentAvalonia.Styling;
+using FluentAvalonia.UI.Controls;
using KeyVaultExplorer.Database;
using KeyVaultExplorer.Models;
using KeyVaultExplorer.Services;
@@ -174,4 +176,14 @@ private void OpenIssueGithub()
{
Process.Start(new ProcessStartInfo("https://github.com/cricketthomas/KeyVaultExplorer/issues/new") { UseShellExecute = true, Verb = "open" });
}
+
+
+ [RelayCommand]
+ private Task DeleteDatabase()
+ {
+ _ = _dbContext.DropTablesAndRecreate();
+ return Task.CompletedTask;
+ }
+
+
}
\ No newline at end of file
diff --git a/KeyVaultExplorer/ViewModels/SortService.cs b/KeyVaultExplorer/ViewModels/SortService.cs
new file mode 100644
index 0000000..29881d0
--- /dev/null
+++ b/KeyVaultExplorer/ViewModels/SortService.cs
@@ -0,0 +1,58 @@
+using Azure.ResourceManager.KeyVault;
+using KeyVaultExplorer.Models;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+
+namespace KeyVaultExplorer.ViewModels;
+
+public static class SortService
+{
+ ///
+ /// Sorts key vaults alphabetically by name
+ ///
+ public static ObservableCollection SortKeyVaults(IEnumerable keyVaults)
+ {
+ var sorted = keyVaults.OrderBy(kv => kv.HasData ? kv.Data.Name : string.Empty);
+ return new ObservableCollection(sorted);
+ }
+
+ ///
+ /// Sorts resource groups alphabetically by display name
+ ///
+ public static ObservableCollection SortResourceGroups(IEnumerable resourceGroups)
+ {
+ var sorted = resourceGroups.OrderBy(rg => rg.ResourceGroupDisplayName ?? string.Empty);
+ return new ObservableCollection(sorted);
+ }
+
+ ///
+ /// Sorts subscriptions alphabetically by display name, keeping "Quick Access" at the top
+ ///
+ public static ObservableCollection SortSubscriptions(IEnumerable subscriptions)
+ {
+ var subscriptionsList = subscriptions.ToList();
+
+ var quickAccess = subscriptionsList.Where(s => s.SubscriptionDisplayName == "Quick Access").ToList();
+ var regularSubscriptions = subscriptionsList.Where(s => s.SubscriptionDisplayName != "Quick Access").ToList();
+
+ var sortedRegular = regularSubscriptions.OrderBy(s => s.SubscriptionDisplayName ?? string.Empty);
+
+ var result = new List();
+ result.AddRange(quickAccess);
+ result.AddRange(sortedRegular);
+
+ return new ObservableCollection(result);
+ }
+ ///
+ /// Sorts an entire subscription model tree, including all nested collections
+ ///
+ public static void SortSubscriptionTree(KvSubscriptionModel subscription)
+ {
+ subscription.ResourceGroups = SortResourceGroups(subscription.ResourceGroups);
+ foreach (var resourceGroup in subscription.ResourceGroups)
+ {
+ resourceGroup.KeyVaultResources = SortKeyVaults(resourceGroup.KeyVaultResources);
+ }
+ }
+}
\ No newline at end of file
diff --git a/KeyVaultExplorer/Views/Pages/SettingsPage.axaml b/KeyVaultExplorer/Views/Pages/SettingsPage.axaml
index 4100ab5..edbc071 100644
--- a/KeyVaultExplorer/Views/Pages/SettingsPage.axaml
+++ b/KeyVaultExplorer/Views/Pages/SettingsPage.axaml
@@ -53,6 +53,13 @@
+
+
+
+
+