diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index a6d7812..e648292 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// MongoDB connection configuration and state. @@ -10,42 +11,54 @@ class MongoConnection { required this.host, this.port = 27017, this.username, - this.password, + String? password, this.database, this.authSource, this.useSSL = false, this.replicaSet, - this.connectionString, - }); + String? connectionString, + }) : _password = password, + _connectionString = connectionString; final int id; final String name; final String host; final int port; final String? username; - final String? password; + String? _password; final String? database; final String? authSource; final bool useSSL; final String? replicaSet; - final String? connectionString; + String? _connectionString; + + String? get password => _password; + String? get connectionString => _connectionString; Db? _db; bool _isConnected = false; + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } + /// Builds MongoDB connection URI from configuration. - String buildConnectionUri() { - if (connectionString != null && connectionString!.isNotEmpty) { - return connectionString!; + String buildConnectionUri({String? pass, String? connStr}) { + final effectiveConnStr = connStr ?? _connectionString; + if (effectiveConnStr != null && effectiveConnStr.isNotEmpty) { + return effectiveConnStr; } final buffer = StringBuffer('mongodb://'); // Add authentication if provided + final effectivePass = pass ?? _password; if (username != null && username!.isNotEmpty) { buffer.write(Uri.encodeComponent(username!)); - if (password != null && password!.isNotEmpty) { - buffer.write(':${Uri.encodeComponent(password!)}'); + if (effectivePass != null && effectivePass.isNotEmpty) { + buffer.write(':${Uri.encodeComponent(effectivePass)}'); } buffer.write('@'); } @@ -86,8 +99,8 @@ class MongoConnection { /// exists, the method automatically adds `authSource=` (defaults /// to `admin`) so that authentication succeeds on databases other than the /// one the user was created in. - String buildUriForDatabase(String databaseName) { - final baseUri = buildConnectionUri(); + String buildUriForDatabase(String databaseName, {String? pass, String? connStr}) { + final baseUri = buildConnectionUri(pass: pass, connStr: connStr); final uri = Uri.parse(baseUri); // Determine the authSource that should be used. @@ -120,11 +133,28 @@ class MongoConnection { return; } + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + try { - final uri = await _effectiveMongoUri(); + final uri = await _effectiveMongoUri( + pass: effectivePassword, + connStr: effectiveConnectionString, + ); _db = await Db.create(uri); await _db!.open(); _isConnected = true; + scrubCredentials(); } catch (e) { _isConnected = false; _db = null; @@ -132,8 +162,8 @@ class MongoConnection { } } - Future _effectiveMongoUri() async { - final base = buildConnectionUri(); + Future _effectiveMongoUri({String? pass, String? connStr}) async { + final base = buildConnectionUri(pass: pass, connStr: connStr); final parsed = Uri.parse(base); final paths = extractSslCertificatePaths(parsed); final params = Map.from(parsed.queryParameters); diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 0eb63f6..d974bf0 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/database/table_schema_meta.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; @@ -38,11 +39,12 @@ class MysqlConnection { required this.host, this.port = 3306, this.username, - this.password, + String? password, this.database, this.useSSL = true, - this.connectionString, - }); + String? connectionString, + }) : _password = password, + _connectionString = connectionString; factory MysqlConnection.fromConnectionRow( ConnectionRow row, { @@ -66,20 +68,27 @@ class MysqlConnection { final String host; final int port; final String? username; - final String? password; + String? _password; final String? database; final bool useSSL; - final String? connectionString; + String? _connectionString; + + String? get password => _password; + String? get connectionString => _connectionString; MySQLConnection? _conn; bool _isConnected = false; bool get isConnected => _isConnected && _conn != null; - bool get _usesConnectionString => - connectionString != null && connectionString!.trim().isNotEmpty; - + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } + bool _usesConnectionString(String? connStr) => + connStr != null && connStr.trim().isNotEmpty; /// MySQL identifier quoting (backticks). static String quoteIdentifier(String id) { @@ -88,17 +97,31 @@ class MysqlConnection { Future connect({int connectTimeoutMs = 10000}) async { if (_isConnected && _conn != null) return; + + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + try { final user = username ?? ''; - final pass = password ?? ''; - if (_usesConnectionString) { + final pass = effectivePassword ?? ''; + if (_usesConnectionString(effectiveConnectionString)) { final dbName = database; final uriStr = dbName != null && dbName.isNotEmpty ? replaceDatabaseInMysqlConnectionString( - connectionString!.trim(), + effectiveConnectionString!.trim(), dbName, ) - : connectionString!.trim(); + : effectiveConnectionString!.trim(); final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); final sslPaths = extractSslCertificatePathsFromString(uriStr); final securityContext = buildSecurityContext(sslPaths); @@ -113,21 +136,21 @@ class MysqlConnection { ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { - final securityContext = buildSecurityContext( - extractSslCertificatePathsFromString(connectionString), - ); + final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); + final securityContext = buildSecurityContext(sslPaths); _conn = await MySQLConnection.createConnection( host: host, port: port, userName: user, password: pass, - secure: useSSL, + secure: useSSL || sslPaths.hasAny, databaseName: database, securityContext: securityContext, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } _isConnected = true; + scrubCredentials(); } catch (e) { _isConnected = false; _conn = null; diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index bd80717..3a79f06 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -3,6 +3,7 @@ import 'dart:io' show SecurityContext; import 'package:flutter/foundation.dart'; import 'package:postgres/postgres.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; // ignore: implementation_imports @@ -44,14 +45,15 @@ class PostgresConnection { required this.host, this.port = 5432, this.username, - this.password, + String? password, this.database, this.useSSL = false, - this.connectionString, + String? connectionString, this.sslRootCert, this.sslCert, this.sslKey, - }); + }) : _password = password, + _connectionString = connectionString; /// Builds a connection from a saved [ConnectionRow] (host/port or URI). factory PostgresConnection.fromConnectionRow( @@ -91,29 +93,38 @@ class PostgresConnection { final String host; final int port; final String? username; - final String? password; + String? _password; final String? database; final bool useSSL; - final String? connectionString; + String? _connectionString; final String? sslRootCert; final String? sslCert; final String? sslKey; + String? get password => _password; + String? get connectionString => _connectionString; + Connection? _conn; bool _isConnected = false; bool get isConnected => _isConnected && _conn != null; - bool get _usesConnectionString => - connectionString != null && connectionString!.trim().isNotEmpty; + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } - Endpoint _buildEndpoint() { + bool _usesConnectionString(String? connStr) => + connStr != null && connStr.trim().isNotEmpty; + + Endpoint _buildEndpoint({String? pass}) { return Endpoint( host: host, port: port, database: database ?? 'postgres', username: username, - password: password, + password: pass ?? _password, ); } @@ -148,14 +159,28 @@ class PostgresConnection { /// form checkbox still applies; otherwise libpq-style URLs drive TLS mode. Future connect() async { if (_isConnected && _conn != null) return; + + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + try { - if (_usesConnectionString) { + if (_usesConnectionString(effectiveConnectionString)) { // Pool passes target catalog via [database]; URI alone would always open // the DB embedded in the string — every tree branch then queried the // same database (duplicate tables under finance / logistics, etc.). final dbName = database ?? 'postgres'; final uriForOpen = replaceDatabaseInConnectionString( - connectionString!.trim(), + effectiveConnectionString!.trim(), dbName, ); final parsed = parseConnectionString(uriForOpen); @@ -176,11 +201,12 @@ class PostgresConnection { ); } else { _conn = await Connection.open( - _buildEndpoint(), + _buildEndpoint(pass: effectivePassword), settings: _buildSettings(), ); } _isConnected = true; + scrubCredentials(); } catch (e, st) { _isConnected = false; _conn = null; diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index f7f8fbe..da4064b 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:redis/redis.dart' as redis; @@ -13,10 +14,11 @@ class RedisConnection { required this.host, this.port = 6379, this.username, - this.password, + String? password, this.useSSL = false, - this.connectionString, - }); + String? connectionString, + }) : _password = password, + _connectionString = connectionString; factory RedisConnection.fromConnectionRow(ConnectionRow row) { final uriText = row.connectionString?.trim(); @@ -62,9 +64,12 @@ class RedisConnection { final String host; final int port; final String? username; - final String? password; + String? _password; final bool useSSL; - final String? connectionString; + String? _connectionString; + + String? get password => _password; + String? get connectionString => _connectionString; redis.RedisConnection? _conn; redis.Command? _command; @@ -72,10 +77,31 @@ class RedisConnection { bool get isConnected => _isConnected && _command != null; + /// Scrubs sensitive in-memory credentials once the network handshake completes. + void scrubCredentials() { + _password = null; + _connectionString = null; + } + Future connect() async { if (_isConnected && _command != null) return; + + var effectivePassword = _password; + var effectiveConnectionString = _connectionString; + + if ((effectivePassword == null || effectivePassword.isEmpty) && + (effectiveConnectionString == null || effectiveConnectionString.isEmpty) && + id > 0) { + try { + final secrets = await ConnectionSecretsStore.readForConnection(id); + effectivePassword = secrets.password; + effectiveConnectionString = secrets.connectionString; + } catch (_) {} + } + _conn = redis.RedisConnection(); - final sslPaths = extractSslCertificatePathsFromString(connectionString); + final sslPaths = + extractSslCertificatePathsFromString(effectiveConnectionString); final secure = useSSL || sslPaths.hasAny; if (secure) { final context = buildSecurityContext(sslPaths); @@ -88,11 +114,11 @@ class RedisConnection { } else { _command = await _conn!.connect(host, port); } - if (password != null && password!.isNotEmpty) { + if (effectivePassword != null && effectivePassword.isNotEmpty) { if (username != null && username!.trim().isNotEmpty) { - await _command!.send_object(['AUTH', username!.trim(), password!]); + await _command!.send_object(['AUTH', username!.trim(), effectivePassword]); } else { - await _command!.send_object(['AUTH', password]); + await _command!.send_object(['AUTH', effectivePassword]); } } final result = await _command!.send_object(['PING']); @@ -103,6 +129,7 @@ class RedisConnection { throw RedisConnectionException('PING failed'); } _isConnected = true; + scrubCredentials(); } Future disconnect() async { diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 2ce7806..c611c62 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -709,4 +709,21 @@ class ConnectionRow { createdAt: createdAt ?? this.createdAt, ); } + + /// Whether in-memory password credentials are set. + bool get hasPassword => password != null && password!.isNotEmpty; + + /// Whether in-memory connection URI credentials are set. + bool get hasConnectionString => + connectionString != null && connectionString!.isNotEmpty; + + /// Whether any in-memory secret credentials are held. + bool get hasSecrets => hasPassword || hasConnectionString; + + /// Returns a clean copy of this [ConnectionRow] with all secret credentials + /// ([password] and [connectionString]) scrubbed to null. + ConnectionRow withoutSecrets() => copyWith( + clearPassword: true, + clearConnectionString: true, + ); } diff --git a/test/core/storage/secret_scrubbing_test.dart b/test/core/storage/secret_scrubbing_test.dart new file mode 100644 index 0000000..65c3681 --- /dev/null +++ b/test/core/storage/secret_scrubbing_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +void main() { + group('ConnectionRow secret scrubbing', () { + test('withoutSecrets clears password and connectionString while keeping all metadata', () { + const row = ConnectionRow( + id: 42, + type: 'postgresql', + name: 'Production DB', + host: 'db.example.com', + port: 5432, + username: 'admin', + password: 'super_secret_password_123', + databaseName: 'customers', + authSource: 'admin', + useSSL: true, + connectionString: 'postgresql://admin:super_secret_password_123@db.example.com/customers', + folderId: 3, + sortOrder: 1, + createdAt: '2026-08-26T12:00:00Z', + ); + + expect(row.hasPassword, isTrue); + expect(row.hasConnectionString, isTrue); + expect(row.hasSecrets, isTrue); + + final scrubbed = row.withoutSecrets(); + + expect(scrubbed.id, 42); + expect(scrubbed.type, 'postgresql'); + expect(scrubbed.name, 'Production DB'); + expect(scrubbed.host, 'db.example.com'); + expect(scrubbed.port, 5432); + expect(scrubbed.username, 'admin'); + expect(scrubbed.databaseName, 'customers'); + expect(scrubbed.authSource, 'admin'); + expect(scrubbed.useSSL, isTrue); + expect(scrubbed.folderId, 3); + expect(scrubbed.sortOrder, 1); + expect(scrubbed.createdAt, '2026-08-26T12:00:00Z'); + + // Secrets must be null + expect(scrubbed.password, isNull); + expect(scrubbed.connectionString, isNull); + expect(scrubbed.hasPassword, isFalse); + expect(scrubbed.hasConnectionString, isFalse); + expect(scrubbed.hasSecrets, isFalse); + }); + + test('toPersistenceMap does not include plaintext secrets for SQLite storage', () { + const row = ConnectionRow( + id: 1, + type: 'mysql', + name: 'App MySQL', + host: '127.0.0.1', + port: 3306, + username: 'root', + password: 'secret_root_password', + databaseName: 'app', + connectionString: 'mysql://root:secret_root_password@127.0.0.1:3306/app', + createdAt: '2026-08-26T12:00:00Z', + ); + + final map = row.toPersistenceMap(); + expect(map['password'], isNull); + expect(map['connection_string'], isNull); + expect(map['name'], 'App MySQL'); + expect(map['username'], 'root'); + }); + }); + + group('Database driver connection secret scrubbing', () { + test('PostgresConnection.scrubCredentials zeroes password and connectionString', () { + final conn = PostgresConnection( + id: 10, + name: 'PG Connection', + host: 'localhost', + port: 5432, + username: 'postgres', + password: 'secret_pg_password', + connectionString: 'postgresql://postgres:secret_pg_password@localhost/postgres', + ); + + expect(conn.password, 'secret_pg_password'); + expect(conn.connectionString, contains('secret_pg_password')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + + test('MysqlConnection.scrubCredentials zeroes password and connectionString', () { + final conn = MysqlConnection( + id: 11, + name: 'MySQL Connection', + host: 'localhost', + port: 3306, + username: 'user', + password: 'secret_mysql_password', + connectionString: 'mysql://user:secret_mysql_password@localhost/app', + ); + + expect(conn.password, 'secret_mysql_password'); + expect(conn.connectionString, contains('secret_mysql_password')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + + test('RedisConnection.scrubCredentials zeroes password and connectionString', () { + final conn = RedisConnection( + id: 12, + name: 'Redis Connection', + host: 'localhost', + port: 6379, + password: 'secret_redis_auth', + connectionString: 'redis://:secret_redis_auth@localhost:6379', + ); + + expect(conn.password, 'secret_redis_auth'); + expect(conn.connectionString, contains('secret_redis_auth')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + + test('MongoConnection.scrubCredentials zeroes password and connectionString', () { + final conn = MongoConnection( + id: 13, + name: 'Mongo Connection', + host: 'localhost', + port: 27017, + username: 'mongo_user', + password: 'secret_mongo_password', + connectionString: 'mongodb://mongo_user:secret_mongo_password@localhost:27017/admin', + ); + + expect(conn.password, 'secret_mongo_password'); + expect(conn.connectionString, contains('secret_mongo_password')); + + conn.scrubCredentials(); + + expect(conn.password, isNull); + expect(conn.connectionString, isNull); + }); + }); +}