From a4d71c119d3211e0d26cc40a19ed18fc5b16bd75 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:14:39 +0300 Subject: [PATCH 1/3] test(security): SshTunnelManager suite with an in-memory SSH fake (#1041) Make the tunnel's TCP connector and SSH client builder injectable and cover loopback binding, ephemeral ports, byte forwarding, password / private-key / passphrase / jump-host authentication, secret scrubbing, host-key pinning, ref-counting, dropped-connection replacement, keep-alive and shutdown. Fixes found on the way: credentials passed for a reused tunnel were not cleared, a replaced session left its loopback listener open, handles of a replaced session could release the session that took its place, and app shutdown did not close open tunnels. --- lib/app/app_shutdown.dart | 5 +- lib/core/security/ssh_tunnel_manager.dart | 94 ++- .../security/ssh_tunnel_manager_test.dart | 649 ++++++++++++++++++ test/support/fake_ssh.dart | 220 ++++++ 4 files changed, 949 insertions(+), 19 deletions(-) create mode 100644 test/core/security/ssh_tunnel_manager_test.dart create mode 100644 test/support/fake_ssh.dart diff --git a/lib/app/app_shutdown.dart b/lib/app/app_shutdown.dart index 95481687..08420fc4 100644 --- a/lib/app/app_shutdown.dart +++ b/lib/app/app_shutdown.dart @@ -4,9 +4,11 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; /// Disconnects all pooled / cached client connections (PostgreSQL pool, MySQL, -/// Mongo, Redis, SQLite, extension drivers). Safe to call when no connections exist. +/// Mongo, Redis, SQLite, extension drivers) and then closes any SSH tunnel +/// still open. Safe to call when no connections exist. Future disconnectAllExternalServices() async { await PostgresService.instance.disconnectAll(); await MysqlService.instance.disconnectAll(); @@ -14,4 +16,5 @@ Future disconnectAllExternalServices() async { await RedisService.instance.disconnectAll(); await SqliteService.instance.disconnectAll(); await ExtensionDriverSession.instance.disconnectAll(); + await SshTunnelManager.instance.closeAll(); } diff --git a/lib/core/security/ssh_tunnel_manager.dart b/lib/core/security/ssh_tunnel_manager.dart index e91c5290..cf39d35c 100644 --- a/lib/core/security/ssh_tunnel_manager.dart +++ b/lib/core/security/ssh_tunnel_manager.dart @@ -88,16 +88,65 @@ class _PooledTunnelSession { } } +/// Opens the raw TCP transport to an SSH server (replaceable in tests). +typedef SshSocketConnector = Future Function( + String host, + int port, { + Duration? timeout, +}); + +/// Builds the SSH client over an open transport (replaceable in tests). +typedef SshClientBuilder = SSHClient Function( + SSHSocket socket, { + required String username, + SSHPasswordRequestHandler? onPasswordRequest, + List? identities, + SSHHostkeyVerifyHandler? onVerifyHostKey, +}); + +SSHClient _defaultClientBuilder( + SSHSocket socket, { + required String username, + SSHPasswordRequestHandler? onPasswordRequest, + List? identities, + SSHHostkeyVerifyHandler? onVerifyHostKey, +}) => + SSHClient( + socket, + username: username, + onPasswordRequest: onPasswordRequest, + identities: identities, + onVerifyHostKey: onVerifyHostKey, + ); + /// Singleton manager for production SSH tunnels (Bastion / Jump Hosts). /// Supports ephemeral local port forwarding, TLS over SSH, ref-counting, /// host key fingerprint verification, and zero-leak credential hygiene. class SshTunnelManager { - SshTunnelManager._(); + SshTunnelManager._() + : _connect = SSHSocket.connect, + _buildClient = _defaultClientBuilder; + + /// A manager whose network transport and SSH client are supplied by the + /// caller, so the tunnel logic can be exercised without an SSH server. + @visibleForTesting + SshTunnelManager.forTesting({ + required SshSocketConnector connector, + required SshClientBuilder clientBuilder, + }) : _connect = connector, + _buildClient = clientBuilder; static SshTunnelManager instance = SshTunnelManager._(); + final SshSocketConnector _connect; + final SshClientBuilder _buildClient; + final Map _sessions = {}; + /// Number of pooled tunnel sessions currently open. + @visibleForTesting + int get activeSessionCount => _sessions.length; + /// Formats SHA-256 fingerprint as standard hex string (`aa:bb:cc...` or raw hex). static String formatFingerprint(Uint8List bytes) { return sha256.convert(bytes).toString(); @@ -116,14 +165,21 @@ class SshTunnelManager { // 1. Check if an active session can be reused (ref-counting) final existing = _sessions[poolKey]; - if (existing != null && !existing.client.isClosed) { + if (existing != null && existing.client.isClosed) { + // The SSH connection dropped (network error, server restart): drop the + // stale session, including its local listener, and dial again below. + _sessions.remove(poolKey); + unawaited(existing.close()); + } else if (existing != null) { existing.refCount++; + // The caller's credentials are not needed for a reused session. + secrets.zero(); return SshTunnelHandle( localHost: '127.0.0.1', localPort: existing.localPort, remoteHost: remoteHost, remotePort: remotePort, - onRelease: () => _releaseSession(poolKey), + onRelease: () => _releaseSession(existing), ); } @@ -136,13 +192,13 @@ class SshTunnelManager { final jumpPort = config.jumpPort ?? 22; final jumpUser = config.jumpUsername ?? config.username; - final rawJumpSocket = await SSHSocket.connect( + final rawJumpSocket = await _connect( jumpHost, jumpPort, timeout: Duration(seconds: config.connectTimeoutSeconds), ); - jumpClient = SSHClient( + jumpClient = _buildClient( rawJumpSocket, username: jumpUser, onPasswordRequest: () => @@ -153,7 +209,7 @@ class SshTunnelManager { // Forward through jump host to target bastion (returns SSHForwardChannel which implements SSHSocket) bastionSocket = await jumpClient.forwardLocal(config.host.trim(), config.port); } else { - bastionSocket = await SSHSocket.connect( + bastionSocket = await _connect( config.host.trim(), config.port, timeout: Duration(seconds: config.connectTimeoutSeconds), @@ -207,7 +263,7 @@ class SshTunnelManager { return true; } - final client = SSHClient( + final client = _buildClient( bastionSocket, username: config.username.trim(), onPasswordRequest: () => secrets.password ?? '', @@ -284,18 +340,20 @@ class SshTunnelManager { localPort: localPort, remoteHost: remoteHost, remotePort: remotePort, - onRelease: () => _releaseSession(poolKey), + onRelease: () => _releaseSession(session), ); } - Future _releaseSession(String poolKey) async { - final session = _sessions[poolKey]; - if (session == null) return; + /// Drops one reference to [session]; the last one closes it. Handles keep a + /// reference to their own session, so a handle from a replaced (dropped) + /// session cannot release the session that took its place. + Future _releaseSession(_PooledTunnelSession session) async { session.refCount--; - if (session.refCount <= 0) { - _sessions.remove(poolKey); - await session.close(); + if (session.refCount > 0) return; + if (identical(_sessions[session.poolKey], session)) { + _sessions.remove(session.poolKey); } + await session.close(); } /// Closes all active tunnels and cleans up all sockets. @@ -336,12 +394,12 @@ class SshTunnelManager { try { if (config.jumpHost != null && config.jumpHost!.trim().isNotEmpty) { - final jumpSocket = await SSHSocket.connect( + final jumpSocket = await _connect( config.jumpHost!.trim(), config.jumpPort ?? 22, timeout: Duration(seconds: config.connectTimeoutSeconds), ); - jumpClient = SSHClient( + jumpClient = _buildClient( jumpSocket, username: config.jumpUsername ?? config.username, onPasswordRequest: () => @@ -353,7 +411,7 @@ class SshTunnelManager { config.port, ); } else { - bastionSocket = await SSHSocket.connect( + bastionSocket = await _connect( config.host.trim(), config.port, timeout: Duration(seconds: config.connectTimeoutSeconds), @@ -380,7 +438,7 @@ class SshTunnelManager { } String? observedFingerprint; - client = SSHClient( + client = _buildClient( bastionSocket, username: config.username.trim(), onPasswordRequest: () => secrets.password ?? '', diff --git a/test/core/security/ssh_tunnel_manager_test.dart b/test/core/security/ssh_tunnel_manager_test.dart new file mode 100644 index 00000000..67704445 --- /dev/null +++ b/test/core/security/ssh_tunnel_manager_test.dart @@ -0,0 +1,649 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/app/app_shutdown.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; + +import '../../support/fake_ssh.dart'; + +SshTunnelConfig _config({ + SshAuthType authType = SshAuthType.password, + String? fingerprint, + String? privateKeyPath, + String? jumpHost, + String? jumpUsername, + int keepAlive = 0, +}) => + SshTunnelConfig( + enabled: true, + host: 'bastion.example', + port: 2222, + username: 'deploy', + authType: authType, + knownHostFingerprint: fingerprint, + privateKeyPath: privateKeyPath, + jumpHost: jumpHost, + jumpUsername: jumpUsername, + keepAliveIntervalSeconds: keepAlive, + ); + +Future _dial(int port) => + Socket.connect(InternetAddress.loopbackIPv4, port); + +Future _accepts(int port) async { + try { + final s = await _dial(port); + s.destroy(); + return true; + } on SocketException { + return false; + } +} + +void main() { + late FakeSshServer server; + late SshTunnelManager manager; + late SshTunnelManager originalInstance; + + setUp(() { + server = FakeSshServer(); + manager = SshTunnelManager.forTesting( + connector: server.connect, + clientBuilder: server.build, + ); + originalInstance = SshTunnelManager.instance; + }); + + tearDown(() async { + SshTunnelManager.instance = originalInstance; + await manager.closeAll(); + }); + + Future open({ + SshTunnelConfig? config, + SshTunnelSecrets? secrets, + String remoteHost = 'db.internal', + int remotePort = 5432, + }) => + manager.openTunnel( + config: config ?? _config(), + secrets: secrets ?? SshTunnelSecrets(password: 'secret'), + remoteHost: remoteHost, + remotePort: remotePort, + ); + + group('loopback binding and ports', () { + test('the tunnel is reported and reachable on 127.0.0.1', () async { + final handle = await open(); + + expect(handle.localHost, '127.0.0.1'); + expect(handle.remoteHost, 'db.internal'); + expect(handle.remotePort, 5432); + expect(await _accepts(handle.localPort), isTrue); + }); + + test('the listener is not reachable through other local interfaces', + () async { + final handle = await open(); + final external = []; + for (final iface in await NetworkInterface.list( + type: InternetAddressType.IPv4, + )) { + external.addAll(iface.addresses.where((a) => !a.isLoopback)); + } + if (external.isEmpty) { + markTestSkipped('no non-loopback IPv4 interface on this machine'); + return; + } + + for (final address in external) { + await expectLater( + Socket.connect(address, handle.localPort, + timeout: const Duration(seconds: 2)), + throwsA(isA()), + reason: 'must not listen on ${address.address}', + ); + } + }); + + test('each tunnel gets its own free ephemeral port', () async { + final a = await open(remotePort: 5432); + final b = await open(remotePort: 3306); + final c = await open(remotePort: 6379); + + final ports = {a.localPort, b.localPort, c.localPort}; + expect(ports, hasLength(3)); + for (final port in ports) { + expect(port, greaterThanOrEqualTo(1024)); + } + }); + + test('bytes written to the local port reach the remote target and back', + () async { + final handle = await open(remoteHost: 'db.internal', remotePort: 5432); + + final socket = await _dial(handle.localPort); + final reply = socket.cast>().transform(utf8.decoder).first; + socket.write('select 1'); + await socket.flush(); + + expect(await reply.timeout(const Duration(seconds: 5)), 'SELECT 1'); + socket.destroy(); + + final client = server.clients.single; + expect(client.forwards.single, (host: 'db.internal', port: 5432)); + expect(client.received.map(utf8.decode), ['select 1']); + }); + }); + + group('authentication', () { + test('a password is supplied to the bastion', () async { + await open(secrets: SshTunnelSecrets(password: 'secret')); + + final client = server.clients.single; + expect(client.username, 'deploy'); + expect(client.suppliedPassword, 'secret'); + expect(server.connects.single, (host: 'bastion.example', port: 2222)); + }); + + test('a wrong password fails with SshAuthenticationException', () async { + await expectLater( + open(secrets: SshTunnelSecrets(password: 'nope')), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('deploy@bastion.example'), + ), + ), + ); + + expect(manager.activeSessionCount, 0); + expect(server.clients.single.isClosed, isTrue); + }); + + test('an unreachable bastion surfaces the connect error', () async { + server.failConnect = true; + + await expectLater(open(), throwsA(isA())); + expect(manager.activeSessionCount, 0); + }); + + test('a jump host is dialed first and the bastion is reached through it', + () async { + server.userPasswords['jumper'] = 'jump-secret'; + + await open( + config: _config(jumpHost: 'jump.example', jumpUsername: 'jumper'), + secrets: SshTunnelSecrets( + password: 'secret', + jumpPassword: 'jump-secret', + ), + ); + + expect(server.connects.single, (host: 'jump.example', port: 22)); + expect(server.clients, hasLength(2)); + final jump = server.clients[0]; + final bastion = server.clients[1]; + expect(jump.username, 'jumper'); + expect(jump.suppliedPassword, 'jump-secret'); + expect(jump.forwards.single, (host: 'bastion.example', port: 2222)); + expect(bastion.username, 'deploy'); + expect(bastion.suppliedPassword, 'secret'); + }); + + test('the jump host falls back to the main password', () async { + await open( + config: _config(jumpHost: 'jump.example'), + secrets: SshTunnelSecrets(password: 'secret'), + ); + + expect(server.clients[0].username, 'deploy'); + expect(server.clients[0].suppliedPassword, 'secret'); + }); + }); + + group('private keys', () { + late Directory keyDir; + String? ed25519; + String? ed25519Encrypted; + String? rsa; + String? rsaEncrypted; + const passphrase = 'correct horse'; + + Future generate( + String name, + List typeArgs, { + String pass = '', + }) async { + final path = p.join(keyDir.path, name); + final r = await Process.run( + 'ssh-keygen', + ['-q', ...typeArgs, '-N', pass, '-C', name, '-f', path], + ); + return r.exitCode == 0 ? path : null; + } + + setUpAll(() async { + keyDir = await Directory.systemTemp.createTemp('ssh_tunnel_keys_'); + try { + ed25519 = await generate('ed25519', ['-t', 'ed25519']); + ed25519Encrypted = await generate( + 'ed25519_enc', + ['-t', 'ed25519'], + pass: passphrase, + ); + rsa = await generate('rsa', ['-t', 'rsa', '-b', '2048', '-m', 'PEM']); + rsaEncrypted = await generate( + 'rsa_enc', + ['-t', 'rsa', '-b', '2048', '-m', 'PEM'], + pass: passphrase, + ); + } on ProcessException { + // ssh-keygen is not installed: the key tests below skip themselves. + } + }); + + tearDownAll(() => keyDir.deleteSync(recursive: true)); + + bool keysAvailable() { + if (ed25519 == null) { + markTestSkipped('ssh-keygen is not available'); + return false; + } + return true; + } + + test('an unencrypted Ed25519 key authenticates', () async { + if (!keysAvailable()) return; + await open( + config: _config(authType: SshAuthType.privateKey), + secrets: SshTunnelSecrets(privateKey: File(ed25519!).readAsStringSync()), + ); + + expect(server.clients.single.identities, hasLength(1)); + expect(server.clients.single.passwordRequested, isFalse); + }); + + test('an unencrypted RSA key authenticates', () async { + if (!keysAvailable()) return; + await open( + config: _config(authType: SshAuthType.privateKey), + secrets: SshTunnelSecrets(privateKey: File(rsa!).readAsStringSync()), + ); + + expect(server.clients.single.identities, hasLength(1)); + }); + + test('keys protected by a passphrase authenticate', () async { + if (!keysAvailable()) return; + final keys = [ed25519Encrypted!, rsaEncrypted!]; + for (var i = 0; i < keys.length; i++) { + final handle = await open( + config: _config(authType: SshAuthType.privateKey), + secrets: SshTunnelSecrets( + privateKey: File(keys[i]).readAsStringSync(), + passphrase: passphrase, + ), + // A distinct target per key so each one gets its own session. + remotePort: 7000 + i, + ); + expect(handle.localPort, greaterThan(0), reason: keys[i]); + } + expect(server.clients, hasLength(2)); + expect(server.clients.every((c) => c.identities!.length == 1), isTrue); + }); + + test('a wrong passphrase is reported as a key parse failure', () async { + if (!keysAvailable()) return; + await expectLater( + open( + config: _config(authType: SshAuthType.privateKey), + secrets: SshTunnelSecrets( + privateKey: File(ed25519Encrypted!).readAsStringSync(), + passphrase: 'wrong', + ), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Failed to parse private key'), + ), + ), + ); + expect(manager.activeSessionCount, 0); + }); + + test('garbage instead of a key is rejected', () async { + await expectLater( + open( + config: _config(authType: SshAuthType.privateKey), + secrets: SshTunnelSecrets(privateKey: 'not a key'), + ), + throwsA(isA()), + ); + }); + + test('the key is read from privateKeyPath when no key content is stored', + () async { + if (!keysAvailable()) return; + await open( + config: _config( + authType: SshAuthType.privateKey, + privateKeyPath: ed25519, + ), + secrets: SshTunnelSecrets(), + ); + + expect(server.clients.single.identities, hasLength(1)); + }); + + test('the server can reject the key', () async { + if (!keysAvailable()) return; + server.acceptPublicKeys = false; + + await expectLater( + open( + config: _config(authType: SshAuthType.privateKey), + secrets: + SshTunnelSecrets(privateKey: File(ed25519!).readAsStringSync()), + ), + throwsA(isA()), + ); + }); + }); + + group('in-memory secret scrubbing', () { + test('credentials are cleared once the handshake succeeds', () async { + final secrets = SshTunnelSecrets( + password: 'secret', + privateKey: 'unused', + passphrase: 'unused', + jumpPassword: 'unused', + ); + + await open(secrets: secrets); + + expect(secrets.isEmpty, isTrue); + expect(secrets.password, isNull); + expect(secrets.privateKey, isNull); + expect(secrets.passphrase, isNull); + expect(secrets.jumpPassword, isNull); + }); + + test('credentials handed to a reused tunnel are cleared too', () async { + await open(); + final second = SshTunnelSecrets(password: 'secret'); + + await open(secrets: second); + + expect(server.clients, hasLength(1)); + expect(second.isEmpty, isTrue); + }); + }); + + group('host key verification', () { + final hostKey = [9, 8, 7, 6, 5]; + final hexFingerprint = sha256.convert(hostKey).toString(); + + test('formatFingerprint is the SHA-256 hex digest', () { + expect( + SshTunnelManager.formatFingerprint(Uint8List.fromList(hostKey)), + hexFingerprint, + ); + }); + + test('a matching pinned fingerprint is accepted', () async { + server.hostKey = Uint8List.fromList(hostKey); + + await open(config: _config(fingerprint: hexFingerprint)); + + expect(manager.activeSessionCount, 1); + }); + + test('colon-separated, upper-case fingerprints still match', () async { + server.hostKey = Uint8List.fromList(hostKey); + final pairs = [ + for (var i = 0; i < hexFingerprint.length; i += 2) + hexFingerprint.substring(i, i + 2).toUpperCase(), + ]; + + await open(config: _config(fingerprint: pairs.join(':'))); + + expect(manager.activeSessionCount, 1); + }); + + test('a changed host key is blocked as a possible man-in-the-middle', + () async { + server.hostKey = Uint8List.fromList(hostKey); + + await expectLater( + open(config: _config(fingerprint: 'aa' * 32)), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('bastion.example'), contains(hexFingerprint)), + ), + ), + ); + + expect(manager.activeSessionCount, 0); + expect(server.clients.single.isClosed, isTrue); + expect(server.clients.single.passwordRequested, isFalse, + reason: 'the password must not be sent to an unverified host'); + }); + + test('a mismatch also closes the jump host connection', () async { + server.hostKey = Uint8List.fromList(hostKey); + + await expectLater( + open(config: _config(fingerprint: 'aa' * 32, jumpHost: 'jump.example')), + throwsA(isA()), + ); + + expect(server.clients, hasLength(2)); + expect(server.clients.every((c) => c.isClosed), isTrue); + }); + + test('without a pinned fingerprint the first key is trusted', () async { + await open(config: _config()); + + expect(manager.activeSessionCount, 1); + }); + }); + + group('testSshConnection', () { + test('a disabled tunnel is trivially ok', () async { + final r = await manager.testSshConnection( + config: const SshTunnelConfig(), + secrets: SshTunnelSecrets(), + ); + + expect(r.ok, isTrue); + expect(server.connects, isEmpty); + }); + + test('empty host and username are reported', () async { + final noHost = await manager.testSshConnection( + config: const SshTunnelConfig(enabled: true, username: 'u'), + secrets: SshTunnelSecrets(), + ); + final noUser = await manager.testSshConnection( + config: const SshTunnelConfig(enabled: true, host: 'h'), + secrets: SshTunnelSecrets(), + ); + + expect(noHost.ok, isFalse); + expect(noHost.error, contains('host')); + expect(noUser.ok, isFalse); + expect(noUser.error, contains('username')); + }); + + test('success returns the server fingerprint and closes the client', + () async { + server.hostKey = Uint8List.fromList([1, 1, 2, 3]); + + final r = await manager.testSshConnection( + config: _config(), + secrets: SshTunnelSecrets(password: 'secret'), + testRemoteHost: 'db.internal', + testRemotePort: 5432, + ); + + expect(r.ok, isTrue); + expect( + r.serverFingerprint, + SshTunnelManager.formatFingerprint(server.hostKey), + ); + expect(server.clients.single.forwards.single, + (host: 'db.internal', port: 5432)); + expect(server.clients.single.isClosed, isTrue); + expect(manager.activeSessionCount, 0, reason: 'a test opens no tunnel'); + }); + + test('bad credentials are reported without throwing', () async { + final r = await manager.testSshConnection( + config: _config(), + secrets: SshTunnelSecrets(password: 'wrong'), + ); + + expect(r.ok, isFalse); + expect(r.error, contains('SSH Connection failed')); + expect(server.clients.single.isClosed, isTrue); + }); + }); + + group('lifecycle', () { + test('the same target shares one session and is ref-counted', () async { + final a = await open(); + final b = await open(secrets: SshTunnelSecrets(password: 'secret')); + + expect(b.localPort, a.localPort); + expect(manager.activeSessionCount, 1); + expect(server.clients, hasLength(1)); + expect(server.connects, hasLength(1)); + + await a.release(); + expect(manager.activeSessionCount, 1); + expect(await _accepts(b.localPort), isTrue, + reason: 'one holder is left'); + + await b.release(); + expect(manager.activeSessionCount, 0); + expect(await _accepts(b.localPort), isFalse); + expect(server.clients.single.isClosed, isTrue); + }); + + test('releasing the same handle twice drops only one reference', () async { + final a = await open(); + final b = await open(); + + await a.release(); + await a.release(); + + expect(manager.activeSessionCount, 1); + expect(await _accepts(b.localPort), isTrue); + }); + + test('different targets get separate sessions', () async { + final db = await open(remotePort: 5432); + final cache = await open(remotePort: 6379); + + expect(manager.activeSessionCount, 2); + expect(server.clients, hasLength(2)); + + await db.release(); + expect(await _accepts(db.localPort), isFalse); + expect(await _accepts(cache.localPort), isTrue); + }); + + test('a dropped SSH connection is replaced, not reused', () async { + final first = await open(); + server.clients.single.close(); + + final second = await open(); + + expect(server.clients, hasLength(2)); + expect(second.localPort, isNot(first.localPort)); + expect(manager.activeSessionCount, 1); + await Future.delayed(const Duration(milliseconds: 50)); + expect(await _accepts(first.localPort), isFalse, + reason: 'the stale listener must be closed'); + expect(await _accepts(second.localPort), isTrue); + }); + + test('a handle from a replaced session cannot release its successor', + () async { + final stale = await open(); + server.clients.single.close(); + final fresh = await open(); + + await stale.release(); + + expect(manager.activeSessionCount, 1); + expect(await _accepts(fresh.localPort), isTrue); + }); + + test('closeAll tears down every tunnel', () async { + final a = await open(remotePort: 1111); + final b = await open(remotePort: 2222); + + await manager.closeAll(); + + expect(manager.activeSessionCount, 0); + expect(await _accepts(a.localPort), isFalse); + expect(await _accepts(b.localPort), isFalse); + expect(server.clients.every((c) => c.isClosed), isTrue); + }); + + test('releasing a handle after closeAll is harmless', () async { + final a = await open(); + await manager.closeAll(); + + await a.release(); + + expect(manager.activeSessionCount, 0); + }); + + test('app shutdown closes tunnels that are still open', () async { + SshTunnelManager.instance = manager; + final handle = await open(); + + await disconnectAllExternalServices(); + + expect(manager.activeSessionCount, 0); + expect(await _accepts(handle.localPort), isFalse); + }); + }); + + group('keep-alive', () { + test('pings the server periodically and stops when the tunnel closes', + () async { + final handle = await open(config: _config(keepAlive: 1)); + final client = server.clients.single; + + await Future.delayed(const Duration(milliseconds: 2300)); + expect(client.pingCount, greaterThanOrEqualTo(2)); + + await handle.release(); + final afterClose = client.pingCount; + await Future.delayed(const Duration(milliseconds: 1300)); + expect(client.pingCount, afterClose); + }); + + test('a zero interval disables pings', () async { + await open(config: _config(keepAlive: 0)); + + await Future.delayed(const Duration(milliseconds: 1300)); + + expect(server.clients.single.pingCount, 0); + }); + }); +} diff --git a/test/support/fake_ssh.dart b/test/support/fake_ssh.dart new file mode 100644 index 00000000..9d42f4a4 --- /dev/null +++ b/test/support/fake_ssh.dart @@ -0,0 +1,220 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +/// In-memory stand-in for an SSH server and its clients, plugged into +/// `SshTunnelManager.forTesting`. No sockets leave the process; only the +/// tunnel's own loopback listener is real. +class FakeSshServer { + FakeSshServer({ + this.password = 'secret', + List? hostKey, + this.acceptPublicKeys = true, + this.failConnect = false, + }) : hostKey = Uint8List.fromList(hostKey ?? const [1, 2, 3, 4]); + + /// Password the server accepts. + String password; + + /// Per-user passwords that take precedence over [password]. + final userPasswords = {}; + + /// Bytes the server presents as its host key fingerprint. + Uint8List hostKey; + + /// Whether public-key authentication succeeds. + bool acceptPublicKeys; + + /// Makes the TCP connect fail. + bool failConnect; + + /// Every (host, port) the manager dialed, in order. + final connects = <({String host, int port})>[]; + + /// Every client the manager built, in order. + final clients = []; + + Future connect( + String host, + int port, { + Duration? timeout, + }) async { + connects.add((host: host, port: port)); + if (failConnect) throw StateError('connect to $host:$port failed'); + return FakeSshSocket(); + } + + SSHClient build( + SSHSocket socket, { + required String username, + SSHPasswordRequestHandler? onPasswordRequest, + List? identities, + SSHHostkeyVerifyHandler? onVerifyHostKey, + }) { + final client = FakeSshClient( + server: this, + socket: socket, + username: username, + onPasswordRequest: onPasswordRequest, + identities: identities, + onVerifyHostKey: onVerifyHostKey, + ); + clients.add(client); + return client; + } +} + +/// A transport that carries nothing; the fake client never reads it. +class FakeSshSocket implements SSHSocket { + final _in = StreamController(); + final _out = StreamController>(); + + @override + Stream get stream => _in.stream; + + @override + StreamSink> get sink => _out.sink; + + @override + Future get done => Future.value(); + + @override + Future close() async {} + + @override + void destroy() {} + + @override + Future flush() async {} +} + +/// Scripted SSH client: runs a host-key check, then password or public-key +/// authentication against [server], and echoes forwarded connections. +class FakeSshClient implements SSHClient { + FakeSshClient({ + required this.server, + required this.socket, + required this.username, + this.onPasswordRequest, + this.identities, + this.onVerifyHostKey, + }) { + unawaited(_handshake()); + } + + final FakeSshServer server; + final SSHSocket socket; + final String username; + final SSHPasswordRequestHandler? onPasswordRequest; + final List? identities; + final SSHHostkeyVerifyHandler? onVerifyHostKey; + + final _authenticated = Completer(); + var _closed = false; + + /// Host/port pairs requested through `forwardLocal`. + final forwards = <({String host, int port})>[]; + var pingCount = 0; + + /// Password the manager supplied when asked, if it was asked. + String? suppliedPassword; + + /// Whether the manager asked this client for a password. + var passwordRequested = false; + + /// Bytes written by tunnel users, per forwarded connection, uppercased back. + final received = >[]; + + Future _handshake() async { + try { + final trusted = + await onVerifyHostKey?.call('ssh-ed25519', server.hostKey) ?? true; + if (!trusted) throw SSHHostkeyError('host key rejected'); + + final keys = identities; + if (keys != null && keys.isNotEmpty) { + if (!server.acceptPublicKeys) { + throw SSHAuthFailError('public key rejected'); + } + } else { + passwordRequested = onPasswordRequest != null; + suppliedPassword = await onPasswordRequest?.call(); + if (suppliedPassword != (server.userPasswords[username] ?? server.password)) { + throw SSHAuthFailError('password rejected'); + } + } + _authenticated.complete(); + } catch (e, st) { + _authenticated.completeError(e, st); + } + } + + @override + Future get authenticated => _authenticated.future; + + @override + bool get isClosed => _closed; + + @override + void close() => _closed = true; + + @override + Future ping() async => pingCount++; + + @override + Future forwardLocal( + String remoteHost, + int remotePort, { + String localHost = 'localhost', + int localPort = 0, + }) async { + if (_closed) throw StateError('client closed'); + forwards.add((host: remoteHost, port: remotePort)); + return _EchoForwardChannel(received); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Forwarded channel that answers every chunk with its uppercase form. +class _EchoForwardChannel implements SSHForwardChannel { + _EchoForwardChannel(this._received) { + _sink.stream.listen((chunk) { + _received.add(chunk); + _reply.add( + Uint8List.fromList(String.fromCharCodes(chunk).toUpperCase().codeUnits), + ); + }, onDone: () => _reply.close()); + } + + final List> _received; + final _sink = StreamController>(); + final _reply = StreamController(); + + @override + Stream get stream => _reply.stream; + + @override + StreamSink> get sink => _sink.sink; + + @override + Future close() async { + await _sink.close(); + } + + @override + Future get done => _reply.done; + + @override + void destroy() { + _sink.close(); + } + + @override + Future flush() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} From 29b9b6772bf1577bcf106ce661c31165d783a253 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:18:06 +0300 Subject: [PATCH 2/3] test(security): annotate overrides in the SSH fake (#1041) --- test/support/fake_ssh.dart | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/support/fake_ssh.dart b/test/support/fake_ssh.dart index 9d42f4a4..5dd9cb07 100644 --- a/test/support/fake_ssh.dart +++ b/test/support/fake_ssh.dart @@ -104,10 +104,20 @@ class FakeSshClient implements SSHClient { } final FakeSshServer server; + + @override final SSHSocket socket; + + @override final String username; + + @override final SSHPasswordRequestHandler? onPasswordRequest; + + @override final List? identities; + + @override final SSHHostkeyVerifyHandler? onVerifyHostKey; final _authenticated = Completer(); @@ -175,7 +185,8 @@ class FakeSshClient implements SSHClient { } @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName} is not faked'); } /// Forwarded channel that answers every chunk with its uppercase form. @@ -216,5 +227,6 @@ class _EchoForwardChannel implements SSHForwardChannel { Future flush() async {} @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName} is not faked'); } From 5c54bdcecac925e1844f8c7d35fbbc331011c868 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 7 Oct 2026 20:23:24 +0300 Subject: [PATCH 3/3] test(security): match SSHClient.close signature in the SSH fake (#1041) --- test/support/fake_ssh.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/support/fake_ssh.dart b/test/support/fake_ssh.dart index 5dd9cb07..3ba26904 100644 --- a/test/support/fake_ssh.dart +++ b/test/support/fake_ssh.dart @@ -167,7 +167,9 @@ class FakeSshClient implements SSHClient { bool get isClosed => _closed; @override - void close() => _closed = true; + Future close() async { + _closed = true; + } @override Future ping() async => pingCount++;