From 2cbbc3a303d1d4d3e8ddcfdc5452e8b192f582ea Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 1 Jul 2026 10:39:11 +0800 Subject: [PATCH 01/32] fix: harden dynamic forwarding, ssh-agent, and EC key parsing Dynamic SOCKS forwarding: - Preserve half-close semantics while streaming: client EOF closes the remote sink and remote EOF destroys the local socket instead of treating EOF as a full connection close immediately. - Guard against concurrent dial attempts for the same SOCKS request. - Cancel the handshake timer before dialing and clean up a tunnel if the connection was closed while dial was in flight. - Tolerate malformed UTF-8 domain names in SOCKS requests and cap handshake buffering at 32 KiB. SSH agent: - Validate that fallback RSA signing produces an SSHRsaSignature with the requested signature type. - Reject zero-length and oversized agent frames to avoid unbounded buffering. EC private keys: - Parse EC PRIVATE KEY curve OIDs when present. - Validate embedded EC public points by deriving the expected public key from the private scalar. - Preserve UnsupportedError for unsupported key types/curves instead of wrapping it as SSHKeyDecodeError. - Decode OpenSSH key comments with allowMalformed to tolerate malformed comments. Adds protocol tests for the dynamic forward hardening and ssh-agent frame size checks. --- lib/src/dynamic_forward_io.dart | 62 +++++++++++++---- lib/src/ssh_agent.dart | 20 +++++- lib/src/ssh_key_pair.dart | 51 ++++++++++++-- test/src/socket/dynamic_forward_io_test.dart | 63 +++++++++++++++++ test/src/ssh_agent_test.dart | 72 ++++++++++++++++++++ 5 files changed, 248 insertions(+), 20 deletions(-) diff --git a/lib/src/dynamic_forward_io.dart b/lib/src/dynamic_forward_io.dart index 191b334..128067c 100644 --- a/lib/src/dynamic_forward_io.dart +++ b/lib/src/dynamic_forward_io.dart @@ -114,20 +114,39 @@ class _SocksConnection { StreamSubscription? _remoteSub; Timer? _handshakeTimer; bool _closed = false; + bool _dialing = false; _SocksState _state = _SocksState.greeting; void start() { - _handshakeTimer = Timer(options.handshakeTimeout, () async { - _sendReply(_SocksReply.ttlExpired); - await close(); - }); - _clientSub = _client.listen( _onClientData, - onDone: close, + onDone: _handleClientEOF, onError: (_, __) => close(), cancelOnError: true, ); + + _handshakeTimer = Timer(options.handshakeTimeout, () async { + _sendReply(_SocksReply.ttlExpired); + await close(); + }); + } + + void _handleClientEOF() { + if (_state == _SocksState.streaming) { + _remote?.sink.close(); + _clientSub?.cancel(); + } else { + close(); + } + } + + void _handleRemoteEOF() { + if (_state == _SocksState.streaming) { + _client.destroy(); + _remoteSub?.cancel(); + } else { + close(); + } } Future close() async { @@ -152,9 +171,8 @@ class _SocksConnection { return; } - _buffer.add(chunk); - try { + _buffer.add(chunk); await _consumeHandshake(); } catch (_) { await close(); @@ -169,41 +187,55 @@ class _SocksConnection { } if (_state == _SocksState.request) { + if (_dialing) return; final target = _parseConnectRequest(); if (target == null) return; + _dialing = true; if (filter != null && !filter!(target.host, target.port)) { _sendReply(_SocksReply.connectionNotAllowed); + _dialing = false; await close(); return; } if (!canOpenTunnel()) { _sendReply(_SocksReply.connectionRefused); + _dialing = false; await close(); return; } + _handshakeTimer?.cancel(); + _handshakeTimer = null; + try { _remote = await dial(target.host, target.port).timeout( options.connectTimeout, ); } catch (_) { _sendReply(_SocksReply.hostUnreachable); + _dialing = false; await close(); return; } + _dialing = false; + + if (_closed) { + _remote?.destroy(); + _remote = null; + return; + } + _remoteSub = _remote!.stream.listen( _client.add, - onDone: close, + onDone: _handleRemoteEOF, onError: (_, __) => close(), cancelOnError: true, ); _sendReply(_SocksReply.succeeded); - _handshakeTimer?.cancel(); - _handshakeTimer = null; _state = _SocksState.streaming; final pending = _buffer.takeAll(); @@ -288,7 +320,7 @@ class _SocksConnection { if (atyp == 0x03) { final length = request[4]; final bytes = request.sublist(5, 5 + length); - return utf8.decode(bytes); + return utf8.decode(bytes, allowMalformed: true); } final raw = request.sublist(4, 20); @@ -316,12 +348,18 @@ class _SocksConnection { } class _ByteBuffer { + static const kMaxHandshakeSize = 32768; + final _data = []; int _offset = 0; int get length => _data.length - _offset; void add(List chunk) { + if (length + chunk.length > kMaxHandshakeSize) { + throw StateError( + 'Handshake buffer overflow: $length + ${chunk.length} > $kMaxHandshakeSize'); + } _data.addAll(chunk); } diff --git a/lib/src/ssh_agent.dart b/lib/src/ssh_agent.dart index 7e48fba..2456a8a 100644 --- a/lib/src/ssh_agent.dart +++ b/lib/src/ssh_agent.dart @@ -95,7 +95,16 @@ class SSHKeyPairAgent implements SSHAgentHandler { ) { final key = _rsaKeyFrom(identity); if (key == null) { - return identity.sign(data) as SSHRsaSignature; + final signature = identity.sign(data); + if (signature is SSHRsaSignature) { + if (signature.type != signatureType) { + throw StateError( + 'RSA signature type mismatch: requested $signatureType but identity produced ${signature.type}'); + } + return signature; + } + throw StateError( + 'RSA signing requested but identity produced non-RSA signature: ${signature.runtimeType}'); } final signer = _rsaSignerFor(signatureType); @@ -154,6 +163,8 @@ class SSHKeyPairAgent implements SSHAgentHandler { } class SSHAgentChannel { + static const maxFrameSize = 256 * 1024; + SSHAgentChannel(this._channel, this._handler, {this.printDebug}) { _subscription = _channel.stream.listen( _handleData, @@ -188,6 +199,13 @@ class SSHAgentChannel { Future _processQueue() async { while (_buffer.length >= 4) { final length = ByteData.sublistView(_buffer, 0, 4).getUint32(0); + if (length == 0 || length > maxFrameSize) { + printDebug + ?.call('SSH agent: invalid frame length $length, closing channel'); + _channel.destroy(); + _buffer = Uint8List(0); + return; + } if (_buffer.length < 4 + length) return; final payload = _buffer.sublist(4, 4 + length); _buffer = _buffer.sublist(4 + length); diff --git a/lib/src/ssh_key_pair.dart b/lib/src/ssh_key_pair.dart index fef8782..40ed6fd 100644 --- a/lib/src/ssh_key_pair.dart +++ b/lib/src/ssh_key_pair.dart @@ -239,8 +239,14 @@ class OpenSSHKeyPairs { final key = Uint8List.view(kdfHash.buffer, 0, cipher.keySize); final iv = Uint8List.view(kdfHash.buffer, cipher.keySize, cipher.ivSize); - final decryptCipher = cipher.createCipher(key, iv, forEncryption: false); - return decryptCipher.processAll(blob); + + try { + final decryptCipher = + cipher.createCipher(key, iv, forEncryption: false); + return decryptCipher.processAll(blob); + } catch (e) { + throw SSHKeyDecryptError('Failed to decrypt private key', e); + } } @override @@ -339,7 +345,7 @@ class OpenSSHRsaKeyPair with OpenSSHKeyPair { final iqmp = reader.readMpint(); final p = reader.readMpint(); final q = reader.readMpint(); - final comment = reader.readUtf8(); + final comment = reader.readUtf8(allowMalformed: true); return OpenSSHRsaKeyPair(n, e, d, iqmp, p, q, comment); } @@ -397,7 +403,7 @@ class OpenSSHEd25519KeyPair with OpenSSHKeyPair { factory OpenSSHEd25519KeyPair.readFrom(SSHMessageReader reader) { final publicKey = reader.readString(); final privateKey = reader.readString(); - final comment = reader.readUtf8(); + final comment = reader.readUtf8(allowMalformed: true); return OpenSSHEd25519KeyPair(publicKey, privateKey, comment); } @@ -446,7 +452,7 @@ class OpenSSHEcdsaKeyPair with OpenSSHKeyPair { final curve = reader.readUtf8(); final q = reader.readString(); final d = reader.readMpint(); - final comment = reader.readUtf8(); + final comment = reader.readUtf8(allowMalformed: true); return OpenSSHEcdsaKeyPair(curve, q, d, comment); } @@ -538,6 +544,8 @@ class RsaKeyPair { try { return RsaPrivateKey.decode(keyBlob); + } on UnsupportedError { + rethrow; } catch (e) { throw SSHKeyDecodeError('Failed to decode private key', e); } @@ -755,6 +763,8 @@ class EcKeyPair { try { return _decodeLegacyEcPrivateKey(keyBlob); + } on UnsupportedError { + rethrow; } catch (e) { throw SSHKeyDecodeError('Failed to decode private key', e); } @@ -772,10 +782,21 @@ class EcKeyPair { final d = decodeBigIntWithSign(1, privateKeyOctets); Uint8List? publicPoint; + String? curveId; for (var i = 2; i < sequence.elements.length; i++) { final element = sequence.elements[i]; - if (element.tag == 0xA1) { + if (element.tag == 0xA0) { + final inner = ASN1Parser(element.valueBytes()).nextObject(); + if (inner is ASN1ObjectIdentifier && inner.identifier != null) { + final oid = inner.identifier!; + curveId = _curveIdFromOid(oid); + if (curveId == null) { + throw UnsupportedError( + 'Unsupported EC PRIVATE KEY curve OID: $oid'); + } + } + } else if (element.tag == 0xA1) { final inner = ASN1Parser(element.valueBytes()).nextObject(); if (inner is ASN1BitString) { publicPoint = inner.contentBytes(); @@ -783,17 +804,33 @@ class EcKeyPair { } } - final curveId = + curveId ??= _inferCurveId(publicPoint?.length ?? 0, privateKeyOctets.length); if (curveId == null) { throw UnsupportedError('Unsupported EC PRIVATE KEY curve'); } + if (publicPoint != null) { + final expectedPublicPoint = _derivePublicPoint(curveId, d); + if (publicPoint.length != expectedPublicPoint.length || + !publicPoint.equals(expectedPublicPoint)) { + throw UnsupportedError( + 'EC PRIVATE KEY public point does not match curve $curveId'); + } + } + final q = publicPoint ?? _derivePublicPoint(curveId, d); return OpenSSHEcdsaKeyPair(curveId, q, d, ''); } + String? _curveIdFromOid(String oid) { + if (oid == '1.2.840.10045.3.1.7') return 'nistp256'; + if (oid == '1.3.132.0.34') return 'nistp384'; + if (oid == '1.3.132.0.35') return 'nistp521'; + return null; + } + String? _inferCurveId(int publicPointLength, int privateKeyLength) { if (publicPointLength == 65 || privateKeyLength == 32) { return 'nistp256'; diff --git a/test/src/socket/dynamic_forward_io_test.dart b/test/src/socket/dynamic_forward_io_test.dart index f52fe7d..d692795 100644 --- a/test/src/socket/dynamic_forward_io_test.dart +++ b/test/src/socket/dynamic_forward_io_test.dart @@ -369,6 +369,69 @@ void main() { expect(dialedHosts[0], '192.168.1.2'); expect(dialedHosts[1], contains(':')); }); + + test('closes remote sink when client EOF arrives during streaming', + () async { + late _DialedTunnel dialed; + + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async { + dialed = _DialedTunnel.create(); + return dialed.channel; + }, + ); + + final client = await Socket.connect(forward.host, forward.port); + final incoming = client.asBroadcastStream(); + addTearDown(() async { + await client.close(); + await forward.close(); + dialed.dispose(); + }); + + await _sendGreeting(client, incoming); + final reply = + await _sendConnectDomain(client, incoming, 'example.com', 443); + expect(reply[1], 0x00); + + // Send some data then close client side (half-close / EOF). + client.add(utf8.encode('data')); + await client.close(); + await Future.delayed(const Duration(milliseconds: 30)); + }); + + test('handles handshake buffer overflow gracefully', () async { + final forward = await startDynamicForward( + bindHost: '127.0.0.1', + bindPort: 0, + options: const SSHDynamicForwardOptions(), + dial: (_, __) async => _DialedTunnel.create().channel, + ); + addTearDown(() => forward.close()); + + final client = await Socket.connect(forward.host, forward.port); + addTearDown(() => client.close()); + + // Send a valid greeting but then flood the handshake buffer beyond + // kMaxHandshakeSize (32768). The server should close the connection + // rather than keep buffering indefinitely. + await _sendGreeting(client, client.asBroadcastStream()); + final huge = Uint8List(33000); + client.add(huge); + + // Give the server time to detect the overflow and close. The test + // passes if the server does not crash — the forward is still usable + // for a new connection after the overflow victim is cleaned up. + await Future.delayed(const Duration(milliseconds: 100)); + + // Verify the forward still accepts new connections. + final client2 = await Socket.connect(forward.host, forward.port); + addTearDown(() => client2.close()); + await _sendGreeting(client2, client2.asBroadcastStream()); + }); }); } diff --git a/test/src/ssh_agent_test.dart b/test/src/ssh_agent_test.dart index 6a24535..a131dbe 100644 --- a/test/src/ssh_agent_test.dart +++ b/test/src/ssh_agent_test.dart @@ -290,4 +290,76 @@ void main() { controller.destroy(); }); + + test('SSHAgentChannel closes on invalid frame length (zero)', () async { + final handler = _RecordingAgentHandler(Uint8List.fromList([1])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (_) {}, + ); + + SSHAgentChannel( + controller.channel, + handler, + printDebug: (_) {}, + ); + + // Frame with length = 0 (invalid). + final zeroFrame = Uint8List.fromList([0, 0, 0, 0]); + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: zeroFrame, + ), + ); + + await Future.delayed(Duration.zero); + + // The channel should have been destroyed, no requests processed. + expect(handler.requests, isEmpty); + + controller.destroy(); + }); + + test('SSHAgentChannel closes on frame length exceeding maxFrameSize', + () async { + final handler = _RecordingAgentHandler(Uint8List.fromList([1])); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (_) {}, + ); + + SSHAgentChannel( + controller.channel, + handler, + printDebug: (_) {}, + ); + + // Frame claiming a length > maxFrameSize (256 * 1024 = 262144). + final oversizedFrame = Uint8List.fromList([0, 4, 0, 1]); + controller.handleMessage( + SSH_Message_Channel_Data( + recipientChannel: controller.localId, + data: oversizedFrame, + ), + ); + + await Future.delayed(Duration.zero); + + expect(handler.requests, isEmpty); + + controller.destroy(); + }); } From 4e707e1af1c092eae877a69544de78c25c1c3e72 Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 1 Jul 2026 10:41:43 +0800 Subject: [PATCH 02/32] Format --- lib/src/ssh_key_pair.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/ssh_key_pair.dart b/lib/src/ssh_key_pair.dart index 40ed6fd..096ecea 100644 --- a/lib/src/ssh_key_pair.dart +++ b/lib/src/ssh_key_pair.dart @@ -241,8 +241,7 @@ class OpenSSHKeyPairs { final iv = Uint8List.view(kdfHash.buffer, cipher.keySize, cipher.ivSize); try { - final decryptCipher = - cipher.createCipher(key, iv, forEncryption: false); + final decryptCipher = cipher.createCipher(key, iv, forEncryption: false); return decryptCipher.processAll(blob); } catch (e) { throw SSHKeyDecryptError('Failed to decrypt private key', e); From f4b3e9e48a0262473bd43d7790e9ce8e5b893add Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 1 Jul 2026 11:00:10 +0800 Subject: [PATCH 03/32] feat: add SSHSession waitForExit timeout --- lib/src/ssh_session.dart | 26 ++++++++ test/src/ssh_client_run_with_result_test.dart | 59 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/lib/src/ssh_session.dart b/lib/src/ssh_session.dart index f22d2bc..fe1514e 100644 --- a/lib/src/ssh_session.dart +++ b/lib/src/ssh_session.dart @@ -35,6 +35,12 @@ class SSHSession { SSHSession(this._channel) { _channel.setRequestHandler(_handleRequest); + done.then((_) { + if (!_exitCompleter.isCompleted) { + _exitCompleter.complete(_exitCode); + } + }); + _channelDataSubscription = _channel.stream.listen( _handleChannelData, onDone: _handleChannelDataDone, @@ -51,6 +57,8 @@ class SSHSession { SSHSessionExitSignal? _exitSignal; + final _exitCompleter = Completer(); + late final StreamSubscription _channelDataSubscription; late final _stdinController = StreamController(); @@ -103,6 +111,18 @@ class SSHSession { _channel.close(); } + /// Waits for the remote process to report an exit status. + /// + /// Returns the exit status, or `null` if the process exited without reporting + /// one, was terminated by a signal, or [timeout] elapsed before it exited. + Future waitForExit({Duration? timeout}) { + Future wait = _exitCompleter.future; + if (timeout != null) { + wait = wait.timeout(timeout, onTimeout: () => null); + } + return wait; + } + /// Deliver [signal] to the remote process. Some implementations may not /// support this. void kill(SSHSignal signal) { @@ -113,6 +133,9 @@ class SSHSession { switch (request.requestType) { case SSHChannelRequestType.exitStatus: _exitCode = request.exitStatus!; + if (!_exitCompleter.isCompleted) { + _exitCompleter.complete(_exitCode); + } return true; case SSHChannelRequestType.exitSignal: _exitSignal = SSHSessionExitSignal( @@ -121,6 +144,9 @@ class SSHSession { errorMessage: request.errorMessage!, languageTag: request.languageTag!, ); + if (!_exitCompleter.isCompleted) { + _exitCompleter.complete(null); + } return true; } return false; diff --git a/test/src/ssh_client_run_with_result_test.dart b/test/src/ssh_client_run_with_result_test.dart index 057c50a..006d092 100644 --- a/test/src/ssh_client_run_with_result_test.dart +++ b/test/src/ssh_client_run_with_result_test.dart @@ -8,6 +8,65 @@ import 'package:dartssh2/src/ssh_channel.dart'; import 'package:test/test.dart'; void main() { + group('SSHSession.waitForExit', () { + test('returns exit status after channel closes', () async { + final harness = _SessionHarness(); + + final exit = harness.session.waitForExit(); + harness.sendExitStatus(7); + harness.close(); + + expect(await exit, 7); + + harness.dispose(); + }); + + test('returns exit status before channel closes', () async { + final harness = _SessionHarness(); + + final exit = harness.session.waitForExit(); + harness.sendExitStatus(7); + + expect(await exit, 7); + + harness.dispose(); + }); + + test('returns null when timeout elapses before exit', () async { + final harness = _SessionHarness(); + + final exit = await harness.session.waitForExit( + timeout: const Duration(milliseconds: 10), + ); + + expect(exit, isNull); + expect(harness.session.exitCode, isNull); + + harness.dispose(); + }); + + test('returns null when channel closes without exit status', () async { + final harness = _SessionHarness(); + + final exit = harness.session.waitForExit(); + harness.close(); + + expect(await exit, isNull); + + harness.dispose(); + }); + + test('returns existing exit status immediately', () async { + final harness = _SessionHarness(); + + harness.sendExitStatus(3); + + expect(await harness.session.waitForExit(), 3); + + harness.dispose(); + }); + }); + group('SSHClient.runWithResult', () { test('captures stdout/stderr and exit status', () async { final harness = _SessionHarness(); From 942707c3f237d279d41e5e74fa7cf38083b0b367 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:19:50 +0200 Subject: [PATCH 04/32] chore: bump version to 2.21.0 and harden SOCKS5, SSH agent, and EC key parsing logic --- CHANGELOG.md | 9 ++++++++- pubspec.yaml | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2e599..dff6fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [2.20.1] - 2026-07-01 +- Hardened SOCKS5 dynamic forwarding (half-close streaming, dialing guards, timeout cancellation, malformed UTF-8 decoding, and buffer limits) [#175]. Thanks [@GT-610]. +- Hardened SSH agent channel frame validation (rejecting empty or oversized frames) and fallback RSA signature type checks [#175]. Thanks [@GT-610]. +- Improved EC private key parsing with proper ASN.1 OID curve detection, public point derivation validation, and robust comments decoding [#175]. Thanks [@GT-610]. + ## [2.20.0] - 2026-06-30 - **BREAKING**: Bumped the minimum Dart SDK constraint to `3.0.0` [#23]. Thanks [@vicajilau]. - **BREAKING**: Declared `OpenSSHKeyPair` as a `mixin class` to comply with Dart 3.0 class modifier rules [#23]. Thanks [@vicajilau]. @@ -252,6 +257,7 @@ [#18]: https://github.com/TerminalStudio/dartssh2/issues/18 [#17]: https://github.com/TerminalStudio/dartssh2/issues/17 [#14]: https://github.com/TerminalStudio/dartssh2/pull/14 +[#175]: https://github.com/TerminalStudio/dartssh2/pull/175 [#1]: https://github.com/TerminalStudio/dartssh/pull/1/files [@linhanyu]: https://github.com/linhanyu @@ -269,4 +275,5 @@ [@bradmartin333]: https://github.com/bradmartin333 [@Wackymax]: https://github.com/Wackymax [@gkc]: https://github.com/gkc -[@vicajilau]: https://github.com/vicajilau \ No newline at end of file +[@vicajilau]: https://github.com/vicajilau +[@GT-610]: https://github.com/GT-610 \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 27c6933..dad0e8f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.20.0 +version: 2.21.0 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 From 1d3ed4ad692a0cdcc5b889a560e05cc73ab87987 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:22:17 +0200 Subject: [PATCH 05/32] feat: add waitForExit with optional timeout to SSHSession --- CHANGELOG.md | 4 +++- README.md | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dff6fb4..fe984a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -## [2.20.1] - 2026-07-01 +## [2.21.0] - 2026-07-01 +- Added `SSHSession.waitForExit({Duration? timeout})` to await remote process exit status with an optional timeout [#176]. Thanks [@GT-610]. - Hardened SOCKS5 dynamic forwarding (half-close streaming, dialing guards, timeout cancellation, malformed UTF-8 decoding, and buffer limits) [#175]. Thanks [@GT-610]. - Hardened SSH agent channel frame validation (rejecting empty or oversized frames) and fallback RSA signature type checks [#175]. Thanks [@GT-610]. - Improved EC private key parsing with proper ASN.1 OID curve detection, public point derivation validation, and robust comments decoding [#175]. Thanks [@GT-610]. @@ -258,6 +259,7 @@ [#17]: https://github.com/TerminalStudio/dartssh2/issues/17 [#14]: https://github.com/TerminalStudio/dartssh2/pull/14 [#175]: https://github.com/TerminalStudio/dartssh2/pull/175 +[#176]: https://github.com/TerminalStudio/dartssh2/pull/176 [#1]: https://github.com/TerminalStudio/dartssh/pull/1/files [@linhanyu]: https://github.com/linhanyu diff --git a/README.md b/README.md index 2f7d2e8..c713316 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,25 @@ void main() async { Processes killed by signals do not have an exit code, instead they have an exit signal property. +**Waiting for exit status with a timeout** + +Alternatively, you can wait for the remote process to report its exit status or exit signal with an optional timeout using `session.waitForExit()`: + +```dart +void main() async { + final session = await client.execute('sleep 5'); + + // Wait for the exit status to be reported (or up to 10 seconds). + final exitCode = await session.waitForExit(timeout: Duration(seconds: 10)); + + if (exitCode != null) { + print('Process exited with code: $exitCode'); + } else { + print('Process timed out or was terminated by a signal'); + } +} +``` + ### Forward connections on local port 8080 to the server ```dart From 1c596547af24f600f05f877a153f5f98052f7180 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:45:21 +0200 Subject: [PATCH 06/32] fix: prevent SSHTransport busy-loop on partial packets --- lib/src/ssh_transport.dart | 10 +++++++++- test/src/ssh_transport_version_test.dart | 25 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index 410e0c3..d0e234f 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -131,6 +131,9 @@ class SSHTransport { /// Guards asynchronous packet processing to preserve message order. var _isProcessingData = false; + /// Tracks whether new socket data was received since packet processing started. + var _hasNewData = false; + /// Identification string sent by us without trailing \r\n. For example, /// "SSH-2.0-DartSSH_2.0". String get _localVersion => 'SSH-2.0-$version'; @@ -475,6 +478,7 @@ class SSHTransport { /// Callback triggered when new raw bytes are received from the socket. void _onSocketData(Uint8List data) { _buffer.add(data); + _hasNewData = true; _scheduleProcessData(); } @@ -496,6 +500,8 @@ class SSHTransport { } _isProcessingData = true; + final lengthBefore = _buffer.length; + _hasNewData = false; _processDataAsync().catchError((error, stackTrace) { if (error is SSHError) { @@ -506,7 +512,9 @@ class SSHTransport { }).whenComplete(() { _isProcessingData = false; if (_buffer.isNotEmpty && !isClosed) { - _scheduleProcessData(); + if (_hasNewData || _buffer.length < lengthBefore) { + _scheduleProcessData(); + } } }); } diff --git a/test/src/ssh_transport_version_test.dart b/test/src/ssh_transport_version_test.dart index ce37450..a4dc13f 100644 --- a/test/src/ssh_transport_version_test.dart +++ b/test/src/ssh_transport_version_test.dart @@ -43,6 +43,27 @@ void main() { client.close(); }); + + test('does not busy loop on partial packet after handshake', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + // Complete the version exchange. + socket.addIncoming('SSH-2.0-OpenSSH_3.6.1p2\r\n'); + await _pumpUntil(() => client.remoteVersion != null); + + // Send the first 4 bytes of a packet indicating a length of 100. + socket.addRawIncoming(Uint8List.fromList([0, 0, 0, 100])); + + // Wait a moment. If there is a microtask busy loop, the delayed future + // will never run and the test will timeout. + await Future.delayed(const Duration(milliseconds: 50)); + + client.close(); + }); }); } @@ -74,6 +95,10 @@ class _FakeSSHSocket implements SSHSocket { _inputController.add(Uint8List.fromList(latin1.encode(data))); } + void addRawIncoming(Uint8List data) { + _inputController.add(data); + } + @override Future close() async { if (!_doneCompleter.isCompleted) { From 9078dfe9e7239af8827f00279511cab6cae24704 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:51:54 +0200 Subject: [PATCH 07/32] test: add test case for rescheduling transport processing when extra data remains in buffer --- test/src/ssh_transport_version_test.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/src/ssh_transport_version_test.dart b/test/src/ssh_transport_version_test.dart index a4dc13f..6a07fcf 100644 --- a/test/src/ssh_transport_version_test.dart +++ b/test/src/ssh_transport_version_test.dart @@ -64,6 +64,24 @@ void main() { client.close(); }); + + test('reschedules processing when more data remains in the buffer', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + ); + + // Send the version banner followed by some extra data in one go. + socket.addIncoming('SSH-2.0-OpenSSH_3.6.1p2\r\nSSH-2.0-SecondLine\r\n'); + + // Pump until the client processes the first version. + await _pumpUntil(() => client.remoteVersion != null); + + expect(client.remoteVersion, 'SSH-2.0-OpenSSH_3.6.1p2'); + + client.close(); + }); }); } From b53960a56f3568aee0ef866cc68c8c7bd29e5558 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:53:39 +0200 Subject: [PATCH 08/32] style: wrap test description line in ssh_transport_version_test.dart --- test/src/ssh_transport_version_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/src/ssh_transport_version_test.dart b/test/src/ssh_transport_version_test.dart index 6a07fcf..848c18d 100644 --- a/test/src/ssh_transport_version_test.dart +++ b/test/src/ssh_transport_version_test.dart @@ -65,7 +65,8 @@ void main() { client.close(); }); - test('reschedules processing when more data remains in the buffer', () async { + test('reschedules processing when more data remains in the buffer', + () async { final socket = _FakeSSHSocket(); final client = SSHClient( socket, From 27333875f9d241d0300a0ac0de75783fa0e61e40 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:00:33 +0200 Subject: [PATCH 09/32] chore: bump version to 2.21.1 and update CHANGELOG --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe984a2..c4d4075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.21.1] - 2026-07-02 +- Fixed an `SSHTransport` busy-loop (100% CPU / ANR) that occurred when a partial packet remained in the read buffer [#179]. Thanks [@vicajilau]. + ## [2.21.0] - 2026-07-01 - Added `SSHSession.waitForExit({Duration? timeout})` to await remote process exit status with an optional timeout [#176]. Thanks [@GT-610]. - Hardened SOCKS5 dynamic forwarding (half-close streaming, dialing guards, timeout cancellation, malformed UTF-8 decoding, and buffer limits) [#175]. Thanks [@GT-610]. diff --git a/pubspec.yaml b/pubspec.yaml index dad0e8f..6d46d98 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.21.0 +version: 2.21.1 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 From 84a31660cb241e16880258477b94771d90730b2f Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:05:55 +0200 Subject: [PATCH 10/32] chore: add github-actions ecosystem and timezone configuration to dependabot.yml --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ad8b29e..e01bb2b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,13 @@ version: 2 updates: - package-ecosystem: "pub" directory: "/example/" + schedule: + interval: "weekly" + time: "09:00" + timezone: Europe/Madrid + + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" time: "09:00" From ba0a5bc743dd10c35020c28c6b296acf634f6cad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:06:34 +0000 Subject: [PATCH 11/32] chore(deps): bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/dart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 86e74d6..83f8746 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -18,7 +18,7 @@ jobs: sdk: [stable] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dart-lang/setup-dart@v1 with: sdk: ${{ matrix.sdk }} From 748968663da20361c59bea7d91830408d47c83c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:06:38 +0000 Subject: [PATCH 12/32] chore(deps): bump codecov/codecov-action from 5 to 7 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 7. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5...v7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/dart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 86e74d6..6fa4066 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -51,7 +51,7 @@ jobs: run: dart test --tags=integration - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: fail_ci_if_error: true # optional (default = false) files: ./coverage/lcov.info # optional From a2719d23738fec2ab9e86a8e625a525dca2c77da Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:12:31 +0200 Subject: [PATCH 13/32] chore: disable CI failure on codecov upload error in workflow --- .github/workflows/dart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 86e74d6..d967c43 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -53,7 +53,7 @@ jobs: - name: Upload coverage uses: codecov/codecov-action@v5 with: - fail_ci_if_error: true # optional (default = false) + fail_ci_if_error: false # optional (default = false) files: ./coverage/lcov.info # optional flags: unittests # optional name: codecov-umbrella # optional From 796b42e528bb2f5a08c05aefc7c03ff5038e1acf Mon Sep 17 00:00:00 2001 From: GT610 Date: Wed, 1 Jul 2026 11:15:54 +0800 Subject: [PATCH 14/32] feat: add SSHClient handshake and auth timeouts --- lib/src/ssh_client.dart | 55 ++++++++++++++ test/src/ssh_client_timeout_test.dart | 103 ++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 test/src/ssh_client_timeout_test.dart diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index a59d71a..fcc1e44 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -182,6 +182,12 @@ class SSHClient { /// method. Set this to null to disable automatic keep-alive messages. final Duration? keepAliveInterval; + /// Maximum time to wait for the SSH transport handshake to complete. + final Duration? handshakeTimeout; + + /// Maximum time to wait for authentication after the transport is ready. + final Duration? authTimeout; + /// Function called when additional host keys are received. This is an OpenSSH /// extension. May not be called if the server does not support the extension. // final SSHHostKeysHandler? onHostKeys; @@ -217,6 +223,8 @@ class SSHClient { this.onX11Forward, this.agentHandler, this.keepAliveInterval = const Duration(seconds: 10), + this.handshakeTimeout, + this.authTimeout, this.disableHostkeyVerification = false, String ident = 'DartSSH_2.0', }) : ident = _validateIdent(ident) { @@ -247,6 +255,11 @@ class SSHClient { if (identities != null) { _keyPairsLeft.addAll(identities!); } + + final handshakeTimeout = this.handshakeTimeout; + if (handshakeTimeout != null) { + _handshakeTimeoutTimer = Timer(handshakeTimeout, _handleHandshakeTimeout); + } } static String _validateIdent(String ident) { @@ -295,6 +308,12 @@ class SSHClient { SSHAuthMethod? _currentAuthMethod; + var _transportReady = false; + + Timer? _handshakeTimeoutTimer; + + Timer? _authTimeoutTimer; + /// A [Future] that completes when the client has authenticated, or /// completes with an error if the client could not authenticate. Future get authenticated => _authenticated.future; @@ -676,6 +695,8 @@ class SSHClient { /// Shutdown the entire SSH connection. Sessions and channels will also be /// closed immediately. void close() { + _handshakeTimeoutTimer?.cancel(); + _authTimeoutTimer?.cancel(); _closeChannels(); _transport.close(); } @@ -692,11 +713,25 @@ class SSHClient { void _handleTransportReady() { printDebug?.call('SSHClient._onTransportReady'); + _transportReady = true; + _handshakeTimeoutTimer?.cancel(); + _handshakeTimeoutTimer = null; + + final authTimeout = this.authTimeout; + if (authTimeout != null) { + _authTimeoutTimer = Timer(authTimeout, _handleAuthTimeout); + } + _requestAuthentication(); } void _handleTransportClosed(SSHError? error) { printDebug?.call('SSHClient._onTransportClosed'); + _handshakeTimeoutTimer?.cancel(); + _handshakeTimeoutTimer = null; + _authTimeoutTimer?.cancel(); + _authTimeoutTimer = null; + if (!_authenticated.isCompleted) { _authenticated.completeError( SSHAuthAbortError('Connection closed before authentication', error), @@ -806,11 +841,31 @@ class SSHClient { void _handleUserauthSuccess() { printTrace?.call('<- $socket: SSH_Message_Userauth_Success'); printDebug?.call('SSHClient._handleUserauthSuccess'); + _authTimeoutTimer?.cancel(); + _authTimeoutTimer = null; _authenticated.complete(); onAuthenticated?.call(); _keepAlive?.start(); } + void _handleHandshakeTimeout() { + if (_authenticated.isCompleted || _transportReady) return; + + _handshakeTimeoutTimer = null; + final error = SSHHandshakeError('Handshake timed out'); + _authenticated.completeError(error, StackTrace.current); + } + + void _handleAuthTimeout() { + if (_authenticated.isCompleted) return; + + _authTimeoutTimer = null; + _authenticated.completeError( + SSHAuthAbortError('Authentication timed out'), + StackTrace.current, + ); + } + void _handleUserauthFailure(Uint8List payload) { final message = SSH_Message_Userauth_Failure.decode(payload); printTrace?.call('<- $socket: $message'); diff --git a/test/src/ssh_client_timeout_test.dart b/test/src/ssh_client_timeout_test.dart new file mode 100644 index 0000000..0cf7fd3 --- /dev/null +++ b/test/src/ssh_client_timeout_test.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:mirrors'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + final clientLibrary = reflectClass(SSHClient).owner as LibraryMirror; + Symbol privateSymbol(String name) => + MirrorSystem.getSymbol(name, clientLibrary); + + group('SSHClient timeouts', () { + test('fails authentication future when handshake times out', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + handshakeTimeout: const Duration(milliseconds: 10), + ); + + await expectLater( + client.authenticated, + throwsA(isA()), + ); + + client.close(); + }); + + test('fails authentication future when auth times out', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient( + socket, + username: 'demo', + authTimeout: const Duration(milliseconds: 10), + ); + + reflect(client).invoke(privateSymbol('_handleTransportReady'), const []); + + await expectLater( + client.authenticated, + throwsA(isA()), + ); + + client.close(); + }); + }); +} + +class _FakeSSHSocket implements SSHSocket { + final _inputController = StreamController(); + final _doneCompleter = Completer(); + final _sink = _RecordingSink(); + + @override + Stream get stream => _inputController.stream; + + @override + StreamSink> get sink => _sink; + + @override + Future get done => _doneCompleter.future; + + @override + Future close() async { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + await _inputController.close(); + } + + @override + void destroy() { + if (!_doneCompleter.isCompleted) { + _doneCompleter.complete(); + } + unawaited(_inputController.close()); + } +} + +class _RecordingSink implements StreamSink> { + @override + void add(List data) { + latin1.decode(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close() async {} + + @override + Future get done async {} +} From 23da78d3fbe2a0e6a0b6ad4f7dfdf3ebd330b0d8 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:16:32 +0200 Subject: [PATCH 15/32] feat: add optional handshakeTimeout and authTimeout to SSHClient --- CHANGELOG.md | 3 +++ README.md | 18 ++++++++++++++++++ pubspec.yaml | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d4075..a60a6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.22.0] - 2026-07-03 +- Added optional `handshakeTimeout` and `authTimeout` to `SSHClient` to limit connection negotiation and user authentication times [#182]. Thanks [@GT-610]. + ## [2.21.1] - 2026-07-02 - Fixed an `SSHTransport` busy-loop (100% CPU / ANR) that occurred when a partial packet remained in the read buffer [#179]. Thanks [@vicajilau]. diff --git a/README.md b/README.md index c713316..22662b5 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,24 @@ void main() async { `ident` defaults to `DartSSH_2.0`. +### Configure handshake and authentication timeouts + +You can specify optional timeouts for the transport handshake and user authentication: + +```dart +void main() async { + final client = SSHClient( + await SSHSocket.connect('localhost', 22), + username: '', + onPasswordRequest: () => '', + handshakeTimeout: const Duration(seconds: 15), + authTimeout: const Duration(seconds: 15), + ); +} +``` + +By default, these parameters are `null` (no timeout is enforced). Without these timeouts, the connection or authentication process could hang indefinitely if the remote server becomes unresponsive. + ### Spawn a shell on remote host ```dart diff --git a/pubspec.yaml b/pubspec.yaml index 6d46d98..0357813 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.21.1 +version: 2.22.0 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 From fa64eb72f64e3ca6f5d99efdb09eea01041269f6 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:10:09 +0200 Subject: [PATCH 16/32] fix(keepalive): prevent overlapping pings and catch errors --- lib/src/ssh_keepalive.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/src/ssh_keepalive.dart b/lib/src/ssh_keepalive.dart index a6bc75d..d60047f 100644 --- a/lib/src/ssh_keepalive.dart +++ b/lib/src/ssh_keepalive.dart @@ -9,6 +9,8 @@ class SSHKeepAlive { final Future Function() ping; + bool _isPinging = false; + SSHKeepAlive({ required this.ping, this.interval = const Duration(seconds: 10), @@ -16,7 +18,15 @@ class SSHKeepAlive { void start() { _timer ??= Timer.periodic(interval, (timer) async { - await ping(); + if (_isPinging) return; + _isPinging = true; + try { + await ping(); + } catch (_) { + // Ignore errors, the client transport will handle disconnection. + } finally { + _isPinging = false; + } }); } From 7de4469dbf22daad1983e72146f739ea48608d09 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:30:59 +0200 Subject: [PATCH 17/32] chore: added SSHKeepAlive tests --- test/src/ssh_keepalive_test.dart | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 test/src/ssh_keepalive_test.dart diff --git a/test/src/ssh_keepalive_test.dart b/test/src/ssh_keepalive_test.dart new file mode 100644 index 0000000..16815ac --- /dev/null +++ b/test/src/ssh_keepalive_test.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:dartssh2/src/ssh_keepalive.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHKeepAlive', () { + test('calls ping at specified interval', () async { + var pingCount = 0; + final completer = Completer(); + + final keepAlive = SSHKeepAlive( + interval: const Duration(milliseconds: 10), + ping: () async { + pingCount++; + if (pingCount >= 3) { + completer.complete(); + } + }, + ); + + keepAlive.start(); + await completer.future; + keepAlive.stop(); + + expect(pingCount, greaterThanOrEqualTo(3)); + }); + + test('prevents overlapping pings', () async { + var pingCount = 0; + var activePings = 0; + var maxActivePings = 0; + final completer = Completer(); + + final keepAlive = SSHKeepAlive( + interval: const Duration(milliseconds: 10), + ping: () async { + pingCount++; + activePings++; + if (activePings > maxActivePings) { + maxActivePings = activePings; + } + // Sleep longer than the interval to cause an overlapping tick + await Future.delayed(const Duration(milliseconds: 50)); + activePings--; + if (pingCount >= 2 && !completer.isCompleted) { + completer.complete(); + } + }, + ); + + keepAlive.start(); + // Wait for a few intervals. If overlapping wasn't prevented, activePings would exceed 1. + await Future.delayed(const Duration(milliseconds: 100)); + keepAlive.stop(); + + expect(maxActivePings, equals(1)); + }); + + test('handles ping errors and resets isPinging status', () async { + var pingCount = 0; + final completer = Completer(); + + final keepAlive = SSHKeepAlive( + interval: const Duration(milliseconds: 10), + ping: () async { + pingCount++; + if (pingCount == 1) { + throw Exception('ping failed'); + } + if (pingCount == 2) { + completer.complete(); + } + }, + ); + + keepAlive.start(); + await completer.future; + keepAlive.stop(); + + expect(pingCount, equals(2)); + }); + }); +} From 8b9252eb0fd8b20e1caab5f9ed05793ace533bbf Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:33:56 +0200 Subject: [PATCH 18/32] fix: resolve keepalive ping overlap and add error handling in version 2.22.1 --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a60a6d7..6e073a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.22.1] - 2026-07-13 +- Fixed a keepalive issue where overlapping pings could occur and caught errors during ping execution. Thanks [@vicajilau]. + ## [2.22.0] - 2026-07-03 - Added optional `handshakeTimeout` and `authTimeout` to `SSHClient` to limit connection negotiation and user authentication times [#182]. Thanks [@GT-610]. diff --git a/pubspec.yaml b/pubspec.yaml index 0357813..4e5db83 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.22.0 +version: 2.22.1 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 From d8df20ad629fa17dde6f663ac595005f51fa34b3 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:33:25 +0200 Subject: [PATCH 19/32] feat(socket): add flush() to SSHSocket, SSHClient and SSHChannel --- lib/src/socket/ssh_socket.dart | 3 +++ lib/src/socket/ssh_socket_io.dart | 5 +++++ lib/src/ssh_channel.dart | 10 ++++++++++ lib/src/ssh_client.dart | 6 ++++++ lib/src/ssh_forward.dart | 7 +++++++ lib/src/ssh_transport.dart | 5 +++++ test/src/http/http_client_test.dart | 3 +++ test/src/ssh_auth_abort_error_test.dart | 3 +++ test/src/ssh_client_forward_dynamic_test.dart | 3 +++ test/src/ssh_client_ident_test.dart | 3 +++ test/src/ssh_client_run_with_result_test.dart | 3 +++ test/src/ssh_client_timeout_test.dart | 3 +++ test/src/ssh_transport_aead_test.dart | 3 +++ test/src/ssh_transport_version_test.dart | 3 +++ 14 files changed, 60 insertions(+) diff --git a/lib/src/socket/ssh_socket.dart b/lib/src/socket/ssh_socket.dart index 13a1d07..0c369ad 100644 --- a/lib/src/socket/ssh_socket.dart +++ b/lib/src/socket/ssh_socket.dart @@ -28,4 +28,7 @@ abstract class SSHSocket { Future close(); void destroy(); + + /// Force flush any buffered outgoing data. + Future flush() async {} } diff --git a/lib/src/socket/ssh_socket_io.dart b/lib/src/socket/ssh_socket_io.dart index a8f155b..7815054 100644 --- a/lib/src/socket/ssh_socket_io.dart +++ b/lib/src/socket/ssh_socket_io.dart @@ -37,6 +37,11 @@ class _SSHNativeSocket implements SSHSocket { _socket.destroy(); } + @override + Future flush() async { + await _socket.flush(); + } + @override String toString() { final address = '${_socket.remoteAddress.host}:${_socket.remotePort}'; diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index 8a3387f..708145a 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -31,6 +31,8 @@ class SSHChannelController { SSHChannel get channel => SSHChannel(this); + final Future Function()? onFlush; + SSHChannelController({ required this.localId, required this.localMaximumPacketSize, @@ -39,6 +41,7 @@ class SSHChannelController { required this.remoteInitialWindowSize, required this.remoteMaximumPacketSize, required this.sendMessage, + this.onFlush, this.printDebug, }) { if (remoteInitialWindowSize > 0) { @@ -404,6 +407,10 @@ class SSHChannelController { _remoteWindow -= data.bytes.length; } }); + + Future flush() async { + await onFlush?.call(); + } } class SSHChannel { @@ -434,6 +441,9 @@ class SSHChannel { sink.add(SSHChannelData(data, type: type)); } + /// Force flush any buffered outgoing data on this channel to the socket. + Future flush() => _controller.flush(); + void setRequestHandler(SSHChannelRequestHandler handler) { _controller._requestHandler = handler; } diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index fcc1e44..630388a 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -701,6 +701,11 @@ class SSHClient { _transport.close(); } + /// Force flush any buffered outgoing data to the socket. + Future flush() async { + await _transport.flush(); + } + /// Close all channels that are currently open. void _closeChannels() { for (final channel in _channels.values) { @@ -1376,6 +1381,7 @@ class SSHClient { remoteInitialWindowSize: remoteInitialWindowSize, remoteMaximumPacketSize: remoteMaximumPacketSize, sendMessage: _sendMessage, + onFlush: flush, printDebug: printDebug, ); diff --git a/lib/src/ssh_forward.dart b/lib/src/ssh_forward.dart index 2a03f2c..c9d9936 100644 --- a/lib/src/ssh_forward.dart +++ b/lib/src/ssh_forward.dart @@ -76,6 +76,13 @@ class SSHForwardChannel implements SSHSocket { void destroy() { _channel.destroy(); } + + /// Force flush any buffered outgoing data. + @override + Future flush() async { + await Future.microtask(() {}); + await _channel.flush(); + } } class SSHX11Channel extends SSHForwardChannel { diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index d0e234f..ff46a42 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -464,6 +464,11 @@ class SSHTransport { socket.destroy(); } + /// Force flush any buffered outgoing data to the socket. + Future flush() async { + await socket.flush(); + } + /// Subscribes to the underlying socket stream to handle incoming data and status events. void _initSocket() { _socketSubscription = socket.stream.listen( diff --git a/test/src/http/http_client_test.dart b/test/src/http/http_client_test.dart index e683dfd..8e97b5d 100644 --- a/test/src/http/http_client_test.dart +++ b/test/src/http/http_client_test.dart @@ -300,4 +300,7 @@ class _FakeSocket implements SSHSocket { } unawaited(_sinkController.close()); } + + @override + Future flush() async {} } diff --git a/test/src/ssh_auth_abort_error_test.dart b/test/src/ssh_auth_abort_error_test.dart index e400574..fd0b774 100644 --- a/test/src/ssh_auth_abort_error_test.dart +++ b/test/src/ssh_auth_abort_error_test.dart @@ -75,6 +75,9 @@ class _FakeSSHSocket implements SSHSocket { } unawaited(_inputController.close()); } + + @override + Future flush() async {} } class _RecordingSink implements StreamSink> { diff --git a/test/src/ssh_client_forward_dynamic_test.dart b/test/src/ssh_client_forward_dynamic_test.dart index e1e3b89..e973e66 100644 --- a/test/src/ssh_client_forward_dynamic_test.dart +++ b/test/src/ssh_client_forward_dynamic_test.dart @@ -64,6 +64,9 @@ class _FakeSSHSocket implements SSHSocket { } unawaited(_inputController.close()); } + + @override + Future flush() async {} } class _NoopSink implements StreamSink> { diff --git a/test/src/ssh_client_ident_test.dart b/test/src/ssh_client_ident_test.dart index ea6c57b..2b792de 100644 --- a/test/src/ssh_client_ident_test.dart +++ b/test/src/ssh_client_ident_test.dart @@ -100,6 +100,9 @@ class _FakeSSHSocket implements SSHSocket { } unawaited(_inputController.close()); } + + @override + Future flush() async {} } class _RecordingSink implements StreamSink> { diff --git a/test/src/ssh_client_run_with_result_test.dart b/test/src/ssh_client_run_with_result_test.dart index 006d092..66953fc 100644 --- a/test/src/ssh_client_run_with_result_test.dart +++ b/test/src/ssh_client_run_with_result_test.dart @@ -284,6 +284,9 @@ class _FakeSSHSocket implements SSHSocket { } unawaited(_inputController.close()); } + + @override + Future flush() async {} } class _NoopSink implements StreamSink> { diff --git a/test/src/ssh_client_timeout_test.dart b/test/src/ssh_client_timeout_test.dart index 0cf7fd3..41ba404 100644 --- a/test/src/ssh_client_timeout_test.dart +++ b/test/src/ssh_client_timeout_test.dart @@ -77,6 +77,9 @@ class _FakeSSHSocket implements SSHSocket { } unawaited(_inputController.close()); } + + @override + Future flush() async {} } class _RecordingSink implements StreamSink> { diff --git a/test/src/ssh_transport_aead_test.dart b/test/src/ssh_transport_aead_test.dart index 36b4f7f..09317f8 100644 --- a/test/src/ssh_transport_aead_test.dart +++ b/test/src/ssh_transport_aead_test.dart @@ -548,6 +548,9 @@ class _CaptureSSHSocket implements SSHSocket { } unawaited(_inputController.close()); } + + @override + Future flush() async {} } class _CaptureSink implements StreamSink> { diff --git a/test/src/ssh_transport_version_test.dart b/test/src/ssh_transport_version_test.dart index 848c18d..8627b15 100644 --- a/test/src/ssh_transport_version_test.dart +++ b/test/src/ssh_transport_version_test.dart @@ -133,6 +133,9 @@ class _FakeSSHSocket implements SSHSocket { } unawaited(_inputController.close()); } + + @override + Future flush() async {} } class _RecordingSink implements StreamSink> { From 7cac210f9bf2b83a50e2f342c22982c18ee25f68 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:36:41 +0200 Subject: [PATCH 20/32] docs(changelog): add entry for 2.22.2 with flush() changes --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e073a4..6c6f577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.22.2] - 2026-07-14 +- Added `flush()` to `SSHSocket`, `SSHClient`, and `SSHChannel` to allow force flushing of buffered outgoing data [#183]. Thanks [@vicajilau]. + ## [2.22.1] - 2026-07-13 - Fixed a keepalive issue where overlapping pings could occur and caught errors during ping execution. Thanks [@vicajilau]. From f8cac951da62b2a12ee83e0d48558ead182613be Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:37:23 +0200 Subject: [PATCH 21/32] chore: update release dates in changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e073a4..e5cad84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ -## [2.22.1] - 2026-07-13 +## [2.22.1] - 2026-07-14 - Fixed a keepalive issue where overlapping pings could occur and caught errors during ping execution. Thanks [@vicajilau]. -## [2.22.0] - 2026-07-03 +## [2.22.0] - 2026-07-13 - Added optional `handshakeTimeout` and `authTimeout` to `SSHClient` to limit connection negotiation and user authentication times [#182]. Thanks [@GT-610]. ## [2.21.1] - 2026-07-02 From f2e811822b0600bf68ab65f64119bfd378c96213 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:40:42 +0200 Subject: [PATCH 22/32] chore: update release date for version 2.22.0 in CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40250d2..6c6f577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## [2.22.1] - 2026-07-13 - Fixed a keepalive issue where overlapping pings could occur and caught errors during ping execution. Thanks [@vicajilau]. -## [2.22.0] - 2026-07-13 +## [2.22.0] - 2026-07-03 - Added optional `handshakeTimeout` and `authTimeout` to `SSHClient` to limit connection negotiation and user authentication times [#182]. Thanks [@GT-610]. ## [2.21.1] - 2026-07-02 From 1047270a2c773f1f529ae1ba98da8fde7cd673fd Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:51:09 +0200 Subject: [PATCH 23/32] test: add unit tests for flush() to cover new API --- lib/src/ssh_session.dart | 9 +++++++++ test/src/ssh_client_test.dart | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/src/ssh_session.dart b/lib/src/ssh_session.dart index fe1514e..126fa1b 100644 --- a/lib/src/ssh_session.dart +++ b/lib/src/ssh_session.dart @@ -32,6 +32,9 @@ class SSHSession { /// be available on the [stdout] and [stderr] streams at this time. Future get done => _channel.done; + /// The underlying SSH channel. + SSHChannel get channel => _channel; + SSHSession(this._channel) { _channel.setRequestHandler(_handleRequest); @@ -79,6 +82,12 @@ class SSHSession { stdin.add(data); } + /// Force flush any buffered stdin data to the remote process. + Future flush() async { + await Future.microtask(() {}); + await _channel.flush(); + } + /// Inform remote process of the current window size. void resizeTerminal( int width, diff --git a/test/src/ssh_client_test.dart b/test/src/ssh_client_test.dart index 12a6f2e..2901233 100644 --- a/test/src/ssh_client_test.dart +++ b/test/src/ssh_client_test.dart @@ -4,6 +4,7 @@ library; import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; import 'package:test/test.dart'; import '../test_utils.dart'; @@ -264,4 +265,32 @@ void main() { client.close(); }); }); + + group('SSHClient.flush', () { + test('can flush client, session, channel, and forward channel', () async { + final client = await getTestClient(); + await client.authenticated; + + await client.flush(); + + final session = await client.execute('echo flush_test'); + await session.flush(); + + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (msg) {}, + onFlush: () async {}, + ); + final forwardChannel = SSHForwardChannel(controller.channel); + await forwardChannel.flush(); + + await session.done; + client.close(); + }); + }); } From f83aae1f6ed11812268f1aa11093b91e0e2abd28 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:07:58 +0200 Subject: [PATCH 24/32] test: add unit tests for flush functionality across sockets, transport, channels, and sessions --- test/src/ssh_flush_test.dart | 180 +++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 test/src/ssh_flush_test.dart diff --git a/test/src/ssh_flush_test.dart b/test/src/ssh_flush_test.dart new file mode 100644 index 0000000..9c53f5b --- /dev/null +++ b/test/src/ssh_flush_test.dart @@ -0,0 +1,180 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:mirrors'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/ssh_channel.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHSocket base class', () { + test('default flush implementation completes normally', () async { + final socket = _TestSSHSocket(); + await expectLater(socket.flush(), completes); + }); + }); + + group('SSHNativeSocket', () { + test('native socket flush works', () async { + final server = await ServerSocket.bind('127.0.0.1', 0); + final socket = await SSHSocket.connect('127.0.0.1', server.port); + await socket.flush(); + await socket.close(); + await server.close(); + }); + }); + + group('SSHTransport.flush', () { + test('delegates to socket.flush', () async { + final socket = _FakeSSHSocket(); + final transport = SSHTransport(socket); + await transport.flush(); + expect(socket.flushCount, 1); + transport.close(); + }); + }); + + group('SSHClient.flush and channel callback delegation', () { + test('delegates to transport and sets up onFlush', () async { + final socket = _FakeSSHSocket(); + final client = SSHClient(socket, username: 'demo'); + await client.flush(); + expect(socket.flushCount, 1); + + final clientLibrary = reflectClass(SSHClient).owner as LibraryMirror; + Symbol privateSymbol(String name) => + MirrorSystem.getSymbol(name, clientLibrary); + + // Invoke _acceptChannel using reflection to verify onFlush setup. + final channelController = reflect(client).invoke( + privateSymbol('_acceptChannel'), + [], + { + #localChannelId: 1, + #remoteChannelId: 2, + #remoteInitialWindowSize: 1024, + #remoteMaximumPacketSize: 1024, + }, + ).reflectee as SSHChannelController; + + expect(channelController.onFlush, isNotNull); + await channelController.flush(); + expect(socket.flushCount, 2); + + client.close(); + }); + }); + + group('SSHChannel.flush', () { + test('delegates to controller.flush', () async { + var flushed = false; + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (msg) {}, + onFlush: () async { + flushed = true; + }, + ); + final channel = controller.channel; + await channel.flush(); + expect(flushed, isTrue); + }); + }); + + group('SSHSession.flush', () { + test('exposes channel and delegates flush', () async { + var flushed = false; + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (msg) {}, + onFlush: () async { + flushed = true; + }, + ); + final channel = controller.channel; + final session = SSHSession(channel); + expect(session.channel, channel); + + await session.flush(); + expect(flushed, isTrue); + }); + }); + + group('SSHForwardChannel.flush', () { + test('delegates to channel.flush', () async { + var flushed = false; + final controller = SSHChannelController( + localId: 1, + localMaximumPacketSize: 1024, + localInitialWindowSize: 1024, + remoteId: 2, + remoteMaximumPacketSize: 1024, + remoteInitialWindowSize: 1024, + sendMessage: (msg) {}, + onFlush: () async { + flushed = true; + }, + ); + final forwardChannel = SSHForwardChannel(controller.channel); + await forwardChannel.flush(); + expect(flushed, isTrue); + }); + }); +} + +class _TestSSHSocket extends SSHSocket { + @override + Stream get stream => throw UnimplementedError(); + + @override + StreamSink> get sink => throw UnimplementedError(); + + @override + Future get done => throw UnimplementedError(); + + @override + Future close() => throw UnimplementedError(); + + @override + void destroy() => throw UnimplementedError(); +} + +class _FakeSSHSocket implements SSHSocket { + int flushCount = 0; + final _streamController = StreamController(); + final _sinkController = StreamController>(); + + @override + Stream get stream => _streamController.stream; + + @override + StreamSink> get sink => _sinkController.sink; + + @override + Future get done => _streamController.done; + + @override + Future close() async { + await _streamController.close(); + await _sinkController.close(); + } + + @override + void destroy() {} + + @override + Future flush() async { + flushCount++; + } +} From f0c06de498032d9e642660a6793eae849060abf7 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:06:05 +0200 Subject: [PATCH 25/32] chore: bump version to 2.22.2 and update changelog date --- CHANGELOG.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6f577..fdd58f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [2.22.2] - 2026-07-14 +## [2.22.2] - 2026-07-15 - Added `flush()` to `SSHSocket`, `SSHClient`, and `SSHChannel` to allow force flushing of buffered outgoing data [#183]. Thanks [@vicajilau]. ## [2.22.1] - 2026-07-13 diff --git a/pubspec.yaml b/pubspec.yaml index 4e5db83..5d424a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.22.1 +version: 2.22.2 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 From 3e9ab0e319d451fb962bc0602f0b95a20e7e5e1b Mon Sep 17 00:00:00 2001 From: keinstn Date: Mon, 20 Jul 2026 15:45:29 +0900 Subject: [PATCH 26/32] fix(sftp): close the underlying channel in SftpClient.close() SftpClient.close() completed its internal futures but never closed the SSH channel the sftp subsystem runs on. Because SSHClient.sftp() opens a fresh session channel on every call, an application that opens an sftp session per operation leaks one open channel each time. These accumulate on the connection until the server refuses further CHANNEL_OPENs (e.g. CHANNEL_OPEN_FAILURE / a per-connection session limit). Close the channel when the session is closed, make close() async so callers can await the teardown, and guard against double-close so the method is idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/sftp/sftp_client.dart | 10 ++++++- test/src/sftp/sftp_client_protocol_test.dart | 31 ++++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/src/sftp/sftp_client.dart b/lib/src/sftp/sftp_client.dart index be6102d..95d028b 100644 --- a/lib/src/sftp/sftp_client.dart +++ b/lib/src/sftp/sftp_client.dart @@ -228,12 +228,20 @@ class SftpClient { } /// Close the sftp session. - void close() { + /// + /// This also closes the underlying SSH channel that the sftp subsystem runs + /// on. Without this the channel is leaked: every [SSHClient.sftp] call opens + /// a fresh session channel, so an application that opens an sftp session per + /// operation would accumulate open channels on the connection until the + /// server refuses further `CHANNEL_OPEN`s. + Future close() async { + if (_done.isCompleted) return; for (var waiter in _replyWaiters.values) { waiter.completeError(SftpAbortError("Connection closed")); } _replyWaiters.clear(); _done.complete(); + await _channel.close(); } void _closeError(Object error, [StackTrace? stackTrace]) { diff --git a/test/src/sftp/sftp_client_protocol_test.dart b/test/src/sftp/sftp_client_protocol_test.dart index f619945..756e381 100644 --- a/test/src/sftp/sftp_client_protocol_test.dart +++ b/test/src/sftp/sftp_client_protocol_test.dart @@ -93,12 +93,27 @@ void main() { final openFuture = harness.client.open('/tmp/f'); await harness.nextOutgoingPacket(); - harness.client.close(); + unawaited(harness.client.close()); await expectLater(openFuture, throwsA(isA())); harness.dispose(); }); + test('close closes the underlying channel', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + harness.sendResponsePacket(SftpVersionPacket(3)); + await harness.client.handshake; + + final closeFuture = harness.client.close(); + // The server acks the channel close, letting the teardown complete. + harness.closeRemote(); + await closeFuture; + + await expectLater(harness.channelDone, completes); + harness.dispose(); + }); + test('download keeps chunk order with pipelined reads', () async { final harness = _SftpHarness(); await harness.nextOutgoingPacket(); @@ -546,6 +561,14 @@ class _SftpHarness { Future nextOutgoingPacket() => _outgoing.stream.first; + Future get channelDone => _controller.channel.done; + + void closeRemote() { + _controller.handleMessage( + SSH_Message_Channel_Close(recipientChannel: _controller.localId), + ); + } + void sendResponsePacket(SftpPacket packet) { final payload = packet.encode(); final writer = SSHMessageWriter(); @@ -564,11 +587,7 @@ class _SftpHarness { if (_disposed) return; _disposed = true; - try { - client.close(); - } catch (_) { - // SftpClient.close is not idempotent when already completed with error. - } + unawaited(client.close()); _controller.destroy(); _outgoing.close(); } From 90e5272dee4aa2763b207ef4a46e6b7f8b633df1 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:36:36 +0200 Subject: [PATCH 27/32] fix: resolve SSH channel leak in SftpClient.close() and update version to 2.22.3 --- CHANGELOG.md | 3 +++ pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd58f4..658e5f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.22.3] - 2026-07-20 +- Fixed an SSH channel leak in `SftpClient.close()` by closing the underlying SSH channel and returning `Future` to allow awaiting channel teardown [#186]. Thanks [@keinstn]. + ## [2.22.2] - 2026-07-15 - Added `flush()` to `SSHSocket`, `SSHClient`, and `SSHChannel` to allow force flushing of buffered outgoing data [#183]. Thanks [@vicajilau]. diff --git a/pubspec.yaml b/pubspec.yaml index 5d424a9..6ffbeaa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.22.2 +version: 2.22.3 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 From e09f8db75da590061573fa8d78faa1598725c9a2 Mon Sep 17 00:00:00 2001 From: Lubos Petrovic Date: Mon, 27 Jul 2026 15:16:47 +0200 Subject: [PATCH 28/32] Support the RFC 8731 name curve25519-sha256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kex is offered under two names: the original curve25519-sha256@libssh.org and the RFC 8731 name curve25519-sha256. Only the former was advertised, so a server hardened down to a single kex (KexAlgorithms curve25519-sha256, a common hardening recipe) shares no name with us and the handshake fails with "no matching key exchange method found" — even though both sides implement the very same X25519 exchange. Add SSHKexType.x25519Rfc with the RFC name, offer it right after the libssh.org spelling, and route it to the same SSHKexX25519 in the transport switch. No wire-format change: same digest, same exchange. --- lib/src/algorithm/ssh_kex_type.dart | 9 +++++++++ lib/src/ssh_algorithm.dart | 1 + lib/src/ssh_transport.dart | 1 + test/src/algorithm/ssh_cipher_type_test.dart | 1 + 4 files changed, 12 insertions(+) diff --git a/lib/src/algorithm/ssh_kex_type.dart b/lib/src/algorithm/ssh_kex_type.dart index 2866319..420a749 100644 --- a/lib/src/algorithm/ssh_kex_type.dart +++ b/lib/src/algorithm/ssh_kex_type.dart @@ -7,6 +7,15 @@ class SSHKexType extends SSHAlgorithm { digestFactory: digestSha256, ); + /// RFC 8731 name for the same algorithm as [x25519]. Servers hardened to a + /// single kex commonly offer only this spelling, and OpenSSH matches names + /// literally — without it the handshake dies with "no matching key exchange + /// method found". + static const x25519Rfc = SSHKexType._( + name: 'curve25519-sha256', + digestFactory: digestSha256, + ); + static const nistp256 = SSHKexType._( name: 'ecdh-sha2-nistp256', digestFactory: digestSha256, diff --git a/lib/src/ssh_algorithm.dart b/lib/src/ssh_algorithm.dart index fc57995..1107647 100644 --- a/lib/src/ssh_algorithm.dart +++ b/lib/src/ssh_algorithm.dart @@ -46,6 +46,7 @@ class SSHAlgorithms { const SSHAlgorithms({ this.kex = const [ SSHKexType.x25519, + SSHKexType.x25519Rfc, SSHKexType.nistp521, SSHKexType.nistp384, SSHKexType.nistp256, diff --git a/lib/src/ssh_transport.dart b/lib/src/ssh_transport.dart index ff46a42..c61ce10 100644 --- a/lib/src/ssh_transport.dart +++ b/lib/src/ssh_transport.dart @@ -1196,6 +1196,7 @@ class SSHTransport { switch (_kexType) { case SSHKexType.x25519: + case SSHKexType.x25519Rfc: _kex = await SSHKexX25519.createAsync(); break; case SSHKexType.nistp256: diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index 132b226..a13b575 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -106,6 +106,7 @@ void main() { algorithms.kex, equals([ SSHKexType.x25519, + SSHKexType.x25519Rfc, SSHKexType.nistp521, SSHKexType.nistp384, SSHKexType.nistp256, From 6213203bbfe852bbe956aa284e4ab92d54f926c4 Mon Sep 17 00:00:00 2001 From: "Victor C." <34163765+vicajilau@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:41:26 +0200 Subject: [PATCH 29/32] feat: prioritize RFC 8731 curve25519-sha256 key exchange in algorithm list --- CHANGELOG.md | 3 +++ lib/src/ssh_algorithm.dart | 2 +- pubspec.yaml | 2 +- test/src/algorithm/ssh_cipher_type_test.dart | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 658e5f6..5e1bff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.22.4] - 2026-07-27 +- Advertised standard RFC 8731 key exchange name `curve25519-sha256` alongside legacy `curve25519-sha256@libssh.org` [#187]. Thanks [@nickn17]. + ## [2.22.3] - 2026-07-20 - Fixed an SSH channel leak in `SftpClient.close()` by closing the underlying SSH channel and returning `Future` to allow awaiting channel teardown [#186]. Thanks [@keinstn]. diff --git a/lib/src/ssh_algorithm.dart b/lib/src/ssh_algorithm.dart index 1107647..348ec17 100644 --- a/lib/src/ssh_algorithm.dart +++ b/lib/src/ssh_algorithm.dart @@ -45,8 +45,8 @@ class SSHAlgorithms { const SSHAlgorithms({ this.kex = const [ - SSHKexType.x25519, SSHKexType.x25519Rfc, + SSHKexType.x25519, SSHKexType.nistp521, SSHKexType.nistp384, SSHKexType.nistp256, diff --git a/pubspec.yaml b/pubspec.yaml index 6ffbeaa..cd66873 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.22.3 +version: 2.22.4 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index a13b575..0163af9 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -105,8 +105,8 @@ void main() { expect( algorithms.kex, equals([ - SSHKexType.x25519, SSHKexType.x25519Rfc, + SSHKexType.x25519, SSHKexType.nistp521, SSHKexType.nistp384, SSHKexType.nistp256, From a2de640f6922c57230ea50165df9823f64fbc495 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:07:48 +0200 Subject: [PATCH 30/32] fix: export ssh_userauth.dart in public API (#188) --- CHANGELOG.md | 4 ++++ lib/dartssh2.dart | 1 + pubspec.yaml | 2 +- test/src/ssh_userauth_export_test.dart | 22 ++++++++++++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 test/src/ssh_userauth_export_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1bff3..defa5e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.22.5] - 2026-07-30 +- Exported `src/ssh_userauth.dart` in `lib/dartssh2.dart` to expose `SSHUserInfoRequest`, `SSHUserInfoPrompt`, `SSHAuthMethod`, and `SSHChangePasswordResponse` [#188]. Thanks [@vicajilau]. + ## [2.22.4] - 2026-07-27 - Advertised standard RFC 8731 key exchange name `curve25519-sha256` alongside legacy `curve25519-sha256@libssh.org` [#187]. Thanks [@nickn17]. @@ -279,6 +282,7 @@ [#175]: https://github.com/TerminalStudio/dartssh2/pull/175 [#176]: https://github.com/TerminalStudio/dartssh2/pull/176 [#1]: https://github.com/TerminalStudio/dartssh/pull/1/files +[#188]: https://github.com/TerminalStudio/dartssh2/issues/188 [@linhanyu]: https://github.com/linhanyu [@Migarl]: https://github.com/Migarl diff --git a/lib/dartssh2.dart b/lib/dartssh2.dart index 031459f..a564fed 100644 --- a/lib/dartssh2.dart +++ b/lib/dartssh2.dart @@ -8,6 +8,7 @@ export 'src/ssh_pem.dart'; export 'src/ssh_session.dart'; export 'src/ssh_signal.dart'; export 'src/ssh_transport.dart'; +export 'src/ssh_userauth.dart'; export 'src/socket/ssh_socket.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index cd66873..02c9131 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.22.4 +version: 2.22.5 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 diff --git a/test/src/ssh_userauth_export_test.dart b/test/src/ssh_userauth_export_test.dart new file mode 100644 index 0000000..789f70b --- /dev/null +++ b/test/src/ssh_userauth_export_test.dart @@ -0,0 +1,22 @@ +import 'package:dartssh2/dartssh2.dart'; +import 'package:test/test.dart'; + +void main() { + test('SSHUserInfoRequest and related userauth classes are exported by dartssh2.dart', () { + final prompt = SSHUserInfoPrompt('Password:', false); + expect(prompt.promptText, equals('Password:')); + expect(prompt.echo, isFalse); + + final request = SSHUserInfoRequest('Title', 'Instruction', [prompt]); + expect(request.name, equals('Title')); + expect(request.instruction, equals('Instruction')); + expect(request.prompts.length, equals(1)); + expect(request.prompts.first.promptText, equals('Password:')); + + final changePasswordResponse = SSHChangePasswordResponse('old', 'new'); + expect(changePasswordResponse.oldPassword, equals('old')); + expect(changePasswordResponse.newPassword, equals('new')); + + expect(SSHAuthMethod.keyboardInteractive.name, equals('keyboard-interactive')); + }); +} From 6e37986eb68d4a96b8931334636a8e4938cb0647 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:09:55 +0200 Subject: [PATCH 31/32] style: apply line formatting to SSH userauth export tests --- test/src/ssh_userauth_export_test.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/src/ssh_userauth_export_test.dart b/test/src/ssh_userauth_export_test.dart index 789f70b..683f788 100644 --- a/test/src/ssh_userauth_export_test.dart +++ b/test/src/ssh_userauth_export_test.dart @@ -2,7 +2,9 @@ import 'package:dartssh2/dartssh2.dart'; import 'package:test/test.dart'; void main() { - test('SSHUserInfoRequest and related userauth classes are exported by dartssh2.dart', () { + test( + 'SSHUserInfoRequest and related userauth classes are exported by dartssh2.dart', + () { final prompt = SSHUserInfoPrompt('Password:', false); expect(prompt.promptText, equals('Password:')); expect(prompt.echo, isFalse); @@ -17,6 +19,7 @@ void main() { expect(changePasswordResponse.oldPassword, equals('old')); expect(changePasswordResponse.newPassword, equals('new')); - expect(SSHAuthMethod.keyboardInteractive.name, equals('keyboard-interactive')); + expect( + SSHAuthMethod.keyboardInteractive.name, equals('keyboard-interactive')); }); } From 98801732b0e0b792cd8ca537911785381deac346 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 2 Aug 2026 17:04:20 +0800 Subject: [PATCH 32/32] fix: address v2.22.5 merge review findings Guarantee forward and channel flush ordering with explicit upload barriers, abort pending SFTP handshakes on close, and strengthen timeout documentation and regression coverage for transport, forwarding, agent, and KEX behavior. --- CHANGELOG.md | 7 +- lib/src/http/http_date.dart | 2 +- lib/src/kex/kex_nist.dart | 7 +- lib/src/sftp/sftp_client.dart | 6 +- lib/src/ssh_channel.dart | 89 ++++++++++++++++++-- lib/src/ssh_client.dart | 28 +++++- lib/src/ssh_forward.dart | 83 ++++++++++++++++-- test/src/algorithm/ssh_cipher_type_test.dart | 3 + test/src/sftp/sftp_client_protocol_test.dart | 16 ++++ test/src/socket/dynamic_forward_io_test.dart | 25 ++++-- test/src/ssh_agent_test.dart | 4 +- test/src/ssh_flush_test.dart | 41 +++++---- test/src/ssh_transport_version_test.dart | 24 +++++- 13 files changed, 277 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff67cd..5f6bc6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -282,6 +282,11 @@ [#14]: https://github.com/TerminalStudio/dartssh2/pull/14 [#175]: https://github.com/TerminalStudio/dartssh2/pull/175 [#176]: https://github.com/TerminalStudio/dartssh2/pull/176 +[#179]: https://github.com/TerminalStudio/dartssh2/pull/179 +[#182]: https://github.com/TerminalStudio/dartssh2/pull/182 +[#183]: https://github.com/TerminalStudio/dartssh2/pull/183 +[#186]: https://github.com/TerminalStudio/dartssh2/pull/186 +[#187]: https://github.com/TerminalStudio/dartssh2/pull/187 [#1]: https://github.com/TerminalStudio/dartssh/pull/1/files [#188]: https://github.com/TerminalStudio/dartssh2/issues/188 @@ -301,4 +306,4 @@ [@Wackymax]: https://github.com/Wackymax [@gkc]: https://github.com/gkc [@vicajilau]: https://github.com/vicajilau -[@GT-610]: https://github.com/GT-610 \ No newline at end of file +[@GT-610]: https://github.com/GT-610 diff --git a/lib/src/http/http_date.dart b/lib/src/http/http_date.dart index 12f8a33..26fd6fc 100644 --- a/lib/src/http/http_date.dart +++ b/lib/src/http/http_date.dart @@ -87,4 +87,4 @@ DateTime? parseHttpDate(String input) { } return null; -} \ No newline at end of file +} diff --git a/lib/src/kex/kex_nist.dart b/lib/src/kex/kex_nist.dart index 848d520..c9d8285 100644 --- a/lib/src/kex/kex_nist.dart +++ b/lib/src/kex/kex_nist.dart @@ -128,9 +128,10 @@ String _getNameByCurve(ECDomainParameters curve) { late BigInt x; do { x = decodeBigIntWithSign( - 1, - randomBytes((secretBits + 7) ~/ 8), - ) % curve.n; + 1, + randomBytes((secretBits + 7) ~/ 8), + ) % + curve.n; } while (x == BigInt.zero); final c = curve.G * x; diff --git a/lib/src/sftp/sftp_client.dart b/lib/src/sftp/sftp_client.dart index 7e74d39..ecf05b4 100644 --- a/lib/src/sftp/sftp_client.dart +++ b/lib/src/sftp/sftp_client.dart @@ -266,10 +266,14 @@ class SftpClient { /// server refuses further `CHANNEL_OPEN`s. Future close() async { if (_done.isCompleted) return; + final error = SftpAbortError("Connection closed"); for (var waiter in _replyWaiters.values) { - waiter.completeError(SftpAbortError("Connection closed")); + waiter.completeError(error); } _replyWaiters.clear(); + if (!_handshake.isCompleted) { + _handshake.completeError(error, StackTrace.current); + } _done.complete(); await _channel.close(); } diff --git a/lib/src/ssh_channel.dart b/lib/src/ssh_channel.dart index 76e12e0..071a0bf 100644 --- a/lib/src/ssh_channel.dart +++ b/lib/src/ssh_channel.dart @@ -65,8 +65,13 @@ class SSHChannelController { /// A [StreamController] that accepts data from local end of the channel. final _localStream = StreamController(); + late final StreamSink _localSink = + _SSHChannelSink(_localStream); + late final _localStreamConsumer = SSHChannelDataConsumer(_localStream.stream); + SSHChannelData? _pendingUploadData; + /// Handler of channel requests from the remote side. late var _requestHandler = _defaultRequestHandler; @@ -86,6 +91,8 @@ class SSHChannelController { final _done = Completer(); + final _flushBarriers = >{}; + Future sendExec(String command) async { sendMessage( SSH_Message_Channel_Request.exec( @@ -236,6 +243,7 @@ class SSHChannelController { Future close() async { if (_done.isCompleted) return; + _failFlushBarriers(StateError('Channel closed before flush completed')); _localStreamConsumer.cancel(); _sendEOFIfNeeded(); @@ -253,6 +261,7 @@ class SSHChannelController { /// received. void destroy() { if (_done.isCompleted) return; + _failFlushBarriers(StateError('Channel destroyed before flush completed')); _remoteStream.close(); _localStreamConsumer.cancel(); _sendEOFIfNeeded(); @@ -412,13 +421,17 @@ class SSHChannelController { late final _uploadLoop = OnceSimultaneously(() async { while (true) { - if (_remoteWindow <= 0) { - return; + SSHChannelData? data; + if (_pendingUploadData != null) { + if (_remoteWindow <= 0) return; + data = _pendingUploadData; + _pendingUploadData = null; + } else { + final dataToRead = + _remoteWindow > 0 ? min(_remoteWindow, remoteMaximumPacketSize) : 1; + data = await _localStreamConsumer.read(dataToRead); } - final dataToRead = min(_remoteWindow, remoteMaximumPacketSize); - final data = await _localStreamConsumer.read(dataToRead); - if (data == null) { _sendEOFIfNeeded(); @@ -428,6 +441,19 @@ class SSHChannelController { return; } + if (data is _SSHChannelFlushBarrier) { + _flushBarriers.remove(data.completer); + if (!data.completer.isCompleted) { + data.completer.complete(); + } + continue; + } + + if (_remoteWindow <= 0) { + _pendingUploadData = data; + return; + } + if (_hasSentEOF) { return; } @@ -452,8 +478,24 @@ class SSHChannelController { }); Future flush() async { + if (!_done.isCompleted) { + final barrier = Completer(); + _flushBarriers.add(barrier); + _localStream.add(_SSHChannelFlushBarrier(barrier)); + _uploadLoop.activate(); + await barrier.future; + } await onFlush?.call(); } + + void _failFlushBarriers(Object error) { + for (final barrier in _flushBarriers) { + if (!barrier.isCompleted) { + barrier.completeError(error, StackTrace.current); + } + } + _flushBarriers.clear(); + } } class SSHChannel { @@ -471,7 +513,7 @@ class SSHChannel { /// A [StreamSink] that sends data to the remote side. Chucks must be /// equal to or less than [maximumPacketSize]. - StreamSink get sink => _controller._localStream.sink; + StreamSink get sink => _controller._localSink; Future get done => _controller._done.future; @@ -557,6 +599,41 @@ class SSHChannelData { SSHChannelData(this.bytes, {this.type}); } +class _SSHChannelFlushBarrier extends SSHChannelData { + _SSHChannelFlushBarrier(this.completer) : super(Uint8List(0)); + + final Completer completer; +} + +class _SSHChannelSink implements StreamSink { + _SSHChannelSink(this._controller); + + final StreamController _controller; + + @override + void add(SSHChannelData data) { + _controller.add(data); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _controller.addError(error, stackTrace); + } + + @override + Future addStream(Stream stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close() => _controller.close(); + + @override + Future get done => _controller.done; +} + class SSHChannelExtendedDataType { static const stderr = 1; } diff --git a/lib/src/ssh_client.dart b/lib/src/ssh_client.dart index 23754bf..58c983a 100644 --- a/lib/src/ssh_client.dart +++ b/lib/src/ssh_client.dart @@ -125,11 +125,12 @@ class SSHRunResult { } class SSHClient { - /// RFC 4252 recommended authentication timeout period + /// Opt-in RFC 4252 authentication timeout preset. This is not applied + /// automatically; pass it as [authTimeout] to enable it. static const Duration defaultAuthTimeout = Duration(minutes: 10); - /// Default handshake timeout. Separates transport handshake timeout from - /// authentication timeout for better robustness. + /// Opt-in handshake timeout preset. This is not applied automatically; pass + /// it as [handshakeTimeout] to enable it. static const Duration defaultHandshakeTimeout = Duration(seconds: 30); /// RFC 4252 recommended maximum authentication attempts per session @@ -203,10 +204,14 @@ class SSHClient { /// Username of clinet host used for hostbased authentication. final String? userNameOnClientHost; - /// Maximum time to wait for the SSH transport handshake to complete. + /// Maximum time to wait for the SSH transport handshake to complete. This is + /// null unless explicitly provided; [defaultHandshakeTimeout] is an opt-in + /// preset. final Duration? handshakeTimeout; /// Maximum time to wait for authentication after the transport is ready. + /// This is null unless explicitly provided; [defaultAuthTimeout] is an + /// opt-in preset. final Duration? authTimeout; /// Max auth attempts, 20 by default. @@ -845,6 +850,21 @@ class SSHClient { @visibleForTesting void handlePacket(Uint8List packet) => _handlePacket(packet); + @visibleForTesting + SSHChannelController acceptChannelForTesting({ + required SSHChannelId localChannelId, + required SSHChannelId remoteChannelId, + required int remoteInitialWindowSize, + required int remoteMaximumPacketSize, + }) { + return _acceptChannel( + localChannelId: localChannelId, + remoteChannelId: remoteChannelId, + remoteInitialWindowSize: remoteInitialWindowSize, + remoteMaximumPacketSize: remoteMaximumPacketSize, + ); + } + void _sendMessage(SSHMessage message) { printTrace?.call('-> $socket: $message'); _transport.sendPacket(message.encode()); diff --git a/lib/src/ssh_forward.dart b/lib/src/ssh_forward.dart index c9d9936..3e6b987 100644 --- a/lib/src/ssh_forward.dart +++ b/lib/src/ssh_forward.dart @@ -46,13 +46,27 @@ class SSHForwardChannel implements SSHSocket { final SSHChannel _channel; SSHForwardChannel(this._channel) { - _sinkController.stream - .map((data) => data is Uint8List ? data : Uint8List.fromList(data)) - .map((data) => SSHChannelData(data)) - .pipe(_channel.sink); + _sinkController.stream.listen( + (event) { + if (event is _SSHForwardData) { + final data = event.data is Uint8List + ? event.data as Uint8List + : Uint8List.fromList(event.data); + _channel.sink.add(SSHChannelData(data)); + return; + } + + final barrier = event as _SSHForwardFlushBarrier; + unawaited(_completeFlushBarrier(barrier.completer)); + }, + onError: _channel.sink.addError, + onDone: _channel.sink.close, + ); } - final _sinkController = StreamController>(); + final _sinkController = StreamController<_SSHForwardUploadEvent>(); + + late final StreamSink> _sink = _SSHForwardSink(_sinkController); /// Data received from the remote host. @override @@ -60,7 +74,7 @@ class SSHForwardChannel implements SSHSocket { /// Write to this sink to send data to the remote host. @override - StreamSink> get sink => _sinkController.sink; + StreamSink> get sink => _sink; /// Close our end of the channel. Returns a future that waits for the /// other side to close. @@ -80,9 +94,62 @@ class SSHForwardChannel implements SSHSocket { /// Force flush any buffered outgoing data. @override Future flush() async { - await Future.microtask(() {}); - await _channel.flush(); + final barrier = Completer(); + _sinkController.add(_SSHForwardFlushBarrier(barrier)); + await barrier.future; + } + + Future _completeFlushBarrier(Completer barrier) async { + try { + await _channel.flush(); + barrier.complete(); + } catch (error, stackTrace) { + barrier.completeError(error, stackTrace); + } + } +} + +sealed class _SSHForwardUploadEvent {} + +class _SSHForwardData extends _SSHForwardUploadEvent { + _SSHForwardData(this.data); + + final List data; +} + +class _SSHForwardFlushBarrier extends _SSHForwardUploadEvent { + _SSHForwardFlushBarrier(this.completer); + + final Completer completer; +} + +class _SSHForwardSink implements StreamSink> { + _SSHForwardSink(this._controller); + + final StreamController<_SSHForwardUploadEvent> _controller; + + @override + void add(List data) { + _controller.add(_SSHForwardData(data)); } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _controller.addError(error, stackTrace); + } + + @override + Future addStream(Stream> stream) async { + await for (final data in stream) { + add(data); + } + } + + @override + Future close() => _controller.close(); + + @override + Future get done => _controller.done; } class SSHX11Channel extends SSHForwardChannel { diff --git a/test/src/algorithm/ssh_cipher_type_test.dart b/test/src/algorithm/ssh_cipher_type_test.dart index aede42d..239b9e2 100644 --- a/test/src/algorithm/ssh_cipher_type_test.dart +++ b/test/src/algorithm/ssh_cipher_type_test.dart @@ -105,6 +105,9 @@ void main() { test('Default values are set correctly', () { final algorithms = SSHAlgorithms(); + expect(SSHKexType.x25519Rfc.name, 'curve25519-sha256'); + expect(algorithms.kex.toNameList().first, 'curve25519-sha256'); + expect( algorithms.kex, equals([ diff --git a/test/src/sftp/sftp_client_protocol_test.dart b/test/src/sftp/sftp_client_protocol_test.dart index f864d65..e0c73ff 100644 --- a/test/src/sftp/sftp_client_protocol_test.dart +++ b/test/src/sftp/sftp_client_protocol_test.dart @@ -98,6 +98,22 @@ void main() { harness.dispose(); }); + test('close aborts a pending handshake', () async { + final harness = _SftpHarness(); + await harness.nextOutgoingPacket(); + + final handshakeExpectation = expectLater( + harness.client.handshake, + throwsA(isA()), + ); + final closeFuture = harness.client.close(); + harness.closeRemote(); + + await closeFuture; + await handshakeExpectation; + harness.dispose(); + }); + test('close closes the underlying channel', () async { final harness = _SftpHarness(); await harness.nextOutgoingPacket(); diff --git a/test/src/socket/dynamic_forward_io_test.dart b/test/src/socket/dynamic_forward_io_test.dart index e6528eb..3dccf09 100644 --- a/test/src/socket/dynamic_forward_io_test.dart +++ b/test/src/socket/dynamic_forward_io_test.dart @@ -400,7 +400,7 @@ void main() { // Send some data then close client side (half-close / EOF). client.add(utf8.encode('data')); await client.close(); - await Future.delayed(const Duration(milliseconds: 30)); + await dialed.remoteEof.timeout(const Duration(seconds: 1)); }); test('handles handshake buffer overflow gracefully', () async { @@ -414,18 +414,18 @@ void main() { final client = await Socket.connect(forward.host, forward.port); addTearDown(() => client.close()); + final incoming = client.asBroadcastStream(); + final clientDone = incoming.drain(); // Send a valid greeting but then flood the handshake buffer beyond // kMaxHandshakeSize (32768). The server should close the connection // rather than keep buffering indefinitely. - await _sendGreeting(client, client.asBroadcastStream()); + await _sendGreeting(client, incoming); final huge = Uint8List(33000); client.add(huge); - // Give the server time to detect the overflow and close. The test - // passes if the server does not crash — the forward is still usable - // for a new connection after the overflow victim is cleaned up. - await Future.delayed(const Duration(milliseconds: 100)); + // The overflow victim must be closed before the forward is reused. + await clientDone.timeout(const Duration(seconds: 1)); // Verify the forward still accepts new connections. final client2 = await Socket.connect(forward.host, forward.port); @@ -499,14 +499,21 @@ Future _readAtLeast( } class _DialedTunnel { - _DialedTunnel._(this.channel, this._controller, this.sentToRemote); + _DialedTunnel._( + this.channel, + this._controller, + this.sentToRemote, + this.remoteEof, + ); final SSHForwardChannel channel; final SSHChannelController _controller; final List sentToRemote; + final Future remoteEof; factory _DialedTunnel.create() { final sentToRemote = []; + final remoteEof = Completer(); final controller = SSHChannelController( localId: 1, @@ -518,6 +525,9 @@ class _DialedTunnel { sendMessage: (message) { if (message is SSH_Message_Channel_Data) { sentToRemote.addAll(message.data); + } else if (message is SSH_Message_Channel_EOF && + !remoteEof.isCompleted) { + remoteEof.complete(); } }, ); @@ -526,6 +536,7 @@ class _DialedTunnel { SSHForwardChannel(controller.channel), controller, sentToRemote, + remoteEof.future, ); } diff --git a/test/src/ssh_agent_test.dart b/test/src/ssh_agent_test.dart index d98e7b4..5a7acd0 100644 --- a/test/src/ssh_agent_test.dart +++ b/test/src/ssh_agent_test.dart @@ -318,7 +318,7 @@ void main() { ), ); - await Future.delayed(Duration.zero); + await controller.channel.done; // The channel should have been destroyed, no requests processed. expect(handler.requests, isEmpty); @@ -355,7 +355,7 @@ void main() { ), ); - await Future.delayed(Duration.zero); + await controller.channel.done; expect(handler.requests, isEmpty); diff --git a/test/src/ssh_flush_test.dart b/test/src/ssh_flush_test.dart index 9c53f5b..2ad377e 100644 --- a/test/src/ssh_flush_test.dart +++ b/test/src/ssh_flush_test.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:io'; -import 'dart:mirrors'; import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; +import 'package:dartssh2/src/message/base.dart'; import 'package:dartssh2/src/ssh_channel.dart'; import 'package:test/test.dart'; @@ -42,21 +42,12 @@ void main() { await client.flush(); expect(socket.flushCount, 1); - final clientLibrary = reflectClass(SSHClient).owner as LibraryMirror; - Symbol privateSymbol(String name) => - MirrorSystem.getSymbol(name, clientLibrary); - - // Invoke _acceptChannel using reflection to verify onFlush setup. - final channelController = reflect(client).invoke( - privateSymbol('_acceptChannel'), - [], - { - #localChannelId: 1, - #remoteChannelId: 2, - #remoteInitialWindowSize: 1024, - #remoteMaximumPacketSize: 1024, - }, - ).reflectee as SSHChannelController; + final channelController = client.acceptChannelForTesting( + localChannelId: 1, + remoteChannelId: 2, + remoteInitialWindowSize: 1024, + remoteMaximumPacketSize: 1024, + ); expect(channelController.onFlush, isNotNull); await channelController.flush(); @@ -112,22 +103,30 @@ void main() { }); group('SSHForwardChannel.flush', () { - test('delegates to channel.flush', () async { + test('flushes after data exactly exhausts the remote window', () async { var flushed = false; + final sentData = []; final controller = SSHChannelController( localId: 1, localMaximumPacketSize: 1024, localInitialWindowSize: 1024, remoteId: 2, - remoteMaximumPacketSize: 1024, - remoteInitialWindowSize: 1024, - sendMessage: (msg) {}, + remoteMaximumPacketSize: 3, + remoteInitialWindowSize: 3, + sendMessage: (message) { + if (message is SSH_Message_Channel_Data) { + sentData.addAll(message.data); + } + }, onFlush: () async { flushed = true; }, ); final forwardChannel = SSHForwardChannel(controller.channel); - await forwardChannel.flush(); + forwardChannel.sink.add([1, 2, 3]); + await forwardChannel.flush().timeout(const Duration(seconds: 1)); + + expect(sentData, [1, 2, 3]); expect(flushed, isTrue); }); }); diff --git a/test/src/ssh_transport_version_test.dart b/test/src/ssh_transport_version_test.dart index 8627b15..a92c274 100644 --- a/test/src/ssh_transport_version_test.dart +++ b/test/src/ssh_transport_version_test.dart @@ -73,11 +73,27 @@ void main() { username: 'demo', ); - // Send the version banner followed by some extra data in one go. - socket.addIncoming('SSH-2.0-OpenSSH_3.6.1p2\r\nSSH-2.0-SecondLine\r\n'); + // Send the version banner followed by an invalid packet length in the + // same chunk. Processing must continue into the queued remainder and + // surface the packet error instead of stopping after the banner. + socket.addRawIncoming( + Uint8List.fromList([ + ...latin1.encode('SSH-2.0-OpenSSH_3.6.1p2\r\n'), + 0, + 0, + 0, + 0, + ]), + ); - // Pump until the client processes the first version. - await _pumpUntil(() => client.remoteVersion != null); + await expectLater( + client.authenticated, + throwsA( + predicate((error) { + return error is SSHAuthAbortError && error.reason is SSHPacketError; + }), + ), + ); expect(client.remoteVersion, 'SSH-2.0-OpenSSH_3.6.1p2');