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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/core/database/database_error_mapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:querya_desktop/core/database/querya_database_exception.dart';
import 'package:querya_desktop/core/database/redis_connection.dart';
import 'package:querya_desktop/core/database/sqlite_connection.dart';
import 'package:querya_desktop/core/database/statement_queue.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';

/// Which engine produced an error; only used to tailor the wording.
enum DatabaseDriver { postgres, mysql, sqlite, mongodb, redis, extension }
Expand All @@ -21,6 +22,16 @@ QueryaDatabaseException mapDatabaseError(
DatabaseDriver? driver,
}) {
if (error is QueryaDatabaseException) return error;
if (error is SecretsStoreUnavailableException) {
// Not a wrong password: the saved one could not be read at all (#1303).
return UnknownDatabaseException(
SecretsStoreUnavailableException.message,
detailedExplanation: error.cause.toString(),
remediationHint: SecretsStoreUnavailableException.hint,
originalError: error,
stackTrace: stackTrace,
);
}

final raw = _fullText(error);
final lower = raw.toLowerCase();
Expand Down
10 changes: 5 additions & 5 deletions lib/core/database/mongodb_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,11 @@ class MongoConnection {
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 (_) {}
// A store that cannot be read throws (#1303): connecting with no password
// would be reported as a wrong one.
final secrets = await ConnectionSecretsStore.readForConnection(id);
effectivePassword = secrets.password;
effectiveConnectionString = secrets.connectionString;
}

try {
Expand Down
10 changes: 5 additions & 5 deletions lib/core/database/mysql_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,11 @@ class MysqlConnection {
(effectiveConnectionString == null ||
effectiveConnectionString.isEmpty) &&
id > 0) {
try {
final secrets = await ConnectionSecretsStore.readForConnection(id);
effectivePassword = secrets.password;
effectiveConnectionString = secrets.connectionString;
} catch (_) {}
// A store that cannot be read throws (#1303): connecting with no password
// would be reported as a wrong one.
final secrets = await ConnectionSecretsStore.readForConnection(id);
effectivePassword = secrets.password;
effectiveConnectionString = secrets.connectionString;
}

try {
Expand Down
10 changes: 5 additions & 5 deletions lib/core/database/postgres_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,11 @@ class PostgresConnection {
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 (_) {}
// A store that cannot be read throws (#1303): connecting with no password
// would be reported as a wrong one.
final secrets = await ConnectionSecretsStore.readForConnection(id);
effectivePassword = secrets.password;
effectiveConnectionString = secrets.connectionString;
}

try {
Expand Down
10 changes: 5 additions & 5 deletions lib/core/database/redis_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ class RedisConnection {
(effectiveConnectionString == null ||
effectiveConnectionString.isEmpty) &&
id > 0) {
try {
final secrets = await ConnectionSecretsStore.readForConnection(id);
effectivePassword = secrets.password;
effectiveConnectionString = secrets.connectionString;
} catch (_) {}
// A store that cannot be read throws (#1303): connecting with no password
// would be reported as a wrong one.
final secrets = await ConnectionSecretsStore.readForConnection(id);
effectivePassword = secrets.password;
effectiveConnectionString = secrets.connectionString;
}

_conn = redis.RedisConnection();
Expand Down
151 changes: 93 additions & 58 deletions lib/core/storage/connection_secrets_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@ class _FlutterSecureStorageBackend implements SecretsStorageBackend {
Future<void> delete(String key) => _storage.delete(key: key);
}

/// The system secret store (Keychain / Credential Manager / libsecret) did not
/// answer: it is locked, has no running secret service, or failed.
///
/// Distinct from "there is no such secret", which reads as null: a password
/// that could not be read must not be taken for a connection without one
/// (#1303).
class SecretsStoreUnavailableException implements Exception {
SecretsStoreUnavailableException(this.cause);

/// What the platform store threw.
final Object cause;

static const String message =
'Could not read the saved credentials from the system keyring '
'(Keychain / Credential Manager / libsecret).';

/// What to do about it, for single-line surfaces.
static const String hint =
'Unlock the keyring or start the secret service, then try again';

@override
String toString() => '$message $hint ($cause)';
}

/// Stores connection passwords and connection strings outside of the SQLite file
/// using the platform secure store (Keychain / Credential Manager / libsecret).
class ConnectionSecretsStore {
Expand Down Expand Up @@ -117,84 +141,95 @@ class ConnectionSecretsStore {
String? passphrase,
String? jumpPassword,
})> readSshSecretsForConnection(int connectionId) async {
final String passwordKey, privateKeyKey, passphraseKey, jumpKey;
try {
final password = await backend.read(_sshPasswordKey(connectionId));
final privateKey = await backend.read(_sshPrivateKeyKey(connectionId));
final passphrase = await backend.read(_sshPassphraseKey(connectionId));
final jumpPassword =
await backend.read(_sshJumpPasswordKey(connectionId));
return (
password: password,
privateKey: privateKey,
passphrase: passphrase,
jumpPassword: jumpPassword,
);
} catch (_) {
passwordKey = _sshPasswordKey(connectionId);
privateKeyKey = _sshPrivateKeyKey(connectionId);
passphraseKey = _sshPassphraseKey(connectionId);
jumpKey = _sshJumpPasswordKey(connectionId);
} on StateError {
return (
password: null,
privateKey: null,
passphrase: null,
jumpPassword: null,
);
}
try {
return (
password: await backend.read(passwordKey),
privateKey: await backend.read(privateKeyKey),
passphrase: await backend.read(passphraseKey),
jumpPassword: await backend.read(jumpKey),
);
} catch (e) {
throw SecretsStoreUnavailableException(e);
}
}

static Future<({String? password, String? connectionString})>
readForConnection(
int connectionId,
) async {
final String passwordKey, connectionStringKey;
try {
var password = await backend.read(_passwordKey(connectionId));
var connectionString =
await backend.read(_connectionStringKey(connectionId));

// Fallback: If either secret wasn't found under the namespaced key,
// lazily check the legacy unnamespaced key (#1008). This guards against
// transient keyring failures during the one-time DB upgrade migration,
// and writes through to adopt the legacy secret immediately.
if (password == null || connectionString == null) {
try {
String? legacyPassword;
String? legacyConnectionString;
if (password == null) {
legacyPassword =
await backend.read(_legacyPasswordKey(connectionId));
}
if (connectionString == null) {
legacyConnectionString =
await backend.read(_legacyConnectionStringKey(connectionId));
passwordKey = _passwordKey(connectionId);
connectionStringKey = _connectionStringKey(connectionId);
} on StateError {
// No profile yet (nothing was ever stored): same as no secrets.
return (password: null, connectionString: null);
}
String? password;
String? connectionString;
try {
password = await backend.read(passwordKey);
connectionString = await backend.read(connectionStringKey);
} catch (e) {
throw SecretsStoreUnavailableException(e);
}
// Fallback: If either secret wasn't found under the namespaced key,
// lazily check the legacy unnamespaced key (#1008). This guards against
// transient keyring failures during the one-time DB upgrade migration,
// and writes through to adopt the legacy secret immediately.
if (password == null || connectionString == null) {
try {
String? legacyPassword;
String? legacyConnectionString;
if (password == null) {
legacyPassword =
await backend.read(_legacyPasswordKey(connectionId));
}
if (connectionString == null) {
legacyConnectionString =
await backend.read(_legacyConnectionStringKey(connectionId));
}
if (legacyPassword != null || legacyConnectionString != null) {
final effectivePassword = password ?? legacyPassword;
final effectiveConnectionString =
connectionString ?? legacyConnectionString;
await writeForConnection(
connectionId,
password: effectivePassword,
connectionString: effectiveConnectionString,
);
if (legacyPassword != null) {
try {
await backend.delete(_legacyPasswordKey(connectionId));
} catch (_) {}
}
if (legacyPassword != null || legacyConnectionString != null) {
final effectivePassword = password ?? legacyPassword;
final effectiveConnectionString =
connectionString ?? legacyConnectionString;
await writeForConnection(
connectionId,
password: effectivePassword,
connectionString: effectiveConnectionString,
);
if (legacyPassword != null) {
try {
await backend.delete(_legacyPasswordKey(connectionId));
} catch (_) {}
}
if (legacyConnectionString != null) {
try {
await backend.delete(_legacyConnectionStringKey(connectionId));
} catch (_) {}
}
password = effectivePassword;
connectionString = effectiveConnectionString;
if (legacyConnectionString != null) {
try {
await backend.delete(_legacyConnectionStringKey(connectionId));
} catch (_) {}
}
} catch (_) {
// Best-effort lazy adoption; return whatever was already read.
password = effectivePassword;
connectionString = effectiveConnectionString;
}
} catch (_) {
// Best-effort lazy adoption; return whatever was already read.
}

return (password: password, connectionString: connectionString);
} catch (_) {
return (password: null, connectionString: null);
}
return (password: password, connectionString: connectionString);
}

static Future<void> deleteForConnection(int connectionId) async {
Expand Down
17 changes: 15 additions & 2 deletions lib/features/connections/connections_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import 'package:querya_desktop/core/extensions/extension_driver_session.dart';
import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
import 'package:querya_desktop/features/connections/sdui_tree_builder.dart';
import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';
import 'package:querya_desktop/core/storage/folders_storage.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/core/theme/querya_typography.dart';
Expand Down Expand Up @@ -719,8 +720,20 @@ class ConnectionsPanelState extends State<ConnectionsPanel> {
Future<void> _editConnection(ConnectionRow conn) async {
final edited = await promptEditConnection(context, conn);
if (edited == null || !mounted) return;
final toSave = await mergeSecretsForConnectionUpdate(edited);
await LocalDb.instance.updateConnection(toSave);
try {
final toSave = await mergeSecretsForConnectionUpdate(edited);
await LocalDb.instance.updateConnection(toSave);
} on SecretsStoreUnavailableException catch (e) {
// Saving would write the secrets it could not read as empty ones.
if (mounted) {
showAppToast(
context: context,
variant: AppToastVariant.error,
message: 'The connection was not saved. ${e.toString()}',
);
}
return;
}
await _loadData();
if (!mounted) return;
ConnectionRow? updated;
Expand Down
68 changes: 68 additions & 0 deletions test/core/database/secrets_unavailable_connect_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/core/database/database_error_mapper.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/querya_database_exception.dart';
import 'package:querya_desktop/core/database/redis_connection.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';

import '../../memory_secrets_backend.dart';

/// #1303: a keyring that cannot be read is not "no password".
void main() {
setUp(() {
ConnectionSecretsStore.profileId = 'test-profile';
testMemorySecrets.clear();
});

final unreadable = isA<SecretsStoreUnavailableException>();

group('connect with a saved connection whose secrets cannot be read', () {
test('PostgreSQL', () async {
final c = PostgresConnection(
id: 3, name: 'pg', host: 'localhost', port: 5432);
testMemorySecrets.failNextRead = Exception('keyring is locked');
await expectLater(c.connect(), throwsA(unreadable));
expect(c.isConnected, isFalse);
});

test('MySQL', () async {
final c = MysqlConnection(
id: 3, name: 'my', host: 'localhost', port: 3306);
testMemorySecrets.failNextRead = Exception('keyring is locked');
await expectLater(c.connect(), throwsA(unreadable));
});

test('Redis', () async {
final c = RedisConnection(id: 3, name: 'r', host: 'localhost');
testMemorySecrets.failNextRead = Exception('keyring is locked');
await expectLater(c.connect(), throwsA(unreadable));
});

test('MongoDB', () async {
final c = MongoConnection(
id: 3, name: 'mo', host: 'localhost', port: 27017);
testMemorySecrets.failNextRead = Exception('keyring is locked');
await expectLater(c.connect(), throwsA(unreadable));
});
});

group('mapDatabaseError', () {
test('an unreadable store is not an authentication failure', () {
// Even when the platform's own text sounds like one.
final mapped = mapDatabaseError(SecretsStoreUnavailableException(
Exception('libsecret: authentication failed')));
expect(mapped, isNot(isA<AuthFailedException>()));
expect(mapped.message, contains('system keyring'));
expect(mapped.remediationHint, contains('Unlock the keyring'));
});

test('its one-line text names the keyring, not the credentials', () {
final text = describeDatabaseError(SecretsStoreUnavailableException(
Exception('The collection is locked')));
expect(text, contains('keyring'));
expect(text.toLowerCase(), isNot(contains('check the username')));
});
});
}
Loading
Loading