Skip to content

Commit 6b2fdab

Browse files
committed
see revisions.md for list of changes.
1 parent 07bc2f0 commit 6b2fdab

15 files changed

Lines changed: 210 additions & 109 deletions

File tree

1-Aquiis.Infrastructure/Aquiis.Infrastructure.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
2020
<PackageReference Include="Twilio" Version="7.14.0" />
2121
<PackageReference Include="QuestPDF" Version="2025.12.1" />
22+
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
2223
</ItemGroup>
2324

2425
<ItemGroup>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace Aquiis.Infrastructure.Interfaces;
2+
3+
/// <summary>
4+
/// Abstraction for platform-specific secure key storage.
5+
/// Linux: uses libsecret (secret-tool). Windows: uses DPAPI encrypted file.
6+
/// </summary>
7+
public interface IKeychainService
8+
{
9+
/// <summary>Store a password/key in the platform keychain</summary>
10+
bool StoreKey(string password, string label = "Aquiis Database Encryption Key");
11+
12+
/// <summary>Retrieve the stored password/key, or null if not found</summary>
13+
string? RetrieveKey();
14+
15+
/// <summary>Remove the stored password/key</summary>
16+
bool RemoveKey();
17+
18+
/// <summary>Check if the keychain service is available on this platform</summary>
19+
bool IsAvailable();
20+
}

1-Aquiis.Infrastructure/Services/DatabaseEncryptionService.cs

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Aquiis.Infrastructure.Interfaces;
12
using Microsoft.Data.Sqlite;
23
using Microsoft.Extensions.Logging;
34
using System.Text;
@@ -11,12 +12,12 @@ namespace Aquiis.Infrastructure.Services;
1112
public class DatabaseEncryptionService
1213
{
1314
private readonly PasswordDerivationService _passwordDerivation;
14-
private readonly LinuxKeychainService _keychain;
15+
private readonly IKeychainService _keychain;
1516
private readonly ILogger<DatabaseEncryptionService> _logger;
1617

1718
public DatabaseEncryptionService(
1819
PasswordDerivationService passwordDerivation,
19-
LinuxKeychainService keychain,
20+
IKeychainService keychain,
2021
ILogger<DatabaseEncryptionService> logger)
2122
{
2223
_passwordDerivation = passwordDerivation;
@@ -118,18 +119,19 @@ public DatabaseEncryptionService(
118119
return (false, null, "Failed to verify encrypted database");
119120
}
120121

122+
// Clear pools so Windows releases the verified file handle before the caller moves the file
123+
SqliteConnection.ClearAllPools();
124+
await Task.Delay(200);
125+
121126
// Store password in keychain (best effort - don't fail if keychain unavailable)
122-
if (OperatingSystem.IsLinux())
127+
var stored = _keychain.StoreKey(password, "Aquiis Database Encryption Password");
128+
if (!stored)
123129
{
124-
var stored = _keychain.StoreKey(password, "Aquiis Database Encryption Password");
125-
if (!stored)
126-
{
127-
_logger.LogWarning("Failed to store password in keychain - you'll need to enter it manually on next startup");
128-
}
129-
else
130-
{
131-
_logger.LogInformation("Password stored in keychain successfully");
132-
}
130+
_logger.LogWarning("Failed to store password in keychain - you'll need to enter it manually on next startup");
131+
}
132+
else
133+
{
134+
_logger.LogInformation("Password stored in keychain successfully");
133135
}
134136

135137
_logger.LogInformation("Database encryption completed successfully");
@@ -230,11 +232,12 @@ public DatabaseEncryptionService(
230232
return (false, null, "Failed to verify decrypted database");
231233
}
232234

235+
// Clear pools so Windows releases the verified file handle before the caller moves the file
236+
SqliteConnection.ClearAllPools();
237+
await Task.Delay(200);
238+
233239
// Remove password from keychain
234-
if (OperatingSystem.IsLinux())
235-
{
236-
_keychain.RemoveKey();
237-
}
240+
_keychain.RemoveKey();
238241

239242
_logger.LogInformation("Database decryption completed successfully");
240243
return (true, decryptedPath, null);
@@ -310,22 +313,10 @@ private async Task<bool> VerifyPlaintextDatabaseAsync(string dbPath)
310313
/// <summary>
311314
/// Try to retrieve encryption key from keychain
312315
/// </summary>
313-
public string? TryGetKeyFromKeychain()
314-
{
315-
if (!OperatingSystem.IsLinux())
316-
return null;
317-
318-
return _keychain.RetrieveKey();
319-
}
316+
public string? TryGetKeyFromKeychain() => _keychain.RetrieveKey();
320317

321318
/// <summary>
322319
/// Check if keychain service is available
323320
/// </summary>
324-
public bool IsKeychainAvailable()
325-
{
326-
if (!OperatingSystem.IsLinux())
327-
return false;
328-
329-
return _keychain.IsAvailable();
330-
}
321+
public bool IsKeychainAvailable() => _keychain.IsAvailable();
331322
}

1-Aquiis.Infrastructure/Services/LinuxKeychainService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
using System.Runtime.InteropServices;
22
using System.Text;
3+
using Aquiis.Infrastructure.Interfaces;
34

45
namespace Aquiis.Infrastructure.Services;
56

67
/// <summary>
78
/// Service for storing and retrieving encryption keys from Linux Secret Service (libsecret).
89
/// Provides convenient auto-decryption on trusted devices.
910
/// </summary>
10-
public class LinuxKeychainService
11+
public class LinuxKeychainService : IKeychainService
1112
{
1213
private const string Schema = "org.aquiis.database";
1314
private const string KeyAttribute = "key-type";
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System.Runtime.Versioning;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
using Aquiis.Infrastructure.Interfaces;
5+
6+
namespace Aquiis.Infrastructure.Services;
7+
8+
/// <summary>
9+
/// Secure key storage for Windows using DPAPI (Data Protection API).
10+
/// Encrypts the database password with the current user's Windows credentials
11+
/// and stores it in a file under %APPDATA%\Aquiis\. Only the same user on the
12+
/// same machine can decrypt the file — no user interaction needed on retrieval.
13+
/// </summary>
14+
[SupportedOSPlatform("windows")]
15+
public class WindowsKeychainService : IKeychainService
16+
{
17+
private readonly string _keyFilePath;
18+
19+
/// <summary>
20+
/// Initialize the Windows DPAPI keychain service.
21+
/// </summary>
22+
/// <param name="appName">App-specific identifier to prevent key conflicts (e.g. "SimpleStart-Electron")</param>
23+
public WindowsKeychainService(string appName = "Aquiis-Electron")
24+
{
25+
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
26+
var aquiisDir = Path.Combine(appDataPath, "Aquiis");
27+
Directory.CreateDirectory(aquiisDir);
28+
29+
// Sanitize appName for use as a filename component
30+
var safeAppName = new string(appName.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray());
31+
_keyFilePath = Path.Combine(aquiisDir, $"aquiis_{safeAppName}.key");
32+
33+
Console.WriteLine($"[WindowsKeychainService] Initialized with key file: {_keyFilePath}");
34+
}
35+
36+
/// <summary>
37+
/// Store the encryption password using DPAPI. The data is encrypted with the
38+
/// current user's credentials and written to a binary key file.
39+
/// </summary>
40+
public bool StoreKey(string password, string label = "Aquiis Database Encryption Key")
41+
{
42+
try
43+
{
44+
Console.WriteLine("[WindowsKeychainService] Storing password using DPAPI");
45+
var plainBytes = Encoding.UTF8.GetBytes(password);
46+
var encryptedBytes = ProtectedData.Protect(plainBytes, null, DataProtectionScope.CurrentUser);
47+
File.WriteAllBytes(_keyFilePath, encryptedBytes);
48+
Console.WriteLine("[WindowsKeychainService] Password stored successfully using DPAPI");
49+
return true;
50+
}
51+
catch (Exception ex)
52+
{
53+
Console.WriteLine($"[WindowsKeychainService] Failed to store password: {ex.Message}");
54+
return false;
55+
}
56+
}
57+
58+
/// <summary>
59+
/// Retrieve the encryption password by decrypting the key file with DPAPI.
60+
/// Returns null if the file does not exist or cannot be decrypted.
61+
/// </summary>
62+
public string? RetrieveKey()
63+
{
64+
if (!File.Exists(_keyFilePath))
65+
{
66+
Console.WriteLine("[WindowsKeychainService] Key file not found");
67+
return null;
68+
}
69+
70+
try
71+
{
72+
Console.WriteLine("[WindowsKeychainService] Retrieving password using DPAPI");
73+
var encryptedBytes = File.ReadAllBytes(_keyFilePath);
74+
var plainBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.CurrentUser);
75+
var password = Encoding.UTF8.GetString(plainBytes);
76+
Console.WriteLine($"[WindowsKeychainService] Password retrieved successfully using DPAPI (length: {password.Length})");
77+
return password;
78+
}
79+
catch (CryptographicException ex)
80+
{
81+
Console.WriteLine($"[WindowsKeychainService] Failed to decrypt password (DPAPI): {ex.Message}");
82+
Console.WriteLine("[WindowsKeychainService] This usually means the key file was encrypted by a different user or machine");
83+
return null;
84+
}
85+
catch (Exception ex)
86+
{
87+
Console.WriteLine($"[WindowsKeychainService] Failed to retrieve password: {ex.Message}");
88+
return null;
89+
}
90+
}
91+
92+
/// <summary>
93+
/// Delete the key file, effectively removing the stored password.
94+
/// </summary>
95+
public bool RemoveKey()
96+
{
97+
try
98+
{
99+
if (File.Exists(_keyFilePath))
100+
{
101+
File.Delete(_keyFilePath);
102+
Console.WriteLine("[WindowsKeychainService] Key file deleted successfully");
103+
}
104+
return true;
105+
}
106+
catch (Exception ex)
107+
{
108+
Console.WriteLine($"[WindowsKeychainService] Failed to remove key file: {ex.Message}");
109+
return false;
110+
}
111+
}
112+
113+
/// <summary>
114+
/// DPAPI is always available on Windows.
115+
/// </summary>
116+
public bool IsAvailable() => OperatingSystem.IsWindows();
117+
}

2-Aquiis.Application/Services/DatabasePasswordService.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
using Aquiis.Core.Interfaces;
2-
using Aquiis.Infrastructure.Services;
2+
using Aquiis.Infrastructure.Interfaces;
33
using Microsoft.Extensions.Logging;
44

55
namespace Aquiis.Application.Services;
@@ -10,11 +10,11 @@ namespace Aquiis.Application.Services;
1010
/// </summary>
1111
public class DatabasePasswordService
1212
{
13-
private readonly LinuxKeychainService _keychain;
13+
private readonly IKeychainService _keychain;
1414
private readonly ILogger<DatabasePasswordService> _logger;
1515

1616
public DatabasePasswordService(
17-
LinuxKeychainService keychain,
17+
IKeychainService keychain,
1818
ILogger<DatabasePasswordService> logger)
1919
{
2020
_keychain = keychain;
@@ -64,9 +64,6 @@ public async Task<bool> IsDatabaseEncryptedAsync(string dbPath)
6464
/// </summary>
6565
public string? TryGetPasswordFromKeychain()
6666
{
67-
if (!OperatingSystem.IsLinux())
68-
return null;
69-
7067
var key = _keychain.RetrieveKey();
7168
if (key != null)
7269
{

2-Aquiis.Application/Services/DatabasePreviewService.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using Aquiis.Application.Models.DTOs;
22
using Aquiis.Core.Entities;
33
using Aquiis.Infrastructure.Data;
4-
using Aquiis.Infrastructure.Services;
4+
using Aquiis.Infrastructure.Interfaces;
55
using Microsoft.Data.Sqlite;
66
using Microsoft.EntityFrameworkCore;
77
using Microsoft.Extensions.Logging;
@@ -14,12 +14,12 @@ namespace Aquiis.Application.Services;
1414
/// </summary>
1515
public class DatabasePreviewService
1616
{
17-
private readonly LinuxKeychainService _keychain;
17+
private readonly IKeychainService _keychain;
1818
private readonly ILogger<DatabasePreviewService> _logger;
1919
private readonly string _backupDirectory;
2020

2121
public DatabasePreviewService(
22-
LinuxKeychainService keychain,
22+
IKeychainService keychain,
2323
ILogger<DatabasePreviewService> logger)
2424
{
2525
_keychain = keychain;
@@ -75,10 +75,7 @@ public async Task<bool> IsDatabaseEncryptedAsync(string backupFileName)
7575
/// </summary>
7676
public async Task<string?> TryGetKeychainPasswordAsync()
7777
{
78-
if (!OperatingSystem.IsLinux())
79-
return null;
80-
81-
await Task.CompletedTask; // Make method async
78+
await Task.CompletedTask; // Keep method async
8279
var key = _keychain.RetrieveKey();
8380
if (key != null)
8481
{

2-Aquiis.Application/Services/DatabaseUnlockService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
using Aquiis.Infrastructure.Interfaces;
12
using Microsoft.Data.Sqlite;
23
using Microsoft.Extensions.Logging;
3-
using Aquiis.Infrastructure.Services;
44

55
namespace Aquiis.Application.Services;
66

@@ -10,11 +10,11 @@ namespace Aquiis.Application.Services;
1010
/// </summary>
1111
public class DatabaseUnlockService
1212
{
13-
private readonly LinuxKeychainService _keychain;
13+
private readonly IKeychainService _keychain;
1414
private readonly ILogger<DatabaseUnlockService> _logger;
1515

1616
public DatabaseUnlockService(
17-
LinuxKeychainService keychain,
17+
IKeychainService keychain,
1818
ILogger<DatabaseUnlockService> logger)
1919
{
2020
_keychain = keychain;

4-Aquiis.SimpleStart/Extensions/ElectronServiceExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Aquiis.SimpleStart.Entities;
1313
using Aquiis.SimpleStart.Services; // For ElectronPathService, WebPathService
1414
using Aquiis.Infrastructure.Services; // For DatabaseUnlockState
15+
using Aquiis.Infrastructure.Interfaces; // For IKeychainService
1516
using Microsoft.Data.Sqlite;
1617

1718
namespace Aquiis.SimpleStart.Extensions;
@@ -191,7 +192,9 @@ public static IServiceCollection AddElectronServices(
191192
// Database is encrypted - try to get password from keychain
192193
if (EnableVerboseLogging)
193194
Console.WriteLine("Detected encrypted database, retrieving password from keychain...");
194-
var keychain = new LinuxKeychainService("SimpleStart-Electron"); // Pass app name to prevent keychain conflicts
195+
var keychain = OperatingSystem.IsWindows()
196+
? (IKeychainService)new WindowsKeychainService("SimpleStart-Electron")
197+
: new LinuxKeychainService("SimpleStart-Electron"); // Pass app name to prevent keychain conflicts
195198

196199
Console.WriteLine("Attempting to retrieve encryption password from keychain...");
197200
var password = keychain.RetrieveKey();

4-Aquiis.SimpleStart/Extensions/WebServiceExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using Aquiis.SimpleStart.Entities;
1313
using Aquiis.SimpleStart.Services; // For ElectronPathService, WebPathService
1414
using Aquiis.Infrastructure.Services; // For DatabaseUnlockState
15+
using Aquiis.Infrastructure.Interfaces; // For IKeychainService
1516
using Microsoft.Data.Sqlite;
1617

1718
namespace Aquiis.SimpleStart.Extensions;
@@ -178,7 +179,9 @@ public static IServiceCollection AddWebServices(
178179
// Database is encrypted - try to get password from keychain
179180
if (EnableVerboseLogging)
180181
Console.WriteLine("Detected encrypted database, retrieving password from keychain...");
181-
var keychain = new LinuxKeychainService("SimpleStart-Web"); // Pass app name to prevent keychain conflicts
182+
var keychain = OperatingSystem.IsWindows()
183+
? (IKeychainService)new WindowsKeychainService("SimpleStart-Web")
184+
: new LinuxKeychainService("SimpleStart-Web"); // Pass app name to prevent keychain conflicts
182185
var password = keychain.RetrieveKey();
183186

184187
if (string.IsNullOrEmpty(password))

0 commit comments

Comments
 (0)