diff --git a/LightQueryProfiler.sln b/LightQueryProfiler.sln index 7646b61..01e42a4 100644 --- a/LightQueryProfiler.sln +++ b/LightQueryProfiler.sln @@ -17,38 +17,100 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LightQueryProfiler.WinForms EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LightQueryProfiler.JsonRpc", "src\LightQueryProfiler.JsonRpc\LightQueryProfiler.JsonRpc.csproj", "{D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LightQueryProfiler.JsonRpc.Tests", "tests\LightQueryProfiler.JsonRpc.Tests\LightQueryProfiler.JsonRpc.Tests.csproj", "{E2F9G4D5-6C7B-5E0F-9G8D-0B2C3D4E5F6G}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LightQueryProfiler.JsonRpc.Tests", "tests\LightQueryProfiler.JsonRpc.Tests\LightQueryProfiler.JsonRpc.Tests.csproj", "{F0668DA6-676A-A14D-F4E5-C15C3F918501}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightQueryProfiler.WinFormsApp.UnitTests", "tests\LightQueryProfiler.WinFormsApp.UnitTests\LightQueryProfiler.WinFormsApp.UnitTests.csproj", "{0E9272F5-4B05-4836-9B89-894CCE50D3E3}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Debug|x64.Build.0 = Debug|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Debug|x86.Build.0 = Debug|Any CPU {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Release|Any CPU.ActiveCfg = Release|Any CPU {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Release|Any CPU.Build.0 = Release|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Release|x64.ActiveCfg = Release|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Release|x64.Build.0 = Release|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Release|x86.ActiveCfg = Release|Any CPU + {50A4667C-2F4A-4DF8-A88F-E5BE71DA7CDB}.Release|x86.Build.0 = Release|Any CPU {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Debug|x64.ActiveCfg = Debug|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Debug|x64.Build.0 = Debug|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Debug|x86.ActiveCfg = Debug|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Debug|x86.Build.0 = Debug|Any CPU {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Release|Any CPU.ActiveCfg = Release|Any CPU {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Release|Any CPU.Build.0 = Release|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Release|x64.ActiveCfg = Release|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Release|x64.Build.0 = Release|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Release|x86.ActiveCfg = Release|Any CPU + {50FB44D7-9878-447C-BA1B-829D2BC86B78}.Release|x86.Build.0 = Release|Any CPU {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Debug|x64.Build.0 = Debug|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Debug|x86.Build.0 = Debug|Any CPU {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Release|Any CPU.ActiveCfg = Release|Any CPU {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Release|Any CPU.Build.0 = Release|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Release|x64.ActiveCfg = Release|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Release|x64.Build.0 = Release|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Release|x86.ActiveCfg = Release|Any CPU + {BFF93236-CB9F-4780-9B52-C0DB3286451F}.Release|x86.Build.0 = Release|Any CPU {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Debug|x64.Build.0 = Debug|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Debug|x86.Build.0 = Debug|Any CPU {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Release|Any CPU.ActiveCfg = Release|Any CPU {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Release|Any CPU.Build.0 = Release|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Release|x64.ActiveCfg = Release|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Release|x64.Build.0 = Release|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Release|x86.ActiveCfg = Release|Any CPU + {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29}.Release|x86.Build.0 = Release|Any CPU {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Debug|x64.Build.0 = Debug|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Debug|x86.Build.0 = Debug|Any CPU {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU - {E2F9G4D5-6C7B-5E0F-9G8D-0B2C3D4E5F6G}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E2F9G4D5-6C7B-5E0F-9G8D-0B2C3D4E5F6G}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E2F9G4D5-6C7B-5E0F-9G8D-0B2C3D4E5F6G}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E2F9G4D5-6C7B-5E0F-9G8D-0B2C3D4E5F6G}.Release|Any CPU.Build.0 = Release|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Release|x64.ActiveCfg = Release|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Release|x64.Build.0 = Release|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Release|x86.ActiveCfg = Release|Any CPU + {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F}.Release|x86.Build.0 = Release|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Debug|x64.ActiveCfg = Debug|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Debug|x64.Build.0 = Debug|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Debug|x86.ActiveCfg = Debug|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Debug|x86.Build.0 = Debug|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Release|x64.ActiveCfg = Release|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Release|x64.Build.0 = Release|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Release|x86.ActiveCfg = Release|Any CPU + {F0668DA6-676A-A14D-F4E5-C15C3F918501}.Release|x86.Build.0 = Release|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Debug|x64.Build.0 = Debug|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Debug|x86.Build.0 = Debug|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Release|Any CPU.Build.0 = Release|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Release|x64.ActiveCfg = Release|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Release|x64.Build.0 = Release|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Release|x86.ActiveCfg = Release|Any CPU + {0E9272F5-4B05-4836-9B89-894CCE50D3E3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -59,7 +121,7 @@ Global {BFF93236-CB9F-4780-9B52-C0DB3286451F} = {51FC1EE8-5A57-4BD8-A682-0790A08C0FE7} {BA5AD370-10BB-40D7-8FD5-F7055DBA9D29} = {51FC1EE8-5A57-4BD8-A682-0790A08C0FE7} {D1E8F3C4-5B6A-4D9E-8F7C-9A1B2C3D4E5F} = {51FC1EE8-5A57-4BD8-A682-0790A08C0FE7} - {E2F9G4D5-6C7B-5E0F-9G8D-0B2C3D4E5F6G} = {66D515C8-C16C-4429-A47D-DB296B7DC486} + {0E9272F5-4B05-4836-9B89-894CCE50D3E3} = {66D515C8-C16C-4429-A47D-DB296B7DC486} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E833AB64-9548-4BE4-ACB5-2059821BDCDB} diff --git a/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs b/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs index 225fe0b..251d55f 100644 --- a/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs +++ b/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs @@ -1,6 +1,7 @@ -using LightQueryProfiler.Shared.Enums; +using LightQueryProfiler.Shared.Enums; using LightQueryProfiler.Shared.Models; using LightQueryProfiler.Shared.Repositories.Interfaces; +using LightQueryProfiler.Shared.Services.Interfaces; using Microsoft.Data.Sqlite; namespace LightQueryProfiler.Shared.Repositories @@ -8,17 +9,56 @@ namespace LightQueryProfiler.Shared.Repositories public class ConnectionRepository : IRepository { private readonly IDatabaseContext _context; + private readonly IPasswordProtectionService? _passwordProtectionService; + /// + /// Initializes the repository without password protection (plain-text storage). + /// Provided for backward compatibility with existing tests and non-Windows environments. + /// + /// The database context used to obtain connections. public ConnectionRepository(IDatabaseContext context) { + ArgumentNullException.ThrowIfNull(context); _context = context; } + /// + /// Initializes the repository with password protection. + /// Passwords are encrypted before storage and decrypted after retrieval. + /// + /// The database context used to obtain connections. + /// + /// Service that encrypts passwords on save and decrypts them on load. + /// Pass null to disable encryption (plain-text behaviour). + /// + public ConnectionRepository(IDatabaseContext context, IPasswordProtectionService? passwordProtectionService) + { + ArgumentNullException.ThrowIfNull(context); + _context = context; + _passwordProtectionService = passwordProtectionService; + } + + /// + /// Returns the encrypted representation of ready for persistence. + /// When no service is configured the original value is returned unchanged. + /// + private string? EncryptPassword(string? plainPassword) + => _passwordProtectionService?.Encrypt(plainPassword) ?? plainPassword; + + /// + /// Returns the decrypted plain-text password from the value stored in the database. + /// When no service is configured the original value is returned unchanged. + /// + private string? DecryptPassword(string? storedPassword) + => _passwordProtectionService?.Decrypt(storedPassword) ?? storedPassword; + public async Task AddAsync(Connection entity) { await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); + string? encryptedPassword = EncryptPassword(entity.Password); + // Try with EngineType column first try { @@ -29,7 +69,7 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", entity.UserId); - sqliteCommand.Parameters.AddWithValue("@Password", entity.Password); + sqliteCommand.Parameters.AddWithValue("@Password", encryptedPassword); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); @@ -48,7 +88,7 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", entity.UserId); - sqliteCommand.Parameters.AddWithValue("@Password", entity.Password); + sqliteCommand.Parameters.AddWithValue("@Password", encryptedPassword); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); @@ -64,7 +104,7 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", entity.UserId); - sqliteCommand.Parameters.AddWithValue("@Password", entity.Password); + sqliteCommand.Parameters.AddWithValue("@Password", encryptedPassword); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); await sqliteCommand.ExecuteNonQueryAsync(); @@ -96,35 +136,35 @@ public async Task Delete(int id) public async Task> GetAllAsync() { + // SELECT column ordinals: + // 0=Id, 1=InitialCatalog, 2=CreationDate, 3=DataSource, + // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode]] List connections = new List(); await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); - // Try with EngineType column first + // Try with AuthenticationMode column first try { const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); - int index; while (query.Read()) { - index = 0; var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); connections.Add(new Connection( - query.GetInt32(index++), - query.GetString(index++), - query.GetDateTime(index++), - query.GetString(index++), - query.GetBoolean(index++), - query.GetString(index++), - query.GetString(index++), - engineTypeValue, - authModeValue - ) - ); + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + engineTypeValue, + authModeValue)); } } catch (SqliteException) @@ -136,22 +176,19 @@ public async Task> GetAllAsync() await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); - int index; while (query.Read()) { - index = 0; var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); connections.Add(new Connection( - query.GetInt32(index++), - query.GetString(index++), - query.GetDateTime(index++), - query.GetString(index++), - query.GetBoolean(index++), - query.GetString(index++), - query.GetString(index++), - engineTypeValue - ) - ); + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + engineTypeValue)); } } catch (SqliteException) @@ -161,21 +198,18 @@ public async Task> GetAllAsync() await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); - int index; while (query.Read()) { - index = 0; + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); connections.Add(new Connection( - query.GetInt32(index++), - query.GetString(index++), - query.GetDateTime(index++), - query.GetString(index++), - query.GetBoolean(index++), - query.GetString(index++), - query.GetString(index++), - null - ) - ); + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + null)); } } } @@ -185,11 +219,14 @@ public async Task> GetAllAsync() public async Task GetByIdAsync(int id) { + // SELECT column ordinals: + // 0=Id, 1=InitialCatalog, 2=CreationDate, 3=DataSource, + // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode]] Connection? connection = null; await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); - // Try with EngineType column first + // Try with AuthenticationMode column first try { const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections WHERE Id = @Id"; @@ -197,21 +234,21 @@ public async Task GetByIdAsync(int id) sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); - int index; while (query.Read()) { - index = 0; var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); - connection = new Connection(query.GetInt32(index++), - query.GetString(index++), - query.GetDateTime(index++), - query.GetString(index++), - query.GetBoolean(index++), - query.GetString(index++), - query.GetString(index++), - engineTypeValue, - authModeValue); + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + connection = new Connection( + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + engineTypeValue, + authModeValue); } } catch (SqliteException) @@ -224,19 +261,19 @@ public async Task GetByIdAsync(int id) sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); - int index; while (query.Read()) { - index = 0; var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); - connection = new Connection(query.GetInt32(index++), - query.GetString(index++), - query.GetDateTime(index++), - query.GetString(index++), - query.GetBoolean(index++), - query.GetString(index++), - query.GetString(index++), - engineTypeValue); + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + connection = new Connection( + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + engineTypeValue); } } catch (SqliteException) @@ -247,18 +284,18 @@ public async Task GetByIdAsync(int id) sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); - int index; while (query.Read()) { - index = 0; - connection = new Connection(query.GetInt32(index++), - query.GetString(index++), - query.GetDateTime(index++), - query.GetString(index++), - query.GetBoolean(index++), - query.GetString(index++), - query.GetString(index++), - null); + var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + connection = new Connection( + query.GetInt32(0), + query.GetString(1), + query.GetDateTime(2), + query.GetString(3), + query.GetBoolean(4), + DecryptPassword(storedPassword), + query.IsDBNull(6) ? null : query.GetString(6), + null); } } } @@ -276,7 +313,9 @@ public async Task UpdateAsync(Connection entity) await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); - // Try with EngineType column first + string? encryptedPassword = EncryptPassword(entity.Password); + + // Try with AuthenticationMode column first try { const string sqlWithAuthMode = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode WHERE Id = @Id"; @@ -285,7 +324,7 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", entity.UserId); - sqliteCommand.Parameters.AddWithValue("@Password", entity.Password); + sqliteCommand.Parameters.AddWithValue("@Password", encryptedPassword); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); @@ -302,7 +341,7 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", entity.UserId); - sqliteCommand.Parameters.AddWithValue("@Password", entity.Password); + sqliteCommand.Parameters.AddWithValue("@Password", encryptedPassword); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); @@ -316,7 +355,7 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); sqliteCommand.Parameters.AddWithValue("@InitialCatalog", entity.InitialCatalog); sqliteCommand.Parameters.AddWithValue("@UserId", entity.UserId); - sqliteCommand.Parameters.AddWithValue("@Password", entity.Password); + sqliteCommand.Parameters.AddWithValue("@Password", encryptedPassword); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); await sqliteCommand.ExecuteNonQueryAsync(); } diff --git a/src/LightQueryProfiler.Shared/Services/Interfaces/IPasswordProtectionService.cs b/src/LightQueryProfiler.Shared/Services/Interfaces/IPasswordProtectionService.cs new file mode 100644 index 0000000..acc29cf --- /dev/null +++ b/src/LightQueryProfiler.Shared/Services/Interfaces/IPasswordProtectionService.cs @@ -0,0 +1,25 @@ +namespace LightQueryProfiler.Shared.Services.Interfaces +{ + /// + /// Provides methods to encrypt and decrypt sensitive data such as connection passwords. + /// + public interface IPasswordProtectionService + { + /// + /// Encrypts a plain-text password and returns a Base64-encoded cipher string. + /// Returns null or empty when is null or empty. + /// + /// The plain-text password to encrypt. + /// A Base64-encoded encrypted string, or the original value when it is null or empty. + string? Encrypt(string? plainText); + + /// + /// Decrypts a Base64-encoded cipher string and returns the original plain-text password. + /// Falls back to returning the original value when decryption fails (e.g. legacy plain-text entries). + /// Returns null or empty when is null or empty. + /// + /// The Base64-encoded encrypted password to decrypt. + /// The plain-text password, or the original value if decryption fails. + string? Decrypt(string? cipherText); + } +} diff --git a/src/LightQueryProfiler.WinFormsApp/LightQueryProfiler.WinFormsApp.csproj b/src/LightQueryProfiler.WinFormsApp/LightQueryProfiler.WinFormsApp.csproj index 444f2e8..fe80668 100644 --- a/src/LightQueryProfiler.WinFormsApp/LightQueryProfiler.WinFormsApp.csproj +++ b/src/LightQueryProfiler.WinFormsApp/LightQueryProfiler.WinFormsApp.csproj @@ -8,7 +8,7 @@ enable true LightQueryProfiler - 1.2.0-dev.4 + 1.2.0-dev.5 1.2.0 1.2.0 Icons\light-query-profiler.ico diff --git a/src/LightQueryProfiler.WinFormsApp/Presenters/MainPresenter.cs b/src/LightQueryProfiler.WinFormsApp/Presenters/MainPresenter.cs index 0772e52..4dbe7b1 100644 --- a/src/LightQueryProfiler.WinFormsApp/Presenters/MainPresenter.cs +++ b/src/LightQueryProfiler.WinFormsApp/Presenters/MainPresenter.cs @@ -11,6 +11,7 @@ using LightQueryProfiler.Shared.Services.Interfaces; using LightQueryProfiler.WinFormsApp.Data; using LightQueryProfiler.WinFormsApp.Helpers; +using LightQueryProfiler.WinFormsApp.Services; using LightQueryProfiler.WinFormsApp.Views; using Microsoft.Data.SqlClient; @@ -68,7 +69,7 @@ public MainPresenter(IMainView mainView) view.OnRecentConnectionsClick += OnRecentConnectionsClick; view.OnExportEvents += OnExportEvents; view.OnImportEvents += OnImportEvents; - _connectionRepository = new ConnectionRepository(new SqliteContext()); + _connectionRepository = new ConnectionRepository(new SqliteContext(), new DpapiPasswordProtectionService()); view.Show(); } diff --git a/src/LightQueryProfiler.WinFormsApp/Services/DpapiPasswordProtectionService.cs b/src/LightQueryProfiler.WinFormsApp/Services/DpapiPasswordProtectionService.cs new file mode 100644 index 0000000..5ac9a70 --- /dev/null +++ b/src/LightQueryProfiler.WinFormsApp/Services/DpapiPasswordProtectionService.cs @@ -0,0 +1,69 @@ +using LightQueryProfiler.Shared.Services.Interfaces; +using System.Security.Cryptography; +using System.Text; + +namespace LightQueryProfiler.WinFormsApp.Services +{ + /// + /// Encrypts and decrypts passwords using the Windows Data Protection API (DPAPI) + /// with scope. + /// + /// Encrypted values are stored as Base64 strings so they can be persisted in SQLite. + /// Decryption gracefully falls back to returning the original value when the input + /// was not encrypted (e.g. legacy plain-text entries stored before this fix). + /// + /// + public sealed class DpapiPasswordProtectionService : IPasswordProtectionService + { + /// + /// Encrypts a plain-text password using DPAPI and returns a Base64-encoded cipher string. + /// + /// The plain-text password to encrypt. + /// + /// A Base64-encoded encrypted string, or null / empty when + /// is null or empty. + /// + public string? Encrypt(string? plainText) + { + if (string.IsNullOrEmpty(plainText)) + { + return plainText; + } + + byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); + byte[] encryptedBytes = ProtectedData.Protect(plainBytes, null, DataProtectionScope.CurrentUser); + return Convert.ToBase64String(encryptedBytes); + } + + /// + /// Decrypts a Base64-encoded DPAPI cipher string and returns the original plain-text password. + /// + /// The Base64-encoded encrypted password to decrypt. + /// + /// The decrypted plain-text password. When decryption fails (e.g. the value is a + /// legacy plain-text entry), the original is returned + /// unchanged so that existing connections continue to work without data migration. + /// Returns null or empty when is null or empty. + /// + public string? Decrypt(string? cipherText) + { + if (string.IsNullOrEmpty(cipherText)) + { + return cipherText; + } + + try + { + byte[] encryptedBytes = Convert.FromBase64String(cipherText); + byte[] plainBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(plainBytes); + } + catch (Exception ex) when (ex is CryptographicException or FormatException) + { + // The value was not encrypted with DPAPI (legacy plain-text entry). + // Return the original value so existing connections remain usable. + return cipherText; + } + } + } +} diff --git a/tests/LightQueryProfiler.WinFormsApp.UnitTests/LightQueryProfiler.WinFormsApp.UnitTests.csproj b/tests/LightQueryProfiler.WinFormsApp.UnitTests/LightQueryProfiler.WinFormsApp.UnitTests.csproj new file mode 100644 index 0000000..c6f90d4 --- /dev/null +++ b/tests/LightQueryProfiler.WinFormsApp.UnitTests/LightQueryProfiler.WinFormsApp.UnitTests.csproj @@ -0,0 +1,39 @@ + + + + net10.0-windows + enable + enable + false + true + true + + + + + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/LightQueryProfiler.WinFormsApp.UnitTests/Services/DpapiPasswordProtectionServiceTests.cs b/tests/LightQueryProfiler.WinFormsApp.UnitTests/Services/DpapiPasswordProtectionServiceTests.cs new file mode 100644 index 0000000..c301ebd --- /dev/null +++ b/tests/LightQueryProfiler.WinFormsApp.UnitTests/Services/DpapiPasswordProtectionServiceTests.cs @@ -0,0 +1,152 @@ +using LightQueryProfiler.WinFormsApp.Services; + +namespace LightQueryProfiler.WinFormsApp.UnitTests.Services; + +/// +/// Unit tests for . +/// These tests require Windows (DPAPI is a Windows-only feature). +/// +public class DpapiPasswordProtectionServiceTests +{ + private readonly DpapiPasswordProtectionService _sut = new(); + + // ------------------------------------------------------------------------- + // Encrypt – boundary / guard cases + // ------------------------------------------------------------------------- + + [Fact] + public void Encrypt_WhenPlainTextIsNull_ReturnsNull() + { + // Act + var result = _sut.Encrypt(null); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Encrypt_WhenPlainTextIsEmpty_ReturnsEmpty() + { + // Act + var result = _sut.Encrypt(string.Empty); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Theory] + [InlineData("MySecretP@ssw0rd!")] + [InlineData("password123")] + [InlineData("p")] + [InlineData("unicode: áéíóú ñ 日本語")] + public void Encrypt_WhenPlainTextProvided_ReturnsDifferentValue(string plainText) + { + // Act + var result = _sut.Encrypt(plainText); + + // Assert + Assert.NotNull(result); + Assert.NotEqual(plainText, result); + } + + [Theory] + [InlineData("MySecretP@ssw0rd!")] + [InlineData("password123")] + [InlineData("p")] + [InlineData("unicode: áéíóú ñ 日本語")] + public void Encrypt_WhenPlainTextProvided_ReturnsValidBase64(string plainText) + { + // Act + var result = _sut.Encrypt(plainText); + + // Assert – should not throw + Assert.NotNull(result); + var decoded = Convert.FromBase64String(result); + Assert.NotEmpty(decoded); + } + + // ------------------------------------------------------------------------- + // Decrypt – boundary / guard cases + // ------------------------------------------------------------------------- + + [Fact] + public void Decrypt_WhenCipherTextIsNull_ReturnsNull() + { + // Act + var result = _sut.Decrypt(null); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Decrypt_WhenCipherTextIsEmpty_ReturnsEmpty() + { + // Act + var result = _sut.Decrypt(string.Empty); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Theory] + [InlineData("not-base64!!")] + [InlineData("plaintext")] + [InlineData("some old legacy password stored before encryption was added")] + public void Decrypt_WhenValueIsLegacyPlainText_ReturnsSameValue(string legacyValue) + { + // Arrange – legacy plain-text values that were never encrypted with DPAPI + // Act + var result = _sut.Decrypt(legacyValue); + + // Assert – fallback: original value returned unchanged + Assert.Equal(legacyValue, result); + } + + // ------------------------------------------------------------------------- + // Round-trip: Encrypt → Decrypt + // ------------------------------------------------------------------------- + + [Theory] + [InlineData("MySecretP@ssw0rd!")] + [InlineData("password123")] + [InlineData("p")] + [InlineData("unicode: áéíóú ñ 日本語")] + public void Encrypt_ThenDecrypt_ReturnsOriginalPassword(string originalPassword) + { + // Act + var encrypted = _sut.Encrypt(originalPassword); + var decrypted = _sut.Decrypt(encrypted); + + // Assert + Assert.Equal(originalPassword, decrypted); + } + + [Fact] + public void Encrypt_ThenDecrypt_WhenPasswordHasSpecialSqlCharacters_ReturnsOriginalPassword() + { + // Arrange – passwords with SQL-sensitive characters that could cause issues if stored unescaped + const string password = "P@ss'; DROP TABLE Connections;--"; + + // Act + var encrypted = _sut.Encrypt(password); + var decrypted = _sut.Decrypt(encrypted); + + // Assert + Assert.Equal(password, decrypted); + } + + [Fact] + public void Encrypt_CalledTwiceWithSameInput_ReturnsDifferentCipherTexts() + { + // DPAPI uses random salt, so each call produces a unique cipher text + const string password = "MySecretP@ssw0rd!"; + + // Act + var first = _sut.Encrypt(password); + var second = _sut.Encrypt(password); + + // Assert + Assert.NotEqual(first, second); + } +}