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
37 changes: 36 additions & 1 deletion 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/security/ssh_tunnel_manager.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';

/// Which engine produced an error; only used to tailor the wording.
Expand All @@ -33,6 +34,38 @@ QueryaDatabaseException mapDatabaseError(
);
}

// The tunnel's own failures, before any database text matching: *SSH
// authentication failed for deploy@bastion* is not the database rejecting
// the credentials (#1305).
if (error is SshAuthenticationException) {
return UnknownDatabaseException(
error.message,
remediationHint: 'Check the SSH user, password or key and the bastion '
'host in the connection\'s SSH tunnel settings',
originalError: error,
stackTrace: stackTrace,
);
}
if (error is SshHostKeyMismatchException) {
return UnknownDatabaseException(
error.message,
remediationHint: 'The server presented a different host key than the '
'pinned one. If the server was reinstalled, update the fingerprint '
'in the SSH tunnel settings; otherwise do not connect',
originalError: error,
stackTrace: stackTrace,
);
}
if (error is SshConnectionException) {
return HostUnreachableException(
error.message,
remediationHint: 'Check the SSH host and port, the network and any '
'firewall',
originalError: error,
stackTrace: stackTrace,
);
}

final raw = _fullText(error);
final lower = raw.toLowerCase();
final engine = _engineName(driver);
Expand Down Expand Up @@ -232,7 +265,9 @@ QueryaDatabaseException mapDatabaseError(
/// place of `error.toString()` without losing information.
String describeDatabaseError(Object error, {DatabaseDriver? driver}) {
final mapped = mapDatabaseError(error, driver: driver);
if (mapped is UnknownDatabaseException) return error.toString();
if (mapped is UnknownDatabaseException && mapped.remediationHint == null) {
return error.toString();
}
return mapped.displayText;
}

Expand Down
75 changes: 66 additions & 9 deletions lib/core/security/ssh_tunnel_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,40 @@ class SshTunnelManager {
return bare(text) == observedBase64;
}

/// Opens the transport to [host]:[port]; a failure names the host, so it is
/// not mistaken for a database problem (#1305).
Future<SSHSocket> _dial(
String host,
int port,
Duration timeout, {
required String what,
}) async {
try {
return await _connect(host, port, timeout: timeout);
} catch (e) {
throw SshConnectionException('Cannot reach the $what $host:$port: $e');
}
}

/// What a failed SSH login or handshake is reported as: a rejected
/// credential is an authentication failure; anything else (a reset, a
/// timeout, a protocol error) is a connection failure, not a wrong password.
static Object _sshFailure(Object e, String user, String host, int port) {
if (e is SshAuthenticationException ||
e is SshHostKeyMismatchException ||
e is SshConnectionException) {
return e;
}
if (e is SSHAuthError) {
return SshAuthenticationException(
'SSH authentication failed for ${user.trim()}@${host.trim()}: $e',
);
}
return SshConnectionException(
'The SSH connection to ${host.trim()}:$port failed: $e',
);
}

/// Establishes or reuses an ephemeral local port forwarding tunnel.
Future<SshTunnelHandle> openTunnel({
required SshTunnelConfig config,
Expand Down Expand Up @@ -224,10 +258,11 @@ class SshTunnelManager {
final jumpPort = config.jumpPort ?? 22;
final jumpUser = config.jumpUsername ?? config.username;

final rawJumpSocket = await _connect(
final rawJumpSocket = await _dial(
jumpHost,
jumpPort,
timeout: Duration(seconds: config.connectTimeoutSeconds),
Duration(seconds: config.connectTimeoutSeconds),
what: 'SSH jump host',
);

jumpClient = _buildClient(
Expand All @@ -236,15 +271,30 @@ class SshTunnelManager {
onPasswordRequest: () =>
secrets.jumpPassword ?? secrets.password ?? '',
);
await jumpClient.authenticated;
try {
await jumpClient.authenticated;
} catch (e) {
unawaited(jumpClient.close());
throw _sshFailure(e, jumpUser, jumpHost, jumpPort);
}

// Forward through jump host to target bastion (returns SSHForwardChannel which implements SSHSocket)
bastionSocket = await jumpClient.forwardLocal(config.host.trim(), config.port);
try {
bastionSocket =
await jumpClient.forwardLocal(config.host.trim(), config.port);
} catch (e) {
unawaited(jumpClient.close());
throw SshConnectionException(
'The SSH jump host $jumpHost:$jumpPort could not reach the bastion '
'${config.host.trim()}:${config.port}: $e',
);
}
} else {
bastionSocket = await _connect(
bastionSocket = await _dial(
config.host.trim(),
config.port,
timeout: Duration(seconds: config.connectTimeoutSeconds),
Duration(seconds: config.connectTimeoutSeconds),
what: 'SSH server',
);
}

Expand Down Expand Up @@ -314,9 +364,7 @@ class SshTunnelManager {
'Observed fingerprint: $observedFingerprint',
);
}
throw SshAuthenticationException(
'SSH authentication failed for ${config.username}@${config.host}: $e',
);
throw _sshFailure(e, config.username, config.host, config.port);
}

// Zero sensitive in-memory credentials immediately after successful authentication
Expand Down Expand Up @@ -526,6 +574,15 @@ class SshAuthenticationException implements Exception {
String toString() => message;
}

/// The SSH server could not be reached or the session broke before any
/// credential was judged: refused, timed out, reset, a protocol error.
class SshConnectionException implements Exception {
const SshConnectionException(this.message);
final String message;
@override
String toString() => message;
}

class SshHostKeyMismatchException implements Exception {
const SshHostKeyMismatchException(this.message);
final String message;
Expand Down
51 changes: 51 additions & 0 deletions test/core/database/database_error_mapper_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,59 @@ 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/database/sqlite_connection.dart';
import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart';

void main() {
group('SSH tunnel failures (#1305)', () {
test('a rejected SSH login is not the database refusing the credentials',
() {
final e = mapDatabaseError(
const SshAuthenticationException(
'SSH authentication failed for [email protected]: rejected'),
driver: DatabaseDriver.postgres,
);
expect(e, isNot(isA<AuthFailedException>()));
expect(e.message, contains('[email protected]'));
expect(e.remediationHint, contains('SSH'));
});

test('the one-line text keeps the host and points at the tunnel settings',
() {
final text = describeDatabaseError(
const SshAuthenticationException(
'SSH authentication failed for [email protected]: rejected'),
driver: DatabaseDriver.postgres,
);
expect(text, contains('[email protected]'));
expect(text, contains('SSH tunnel settings'));
expect(text, isNot(contains('username and password in the connection')));
});

test('a connection failure of the tunnel is unreachable, not authentication',
() {
final e = mapDatabaseError(
const SshConnectionException(
'The SSH connection to bastion.example:22 failed: reset'),
);
expect(e, isA<HostUnreachableException>());
expect(e.message, contains('bastion.example:22'));
});

test('a host key mismatch says not to connect', () {
final e = mapDatabaseError(const SshHostKeyMismatchException(
'SSH host key verification failed for bastion.example.'));
expect(e.remediationHint, contains('do not connect'));
});

test('a database authentication failure behind a tunnel is still one', () {
final e = mapDatabaseError(
Exception('password authentication failed for user "bob"'),
driver: DatabaseDriver.postgres,
);
expect(e, isA<AuthFailedException>());
});
});

group('lost sessions and TLS (#1316)', () {
test('a reset or closed session is a lost connection, not a refusal', () {
for (final text in [
Expand Down
43 changes: 41 additions & 2 deletions test/core/security/ssh_tunnel_manager_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,49 @@ void main() {
expect(server.clients.single.isClosed, isTrue);
});

test('an unreachable bastion surfaces the connect error', () async {
test('an unreachable bastion is a connection failure naming the host',
() async {
server.failConnect = true;

await expectLater(open(), throwsA(isA<StateError>()));
await expectLater(
open(),
throwsA(isA<SshConnectionException>().having(
(e) => e.message,
'message',
allOf(contains('bastion.example:2222'), contains('failed')),
)),
);
expect(manager.activeSessionCount, 0);
});

test('a reset during the handshake is not reported as a wrong password',
() async {
server.failHandshake = const SocketException('Connection reset by peer');

await expectLater(
open(),
throwsA(isA<SshConnectionException>().having(
(e) => e.message,
'message',
allOf(contains('bastion.example:2222'), contains('reset by peer')),
)),
);
});

test('a rejected jump host login names the jump host and user', () async {
server.userPasswords['jumper'] = 'right';

await expectLater(
open(
config: _config(jumpHost: 'jump.example', jumpUsername: 'jumper'),
secrets: SshTunnelSecrets(password: 'secret', jumpPassword: 'wrong'),
),
throwsA(isA<SshAuthenticationException>().having(
(e) => e.message,
'message',
allOf(contains('[email protected]')),
)),
);
expect(manager.activeSessionCount, 0);
});

Expand Down
6 changes: 6 additions & 0 deletions test/support/fake_ssh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ class FakeSshServer {
/// Makes the TCP connect fail.
bool failConnect;

/// When set, the handshake of every client fails with this (a reset, a
/// protocol error) before any credential is judged.
Object? failHandshake;

/// How long the TCP connect takes, to land another call inside it.
Duration? connectDelay;

Expand Down Expand Up @@ -158,6 +162,8 @@ class FakeSshClient implements SSHClient {

Future<void> _handshake() async {
try {
final broken = server.failHandshake;
if (broken != null) throw broken;
final trusted =
await onVerifyHostKey?.call(
'ssh-ed25519',
Expand Down
Loading